gloomberb 0.17.3 → 0.17.5

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 (164) hide show
  1. package/package.json +2 -1
  2. package/patches/electrobun@1.18.1.patch +80 -0
  3. package/src/api-client/chat.ts +28 -0
  4. package/src/api-client/data.ts +4 -1
  5. package/src/api-client/errors.ts +11 -1
  6. package/src/api-client/index.ts +4 -1
  7. package/src/api-client/normalizers.ts +21 -0
  8. package/src/api-client/research-activity.ts +10 -3
  9. package/src/api-client/socket.ts +27 -0
  10. package/src/app/pane-runtime/ticker-open-runtime.ts +2 -1
  11. package/src/app/runtime/startup.ts +4 -0
  12. package/src/app/runtime/update.ts +50 -3
  13. package/src/app.tsx +2 -0
  14. package/src/ask-gloom/pane-context.ts +11 -1
  15. package/src/cli/commands/overview.ts +2 -2
  16. package/src/cli/commands/system.ts +19 -1
  17. package/src/cli/glossary.ts +34 -4
  18. package/src/cli/index.ts +6 -0
  19. package/src/cli/pane-functions/function-help.ts +2 -2
  20. package/src/cli/pane-functions/market-session.ts +18 -3
  21. package/src/components/chart/composite/format.ts +7 -0
  22. package/src/components/chart/composite/legend.tsx +8 -11
  23. package/src/components/command-bar/commands/direct/index.ts +47 -0
  24. package/src/components/command-bar/commands/registry.ts +8 -0
  25. package/src/components/command-bar/panel/prompt-binding.ts +8 -0
  26. package/src/components/command-bar/routes/root/command-items.ts +16 -1
  27. package/src/components/command-bar/surface/index.tsx +2 -1
  28. package/src/components/command-bar/workflow/ops.ts +4 -3
  29. package/src/components/form-modal/confirm.tsx +1 -0
  30. package/src/components/form-modal/request.ts +2 -0
  31. package/src/components/index.ts +1 -1
  32. package/src/components/layout/detached-pane-shell.tsx +6 -1
  33. package/src/components/layout/pane/sidebar.tsx +24 -11
  34. package/src/components/layout/shell/index.tsx +5 -1
  35. package/src/components/layout/shell/menu.ts +8 -0
  36. package/src/components/layout/shell/pane/capture.ts +29 -12
  37. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  38. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  39. package/src/components/metric-treemap/index.tsx +1 -1
  40. package/src/components/ui/button.tsx +24 -9
  41. package/src/components/ui/confirm-dialog.tsx +30 -4
  42. package/src/components/ui/fields.tsx +5 -0
  43. package/src/components/ui/index.ts +1 -1
  44. package/src/components/ui/status.tsx +148 -5
  45. package/src/data/config/store/normalize.ts +8 -0
  46. package/src/i18n/es.ts +2 -0
  47. package/src/i18n/ja.ts +2 -0
  48. package/src/i18n/ko.ts +2 -0
  49. package/src/i18n/zh-cn.ts +2 -0
  50. package/src/i18n/zh-tw.ts +2 -0
  51. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  52. package/src/market-data/coordinator/events.ts +5 -1
  53. package/src/market-data/coordinator/index.ts +4 -0
  54. package/src/market-data/coordinator/quotes.ts +4 -0
  55. package/src/market-data/futures-board.ts +44 -1
  56. package/src/market-data/hooks.tsx +168 -29
  57. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  58. package/src/plugins/builtin/alerts/cli.ts +72 -11
  59. package/src/plugins/builtin/awards/headless.ts +5 -1
  60. package/src/plugins/builtin/broker-manager/detail.tsx +2 -2
  61. package/src/plugins/builtin/broker-manager/model.ts +2 -2
  62. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  63. package/src/plugins/builtin/chart-composer/headless.ts +2 -1
  64. package/src/plugins/builtin/chart-composer/pane.tsx +20 -2
  65. package/src/plugins/builtin/chat/content/footer.tsx +5 -2
  66. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  67. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  68. package/src/plugins/builtin/chat/controller/index.ts +69 -21
  69. package/src/plugins/builtin/chat/controller/messages.ts +14 -2
  70. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  71. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  72. package/src/plugins/builtin/chat/controller/state.ts +15 -0
  73. package/src/plugins/builtin/chat/controller/unread-people.ts +240 -0
  74. package/src/plugins/builtin/chat/controller/view.ts +2 -0
  75. package/src/plugins/builtin/chat/index.tsx +1 -1
  76. package/src/plugins/builtin/chat/share/flow.tsx +18 -9
  77. package/src/plugins/builtin/chat/unread-badge.ts +38 -7
  78. package/src/plugins/builtin/cloud/askg/pane-attachment.ts +2 -3
  79. package/src/plugins/builtin/cot/model.ts +20 -0
  80. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  81. package/src/plugins/builtin/docs/pane.tsx +1 -1
  82. package/src/plugins/builtin/futures/index.tsx +2 -3
  83. package/src/plugins/builtin/futures/model.ts +4 -2
  84. package/src/plugins/builtin/futures/series.ts +3 -3
  85. package/src/plugins/builtin/futures-curve/headless.ts +13 -6
  86. package/src/plugins/builtin/futures-curve/index.tsx +3 -3
  87. package/src/plugins/builtin/futures-curve/model.ts +64 -4
  88. package/src/plugins/builtin/futures-curve/pane.tsx +11 -4
  89. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  90. package/src/plugins/builtin/kelly-sizer/headless.ts +1 -1
  91. package/src/plugins/builtin/kelly-sizer/history.ts +5 -3
  92. package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
  93. package/src/plugins/builtin/macro-day/release-table.ts +3 -3
  94. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  95. package/src/plugins/builtin/mna/model.ts +2 -1
  96. package/src/plugins/builtin/news/headless.ts +4 -2
  97. package/src/plugins/builtin/options-positioning/model.ts +14 -0
  98. package/src/plugins/builtin/options-positioning/pane.tsx +17 -10
  99. package/src/plugins/builtin/perps/client.ts +77 -6
  100. package/src/plugins/builtin/perps/headless.ts +25 -14
  101. package/src/plugins/builtin/perps/index.tsx +1 -1
  102. package/src/plugins/builtin/perps/model.ts +32 -0
  103. package/src/plugins/builtin/perps/pane.tsx +5 -4
  104. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  105. package/src/plugins/builtin/rate-path/model.ts +1 -1
  106. package/src/plugins/builtin/rate-path/pane.tsx +2 -1
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -3
  108. package/src/plugins/builtin/research/event-sources.ts +1 -1
  109. package/src/plugins/builtin/research/relative-valuation-model.ts +6 -0
  110. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -3
  111. package/src/plugins/builtin/sectors/client.ts +46 -19
  112. package/src/plugins/builtin/sectors/sector-model.ts +22 -10
  113. package/src/plugins/builtin/thirteenf/api.ts +60 -43
  114. package/src/plugins/builtin/thirteenf/data.ts +76 -35
  115. package/src/plugins/builtin/thirteenf/headless.ts +2 -0
  116. package/src/plugins/builtin/thirteenf/pane.tsx +10 -2
  117. package/src/plugins/builtin/yield-curve/curves.ts +15 -1
  118. package/src/plugins/builtin/yield-curve/headless.ts +17 -7
  119. package/src/plugins/builtin/yield-curve/history.ts +2 -0
  120. package/src/plugins/builtin/yield-curve/index.tsx +23 -18
  121. package/src/plugins/builtin/yield-curve/series.ts +19 -9
  122. package/src/plugins/builtin/yield-curve/treasury-data.ts +2 -0
  123. package/src/plugins/builtin/yield-curve/world.ts +4 -0
  124. package/src/plugins/registry/host-actions.ts +3 -0
  125. package/src/renderers/browser/app-services.ts +7 -1
  126. package/src/renderers/browser/config-host.ts +55 -2
  127. package/src/renderers/browser/first-screen.ts +193 -0
  128. package/src/renderers/browser/main.tsx +59 -7
  129. package/src/renderers/dom/build-assets.ts +17 -4
  130. package/src/renderers/dom/desktop/controls.tsx +4 -0
  131. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -0
  132. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +3 -0
  133. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  134. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  135. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  136. package/src/renderers/electrobun/bun/desktop/update.ts +115 -12
  137. package/src/renderers/electrobun/shared/protocol.ts +3 -2
  138. package/src/renderers/electrobun/view/update-host.ts +2 -2
  139. package/src/shares/bug-report-dialog.tsx +162 -0
  140. package/src/shares/bug-report-flow.tsx +84 -0
  141. package/src/shares/bug-report.ts +42 -0
  142. package/src/shares/capture-flow.ts +19 -0
  143. package/src/shares/capture-preview.tsx +129 -0
  144. package/src/shares/chat.ts +10 -1
  145. package/src/shares/dialog.tsx +16 -91
  146. package/src/shares/flow.tsx +12 -16
  147. package/src/shares/personal-pane.ts +35 -1
  148. package/src/sources/gloomberb-cloud/normalizers.ts +12 -4
  149. package/src/telemetry/usage-counts.ts +16 -0
  150. package/src/tickers/open-target.ts +61 -11
  151. package/src/time-series/resolve.ts +26 -9
  152. package/src/time-series/spec.ts +8 -0
  153. package/src/types/config.ts +14 -0
  154. package/src/ui/resize-settle.ts +128 -0
  155. package/src/updater/channel.ts +51 -0
  156. package/src/updater/github-releases.ts +85 -16
  157. package/src/updater/index.ts +125 -16
  158. package/src/utils/calendar-date.ts +56 -0
  159. package/src/utils/datetime-format.ts +7 -1
  160. package/src/utils/decimal-comma.ts +31 -0
  161. package/src/utils/futures-generic.ts +25 -4
  162. package/src/utils/plugin-proxy-hosts.json +5 -0
  163. package/src/utils/semver.ts +47 -2
  164. package/src/utils/utc-time.ts +14 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.17.3",
3
+ "version": "0.17.5",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/public/index.ts",
@@ -86,6 +86,7 @@
86
86
  "check:pane-screenshot": "bun run scripts/pane-chrome/screenshot.ts",
87
87
  "check:pane-rest": "bun run scripts/pane-chrome/rest-diff.ts",
88
88
  "check:pane-layout": "bun run scripts/pane-chrome/layout-sweep.ts",
89
+ "audit:ui": "bun run scripts/pane-chrome/ui-audit.ts",
89
90
  "benchmark:pane-drag:compare": "bun run scripts/pane-chrome/frame-budget.ts",
90
91
  "test:global-upgrade": "bun run scripts/smoke-global-upgrade.ts",
91
92
  "release": "./scripts/release.sh"
@@ -53,6 +53,86 @@ index c26c2c977ee1fc25a8dc144b85bac8626a45f572..8920a2905380228c23cba66c20d9bfd1
53
53
  frame: {
54
54
  width: this.frame.width,
55
55
  height: this.frame.height,
56
+ diff --git a/dist/api/bun/core/Updater.ts b/dist/api/bun/core/Updater.ts
57
+ index 39daf6d341780af8e30c2c50327162863f5977ec..bf4a4b615f84904759d4bcd57be0d899c9464a5f 100644
58
+ --- a/dist/api/bun/core/Updater.ts
59
+ +++ b/dist/api/bun/core/Updater.ts
60
+ @@ -132,6 +132,19 @@ let updateInfo: {
61
+ error: string;
62
+ };
63
+
64
+ +// Where updates are fetched from when it is not the channel and baseUrl that
65
+ +// version.json names. Only the download URLs read it: the app data folder and
66
+ +// the "dev" check keep the installed build's own channel.
67
+ +let updateSource: { channel: string; baseUrl: string } | null = null;
68
+ +
69
+ +function updateSourceChannel(): string {
70
+ + return updateSource?.channel ?? localInfo.channel;
71
+ +}
72
+ +
73
+ +function updateSourceBaseUrl(): string {
74
+ + return updateSource?.baseUrl ?? localInfo.baseUrl;
75
+ +}
76
+ +
77
+ function cleanupExtractionFolder(
78
+ extractionFolder: string,
79
+ keepTarHash: string,
80
+ @@ -176,6 +189,12 @@ const Updater = {
81
+ onStatusChangeCallback = callback;
82
+ },
83
+
84
+ + // Checks and downloads from `source` (a channel prefix and the URL its
85
+ + // files sit under) until it is set again; null goes back to version.json.
86
+ + setUpdateSource: (source: { channel: string; baseUrl: string } | null) => {
87
+ + updateSource = source;
88
+ + },
89
+ +
90
+ // todo: allow switching channels, by default will check the current channel
91
+ checkForUpdate: async () => {
92
+ emitStatus("checking", "Checking for updates...");
93
+ @@ -196,11 +215,11 @@ const Updater = {
94
+
95
+ const cacheBuster = Math.random().toString(36).substring(7);
96
+ const platformPrefix = getPlatformPrefix(
97
+ - localInfo.channel,
98
+ + updateSourceChannel(),
99
+ currentOS,
100
+ currentArch,
101
+ );
102
+ - const updateInfoUrl = `${localInfo.baseUrl.replace(/\/+$/, "")}/${platformPrefix}-update.json?${cacheBuster}`;
103
+ + const updateInfoUrl = `${updateSourceBaseUrl().replace(/\/+$/, "")}/${platformPrefix}-update.json?${cacheBuster}`;
104
+
105
+ try {
106
+ const updateInfoResponse = await fetch(updateInfoUrl);
107
+ @@ -329,11 +348,11 @@ const Updater = {
108
+
109
+ // check if there's a patch file for it
110
+ const platformPrefix = getPlatformPrefix(
111
+ - localInfo.channel,
112
+ + updateSourceChannel(),
113
+ currentOS,
114
+ currentArch,
115
+ );
116
+ - const patchUrl = `${localInfo.baseUrl.replace(/\/+$/, "")}/${platformPrefix}-${currentHash}.patch`;
117
+ + const patchUrl = `${updateSourceBaseUrl().replace(/\/+$/, "")}/${platformPrefix}-${currentHash}.patch`;
118
+ emitStatus(
119
+ "fetching-patch",
120
+ `Checking for patch: ${currentHash.slice(0, 8)}`,
121
+ @@ -612,12 +631,12 @@ const Updater = {
122
+
123
+ const cacheBuster = Math.random().toString(36).substring(7);
124
+ const platformPrefix = getPlatformPrefix(
125
+ - localInfo.channel,
126
+ + updateSourceChannel(),
127
+ currentOS,
128
+ currentArch,
129
+ );
130
+ const tarballName = getTarballFileName(appFileName, currentOS);
131
+ - const urlToLatestTarball = `${localInfo.baseUrl.replace(/\/+$/, "")}/${platformPrefix}-${tarballName}`;
132
+ + const urlToLatestTarball = `${updateSourceBaseUrl().replace(/\/+$/, "")}/${platformPrefix}-${tarballName}`;
133
+ const prevVersionCompressedTarballPath = join(
134
+ appDataFolder,
135
+ "self-extraction",
56
136
  diff --git a/dist/api/bun/proc/native.ts b/dist/api/bun/proc/native.ts
57
137
  index f0f6c2532ba2eb467ec1d452a25c835b6db72661..ae77cf58d32392ad4cc8e2103ac7ed27baae8bea 100644
58
138
  --- a/dist/api/bun/proc/native.ts
@@ -154,16 +154,40 @@ export class CloudChatApi {
154
154
  );
155
155
  }
156
156
 
157
+ /**
158
+ * `seen`: the app showed them where the reader was looking (the open
159
+ * channel, a toast in the foreground), which also cancels the phone push
160
+ * waiting on them. A server that predates it ignores the field.
161
+ */
157
162
  async markNotificationsDelivered(
158
163
  notificationIds: string[],
164
+ options: { seen?: boolean } = {},
159
165
  ): Promise<{ delivered: number }> {
160
166
  return this.request<{ delivered: number }>(
161
167
  "/chat/notifications/delivered",
168
+ {
169
+ method: "POST",
170
+ body: JSON.stringify({ notificationIds, ...(options.seen ? { seen: true } : {}) }),
171
+ },
172
+ );
173
+ }
174
+
175
+ /**
176
+ * The notifications among these the reader has neither read nor been shown
177
+ * in front of them, on any device. Throws on a server without the route
178
+ * (404); the caller raises the alert then, as before.
179
+ */
180
+ async getUnseenNotificationIds(notificationIds: string[]): Promise<string[]> {
181
+ const answer = await this.request<{ notificationIds?: unknown }>(
182
+ "/chat/notifications/unseen",
162
183
  {
163
184
  method: "POST",
164
185
  body: JSON.stringify({ notificationIds }),
165
186
  },
166
187
  );
188
+ return Array.isArray(answer?.notificationIds)
189
+ ? answer.notificationIds.filter((id): id is string => typeof id === "string")
190
+ : notificationIds;
167
191
  }
168
192
 
169
193
  async openDirectChannel(target: {
@@ -327,6 +351,10 @@ export class CloudChatApi {
327
351
  return this.options.socket.subscribeChatNotifications(listener);
328
352
  }
329
353
 
354
+ subscribeChannelState(listener: (state: ChatChannelState) => void): () => void {
355
+ return this.options.socket.subscribeChatChannelState(listener);
356
+ }
357
+
330
358
  subscribePresence(listener: ChatPresenceListener): () => void {
331
359
  return this.options.socket.subscribeChatPresence(listener);
332
360
  }
@@ -953,8 +953,11 @@ export class CloudDataApi {
953
953
  async getCloudSec13F(
954
954
  path: string,
955
955
  params: Record<string, string | number | undefined> = {},
956
+ options?: { signal?: AbortSignal },
956
957
  ): Promise<unknown> {
957
- return this.request<unknown>(cloudSec13FPath(path, params));
958
+ return this.request<unknown>(cloudSec13FPath(path, params), {
959
+ signal: options?.signal,
960
+ });
958
961
  }
959
962
 
960
963
  /**
@@ -75,12 +75,22 @@ export function parseApiErrorMessage(body: string): string {
75
75
  (value): value is string =>
76
76
  typeof value === "string" && value.trim().length > 0,
77
77
  );
78
- return parts.join(" ") || body;
78
+ return parts.join(" ") || validationErrorText(parsed) || body;
79
79
  } catch {
80
80
  return htmlErrorTitle(body) ?? body;
81
81
  }
82
82
  }
83
83
 
84
+ /**
85
+ * A request the API's schema refused comes back as the values it was sent
86
+ * (`{"type":"validation","on":"query","found":{...}}`), which is no message to
87
+ * show; it is said in words instead.
88
+ */
89
+ function validationErrorText(parsed: Record<string, unknown>): string | null {
90
+ if (parsed.type !== "validation") return null;
91
+ return parsed.on === "query" ? "Gloom Cloud did not accept an option or filter value." : "Gloom Cloud did not accept the request.";
92
+ }
93
+
84
94
  /** A JSON error body as an object, or undefined for any other body. */
85
95
  export function parseApiErrorDetails(body: string): Readonly<Record<string, unknown>> | undefined {
86
96
  try {
@@ -60,7 +60,8 @@ export type {
60
60
  /** Server-side caps for `/assist/command`; enforced here so a 422 is never sent. */
61
61
  const ASSIST_QUERY_MAX_LENGTH = 200;
62
62
  const ASSIST_COMMAND_LIMIT = 150;
63
- const ASSIST_ARG_OPTION_LIMIT = 64;
63
+ /** The futures curve's root list is the longest a command sends: 76 since GLO-783. */
64
+ export const ASSIST_ARG_OPTION_LIMIT = 128;
64
65
  const ASSIST_ARG_OPTION_VALUE_MAX_LENGTH = 40;
65
66
  const ASSIST_ARG_OPTION_LABEL_MAX_LENGTH = 60;
66
67
  const ASSIST_REQUEST_TIMEOUT_MS = 6_000;
@@ -646,6 +647,7 @@ class GloomApiClient {
646
647
  getChatState = this.chat.getState.bind(this.chat);
647
648
  updateChatChannelState = this.chat.updateChannelState.bind(this.chat);
648
649
  markChatNotificationsDelivered = this.chat.markNotificationsDelivered.bind(this.chat);
650
+ getUnseenChatNotificationIds = this.chat.getUnseenNotificationIds.bind(this.chat);
649
651
  openDirectChannel = this.chat.openDirectChannel.bind(this.chat);
650
652
  openGroupChannel = this.chat.openGroupChannel.bind(this.chat);
651
653
  getMessages = this.chat.getMessages.bind(this.chat);
@@ -658,6 +660,7 @@ class GloomApiClient {
658
660
  subscribeChatReactions = this.chat.subscribeReactions.bind(this.chat);
659
661
  connectChannel = this.chat.connectChannel.bind(this.chat);
660
662
  subscribeChatNotifications = this.chat.subscribeNotifications.bind(this.chat);
663
+ subscribeChatChannelState = this.chat.subscribeChannelState.bind(this.chat);
661
664
  subscribeChatPresence = this.chat.subscribePresence.bind(this.chat);
662
665
  getChatDiscordLink = this.chat.getDiscordLink.bind(this.chat);
663
666
  unlinkChatDiscord = this.chat.unlinkDiscord.bind(this.chat);
@@ -2,6 +2,7 @@ import type {
2
2
  ChatAccountType,
3
3
  ChatAttachment,
4
4
  ChatChannel,
5
+ ChatChannelState,
5
6
  ChatDiscordLink,
6
7
  ChatMessage,
7
8
  ChatMessageOrigin,
@@ -191,6 +192,26 @@ export function normalizeChatNotification(
191
192
  };
192
193
  }
193
194
 
195
+ /** A `chat.channel_state` frame, or null when it is not one. */
196
+ export function normalizeChatChannelStateFrame(data: unknown): ChatChannelState | null {
197
+ if (!data || typeof data !== "object") return null;
198
+ const state = data as Partial<ChatChannelState>;
199
+ if (
200
+ typeof state.channelId !== "string"
201
+ || typeof state.notificationsEnabled !== "boolean"
202
+ || typeof state.unreadCount !== "number"
203
+ || (state.lastReadMessageId !== null && typeof state.lastReadMessageId !== "string")
204
+ ) {
205
+ return null;
206
+ }
207
+ return {
208
+ channelId: state.channelId,
209
+ notificationsEnabled: state.notificationsEnabled,
210
+ lastReadMessageId: state.lastReadMessageId,
211
+ unreadCount: state.unreadCount,
212
+ };
213
+ }
214
+
194
215
  export function normalizeChatChannel(
195
216
  channel: ChatChannel,
196
217
  fallbackKind: ChatChannel["kind"] = "public",
@@ -28,7 +28,10 @@ export type ResearchActivity =
28
28
  | "onboarding_signed_in"
29
29
  | "onboarding_pro_viewed"
30
30
  | "onboarding_completed"
31
- | "onboarding_skipped";
31
+ | "onboarding_skipped"
32
+ // The first action of a web visitor counted in the first-screen experiment,
33
+ // in that first session. Keep in sync with the server's list.
34
+ | "first_screen_engaged";
32
35
  export type ResearchFeature =
33
36
  | "overview"
34
37
  | "chart"
@@ -451,7 +454,10 @@ function webExperimentAttribution(): { experiments?: string } {
451
454
  * visitors on term.gloom.sh with an analytics id (no Do Not Track or Global
452
455
  * Privacy Control) and not driven by automation are asked. See web-experiments.ts.
453
456
  */
454
- export function exposeWebExperiment(experiment: string): Promise<string | null> {
457
+ export function exposeWebExperiment(
458
+ experiment: string,
459
+ { placement, signal }: { placement?: string; signal?: AbortSignal } = {},
460
+ ): Promise<string | null> {
455
461
  if (!researchTelemetryAllowed()) return Promise.resolve(null);
456
462
  if (getCurrentPluginTarget() !== "web") return Promise.resolve(null);
457
463
  try {
@@ -468,7 +474,8 @@ export function exposeWebExperiment(experiment: string): Promise<string | null>
468
474
  attribution: attributionPayload(),
469
475
  experiment: key,
470
476
  variant,
471
- }),
477
+ ...(placement && ACTIVITY_ID.test(placement) ? { placement } : {}),
478
+ }, signal),
472
479
  });
473
480
  } catch {
474
481
  return Promise.resolve(null);
@@ -1,6 +1,7 @@
1
1
  import type { TapeFeedEvent } from "./tape";
2
2
  import type {
3
3
  AuthUser,
4
+ ChatChannelState,
4
5
  ChatMessage,
5
6
  ChatNotification,
6
7
  ChatReactionsUpdate,
@@ -11,6 +12,7 @@ import type {
11
12
  TeamNotification,
12
13
  } from "./types";
13
14
  import {
15
+ normalizeChatChannelStateFrame,
14
16
  normalizeChatMessage,
15
17
  normalizeChatNotification,
16
18
  normalizeChatReactions,
@@ -42,6 +44,7 @@ const cloudApiLog = debugLog.createLogger("cloud-api");
42
44
 
43
45
  type ChannelListener = (message: ChatMessage) => void;
44
46
  type ChatNotificationListener = (notification: ChatNotification) => void;
47
+ type ChatChannelStateListener = (state: ChatChannelState) => void;
45
48
  type ChatPresenceListener = (onlineCount: number) => void;
46
49
  type ChatReactionsListener = (update: ChatReactionsUpdate) => void;
47
50
  type TeamNotificationListener = (notification: TeamNotification) => void;
@@ -108,6 +111,8 @@ export class CloudApiSocket {
108
111
  private readonly channelListeners = new Map<string, Set<ChannelListener>>();
109
112
  private readonly chatNotificationListeners =
110
113
  new Set<ChatNotificationListener>();
114
+ private readonly chatChannelStateListeners =
115
+ new Set<ChatChannelStateListener>();
111
116
  private readonly chatPresenceListeners = new Set<ChatPresenceListener>();
112
117
  private readonly chatReactionsListeners = new Set<ChatReactionsListener>();
113
118
  private readonly teamNotificationListeners =
@@ -249,6 +254,17 @@ export class CloudApiSocket {
249
254
  };
250
255
  }
251
256
 
257
+ /**
258
+ * The reader's read state or mute changed on another device or window. Like
259
+ * notifications, it never opens the socket by itself.
260
+ */
261
+ subscribeChatChannelState(listener: ChatChannelStateListener): () => void {
262
+ this.chatChannelStateListeners.add(listener);
263
+ return () => {
264
+ this.chatChannelStateListeners.delete(listener);
265
+ };
266
+ }
267
+
252
268
  /** A message's reactions changed in a channel this socket reads. */
253
269
  subscribeChatReactions(listener: ChatReactionsListener): () => void {
254
270
  this.chatReactionsListeners.add(listener);
@@ -511,6 +527,7 @@ export class CloudApiSocket {
511
527
  this.offeredFeatureListeners.clear();
512
528
  this.connectionListeners.clear();
513
529
  this.chatNotificationListeners.clear();
530
+ this.chatChannelStateListeners.clear();
514
531
  this.chatPresenceListeners.clear();
515
532
  this.chatReactionsListeners.clear();
516
533
  this.teamNotificationListeners.clear();
@@ -614,6 +631,16 @@ export class CloudApiSocket {
614
631
  return;
615
632
  }
616
633
 
634
+ if (parsed?.type === "chat.channel_state") {
635
+ const state = normalizeChatChannelStateFrame(parsed.data);
636
+ if (state) {
637
+ for (const listener of this.chatChannelStateListeners) {
638
+ listener(state);
639
+ }
640
+ }
641
+ return;
642
+ }
643
+
617
644
  if (parsed?.type === "team.notification" && parsed.data) {
618
645
  const notification = normalizeTeamNotification(
619
646
  parsed.data as TeamNotification,
@@ -21,6 +21,7 @@ import type { DataProvider } from "../../types/data-provider";
21
21
  import type { PinTickerOptions } from "../../types/plugin";
22
22
  import {
23
23
  resolveTickerOpenTarget,
24
+ tickerOpenFailureMessage,
24
25
  type TickerOpenTarget,
25
26
  } from "../../tickers/open-target";
26
27
  import { AmbiguousTickerError, findExactTickerSearchMatch } from "../../tickers/search";
@@ -73,7 +74,7 @@ export function useAppTickerOpenRuntime({
73
74
  tickerRepository,
74
75
  });
75
76
  if (!target && canPresentFeedback()) {
76
- pluginRegistry.notify({ body: `Could not open ${rawSymbol}.`, type: "error" });
77
+ pluginRegistry.notify({ body: tickerOpenFailureMessage(rawSymbol), type: "error" });
77
78
  }
78
79
  return target;
79
80
  } catch (err) {
@@ -107,6 +107,10 @@ export function useAppStartupRuntime({
107
107
  appLog.info("app activity propagated", { active: appActive });
108
108
  }, [appActive]);
109
109
 
110
+ useEffect(() => {
111
+ chatController.setRaisesBackgroundAlerts(!isDetachedWindow);
112
+ }, [isDetachedWindow]);
113
+
110
114
  // Runs for the life of the app: a freeze is only reportable if something
111
115
  // was watching the clock while it happened.
112
116
  useEffect(() => startMainThreadStallMonitor(), []);
@@ -7,13 +7,17 @@ import {
7
7
  canRestartToApply,
8
8
  canSelfUpdate,
9
9
  checkForUpdateDetailed,
10
+ discardStagedUpdate,
10
11
  performUpdate,
11
12
  type ReleaseInfo,
12
13
  type UpdateCheckResult,
13
14
  type UpdateProgress,
14
15
  } from "../../updater";
16
+ import { resolveUpdateChannel, stagedUpdateFitsChannel } from "../../updater/channel";
17
+ import type { UpdateChannel } from "../../types/config";
15
18
  import { VERSION } from "../../version";
16
19
  import { debugLog } from "../../utils/debug-log";
20
+ import { isPrereleaseVersion } from "../../utils/semver";
17
21
  import { reportCrash } from "../../telemetry/crash-reports";
18
22
  import { runAutomated } from "../../telemetry/usage-counts";
19
23
 
@@ -41,6 +45,7 @@ export function useAppUpdateRuntime({
41
45
  pluginRegistry,
42
46
  stateRef,
43
47
  updateAvailable,
48
+ updateChannel = "stable",
44
49
  updateCheckInProgress,
45
50
  updateProgress,
46
51
  }: {
@@ -50,6 +55,8 @@ export function useAppUpdateRuntime({
50
55
  pluginRegistry: PluginRegistry;
51
56
  stateRef: { current: AppState };
52
57
  updateAvailable: ReleaseInfo | null;
58
+ /** The channel in effect (`resolveUpdateChannel`); a change checks again at once. */
59
+ updateChannel?: UpdateChannel;
53
60
  updateCheckInProgress: boolean;
54
61
  updateProgress: unknown;
55
62
  }): {
@@ -97,7 +104,8 @@ export function useAppUpdateRuntime({
97
104
  // failed check like any other: the hourly one must not end as an
98
105
  // unhandled rejection, and a manual one must clear its progress state.
99
106
  // The timeout is still reported, as it was when it went unhandled.
100
- const result = await checkForUpdateDetailed(VERSION).catch((error: unknown): UpdateCheckResult => {
107
+ const channel = resolveUpdateChannel(stateRef.current.config);
108
+ const result = await checkForUpdateDetailed(VERSION, channel).catch((error: unknown): UpdateCheckResult => {
101
109
  const message = error instanceof Error ? error.message : "";
102
110
  if (message.startsWith("RPC request timed out")) {
103
111
  reportCrash(error, { kind: "unhandled-rejection" });
@@ -123,7 +131,7 @@ export function useAppUpdateRuntime({
123
131
 
124
132
  if (result.kind === "current") {
125
133
  dispatch({ type: "SET_UPDATE_AVAILABLE", release: null });
126
- dispatch({ type: "SET_UPDATE_NOTICE", notice: `Already on v${VERSION}` });
134
+ dispatch({ type: "SET_UPDATE_NOTICE", notice: result.notice ?? `Already on v${VERSION}` });
127
135
  return;
128
136
  }
129
137
 
@@ -147,6 +155,44 @@ export function useAppUpdateRuntime({
147
155
  return () => { clearInterval(interval); };
148
156
  }, [enabled, isDetachedWindow, runUpdateCheck]);
149
157
 
158
+ // A channel switch checks again at once and says what it found: nightly
159
+ // starts on today's build, stable says when this nightly build stays. An
160
+ // update already downloading or waiting for a restart is left to the effect
161
+ // below, which takes it back once it is ready if the channel rules it out.
162
+ const checkedChannelRef = useRef(updateChannel);
163
+ useEffect(() => {
164
+ if (!enabled || isDetachedWindow || checkedChannelRef.current === updateChannel) return;
165
+ checkedChannelRef.current = updateChannel;
166
+ if (updateInFlight(stateRef.current.updateProgress)) return;
167
+ // What the other channel offered, or how its download failed, is not this one's answer.
168
+ if (stateRef.current.updateProgress) dispatch({ type: "SET_UPDATE_PROGRESS", progress: null });
169
+ dispatch({ type: "SET_UPDATE_AVAILABLE", release: null });
170
+ void runUpdateCheck(true);
171
+ }, [dispatch, enabled, isDetachedWindow, runUpdateCheck, stateRef, updateChannel]);
172
+
173
+ // A downloaded update the channel does not allow (a nightly build waiting
174
+ // for a restart after the switch back to stable, or one that finished
175
+ // downloading after it) is taken back rather than installed by the restart,
176
+ // and stable checks again.
177
+ const discardingRef = useRef(false);
178
+ useEffect(() => {
179
+ if (!enabled || isDetachedWindow || discardingRef.current) return;
180
+ const { updateProgress: progress, updateAvailable: staged } = stateRef.current;
181
+ if (progress?.phase !== "ready" || stagedUpdateFitsChannel(staged, updateChannel)) return;
182
+ discardingRef.current = true;
183
+ void discardStagedUpdate().then((error) => {
184
+ discardingRef.current = false;
185
+ if (error) {
186
+ updateLog.warn("could not take back the staged update", { error, version: staged?.version });
187
+ dispatch({ type: "SET_UPDATE_NOTICE", notice: `Could not take back v${staged?.version ?? "?"}: ${error}` });
188
+ return;
189
+ }
190
+ dispatch({ type: "SET_UPDATE_PROGRESS", progress: null });
191
+ dispatch({ type: "SET_UPDATE_AVAILABLE", release: null });
192
+ void runUpdateCheck(true);
193
+ });
194
+ }, [dispatch, enabled, isDetachedWindow, runUpdateCheck, stateRef, updateAvailable, updateChannel, updateProgress]);
195
+
150
196
  // First launch on a new version: show that release's notes, then remember the version.
151
197
  useEffect(() => {
152
198
  if (!enabled || isDetachedWindow) return;
@@ -156,7 +202,8 @@ export function useAppUpdateRuntime({
156
202
  const nextConfig = { ...config, lastLaunchedVersion: VERSION };
157
203
  dispatch({ type: "SET_CONFIG", config: nextConfig });
158
204
  void saveConfigImmediately(nextConfig).catch(() => {});
159
- if (!isUpgrade) return;
205
+ // Nightly builds come daily and the changelog lists stable releases only.
206
+ if (!isUpgrade || isPrereleaseVersion(VERSION)) return;
160
207
  // Opened by the app, not the user: usage counts leave it out.
161
208
  void runAutomated(() => pluginRegistry.createPaneFromTemplateAsync("changelog-pane", {
162
209
  values: { version: VERSION },
package/src/app.tsx CHANGED
@@ -50,6 +50,7 @@ import { bindPluginRegistryRuntimeAccess } from "./app/runtime/plugin-bindings";
50
50
  import { useAppStartupRuntime } from "./app/runtime/startup";
51
51
  import { useTickerRefreshRuntime } from "./app/runtime/ticker-refresh";
52
52
  import { useAppUpdateRuntime } from "./app/runtime/update";
53
+ import { resolveUpdateChannel } from "./updater/channel";
53
54
  import { createCoreSyncContributors } from "./sync/core-contributors";
54
55
  import { useCloudSyncRuntime } from "./sync/react";
55
56
  import { RemoteControlHost, type RemoteControlAdapter } from "./remote/app-host";
@@ -278,6 +279,7 @@ function AppInner({
278
279
  pluginRegistry,
279
280
  stateRef,
280
281
  updateAvailable: state.updateAvailable,
282
+ updateChannel: resolveUpdateChannel(state.config),
281
283
  updateCheckInProgress: state.updateCheckInProgress,
282
284
  updateProgress: state.updateProgress,
283
285
  });
@@ -41,7 +41,7 @@ function scalarArgs(source: ArgSource, privateFields: PaneSharePrivateFields | u
41
41
  * that opens this pane (a chart opened with `GP` stays `GP`), else the first
42
42
  * template that opens it.
43
43
  */
44
- function functionCode(registry: Pick<PluginRegistry, "paneTemplates">, paneType: string, title: string): string | null {
44
+ export function functionCode(registry: Pick<PluginRegistry, "paneTemplates">, paneType: string, title: string): string | null {
45
45
  const prefixes = [...registry.paneTemplates.values()]
46
46
  .filter((template) => template.paneId === paneType && template.shortcut?.prefix)
47
47
  .map((template) => template.shortcut!.prefix.toUpperCase());
@@ -50,6 +50,16 @@ function functionCode(registry: Pick<PluginRegistry, "paneTemplates">, paneType:
50
50
  return prefixes[0] ?? null;
51
51
  }
52
52
 
53
+ /**
54
+ * A pane named by its function and what it shows, such as `DES AAPL`: its
55
+ * title, led by its function code unless the title already starts with it.
56
+ */
57
+ export function paneFunctionLabel(pane: { title: string; code: string | null; paneType: string }): string {
58
+ const title = pane.title.trim() || pane.paneType;
59
+ if (!pane.code) return title;
60
+ return title.split(/\s+/)[0]?.toUpperCase() === pane.code ? title : `${pane.code} ${title}`;
61
+ }
62
+
53
63
  export interface PaneAttachmentSource {
54
64
  registry: Pick<PluginRegistry, "paneTemplates" | "brokers" | "getPanePluginId" | "getBrokerPluginId">;
55
65
  pane: Pick<ResolvedPane, "instance" | "def">;
@@ -428,7 +428,7 @@ async function runFred(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
428
428
  }),
429
429
  }, {
430
430
  dateKey: "date",
431
- empty: fredEmptyMessage(startDate, latest),
431
+ empty: fredEmptyMessage(startDate, latest == null ? null : exportRow(latest)),
432
432
  ...(notes.length ? { footnote: notes.join("\n"), exportNotes: notes } : {}),
433
433
  exportIdentity: { symbolOrSeries: seriesId, currencyOrUnit: unit, interval },
434
434
  textColumns: [
@@ -486,7 +486,7 @@ async function runYieldCurve(rawArgs: string[], ctx: Parameters<CliCommandDef["e
486
486
  key: "changeBasisPoints",
487
487
  header: "1D bp",
488
488
  align: "right",
489
- format: (value) => typeof value === "number" ? formatBasisPoints(value / 100) : "-",
489
+ format: (value) => typeof value === "number" ? formatBasisPoints(value / 100, 1) : "-",
490
490
  },
491
491
  { key: "date", header: "Date" },
492
492
  { key: "seriesId", header: "Series" },
@@ -4,6 +4,7 @@ import { saveConfig } from "../../data/config/store";
4
4
  import { builtinPluginGroupMembers } from "../../plugins/ownership";
5
5
  import type { AppConfig, TelemetryConfig } from "../../types/config";
6
6
  import { canonicalTimeZone } from "../../utils/utc-time";
7
+ import { NIGHTLY_CHANNEL_SUMMARY, parseUpdateChannel, withUpdateChannel } from "../../updater/channel";
7
8
  import type { CliCommandDef } from "../../types/plugin";
8
9
  import { resolveCliDataDir, withCliServices, withConfigData } from "../context";
9
10
  import { CLI_COMMAND_GROUPS } from "../help";
@@ -33,6 +34,8 @@ const STAR_PROMPT_CONFIG_KEY = "starPrompt.enabled";
33
34
  const TIMEZONE_CONFIG_KEY = "timezone";
34
35
  /** What `config set timezone` takes to go back to UTC alone. */
35
36
  const NO_TIMEZONE = "none";
37
+ /** Stable releases, or nightly builds as well. */
38
+ const UPDATE_CHANNEL_CONFIG_KEY = "updates.channel";
36
39
  const EDITABLE_CONFIG_KEYS = [
37
40
  "baseCurrency",
38
41
  "refreshIntervalMinutes",
@@ -41,6 +44,7 @@ const EDITABLE_CONFIG_KEYS = [
41
44
  ...Object.keys(TELEMETRY_CONFIG_KEYS),
42
45
  STAR_PROMPT_CONFIG_KEY,
43
46
  TIMEZONE_CONFIG_KEY,
47
+ UPDATE_CHANNEL_CONFIG_KEY,
44
48
  ];
45
49
 
46
50
  function isTelemetryConfigKey(key: string): key is TelemetryConfigKey {
@@ -219,6 +223,7 @@ export function createSystemCliCommands(): CliCommandDef[] {
219
223
  "config set telemetry.usage false",
220
224
  "config set starPrompt.enabled false",
221
225
  "config set timezone <Region/City>|none",
226
+ "config set updates.channel stable|nightly",
222
227
  "config get keybindings",
223
228
  "config set keybindings.actions.<action> <keys>|null|default",
224
229
  "config set keybindings.commands.<keys> <command>|null",
@@ -236,6 +241,11 @@ export function createSystemCliCommands(): CliCommandDef[] {
236
241
  lines: [
237
242
  `theme takes one of these ids, or its name as config themes lists it: ${getThemeIds().join(", ")}.`,
238
243
  ],
244
+ }, {
245
+ title: "Release channel",
246
+ lines: [
247
+ `The app updates itself from stable releases. With updates.channel nightly (Release Channel in the command bar) it also installs the nightly build: ${NIGHTLY_CHANNEL_SUMMARY}. Back on stable, a nightly build stays until a newer stable release is out. GLOOMBERB_UPDATE_CHANNEL overrides the setting for one run; npm and Bun installs always follow stable.`,
248
+ ],
239
249
  }],
240
250
  examples: [
241
251
  "config",
@@ -287,6 +297,7 @@ export function createSystemCliCommands(): CliCommandDef[] {
287
297
  "telemetry.attention": context.config.telemetry?.attention === true,
288
298
  [STAR_PROMPT_CONFIG_KEY]: context.config.starPrompt?.enabled !== false,
289
299
  [TIMEZONE_CONFIG_KEY]: context.config.timezone ?? null,
300
+ [UPDATE_CHANNEL_CONFIG_KEY]: context.config.updates?.channel ?? "stable",
290
301
  };
291
302
 
292
303
  if (action === "list") {
@@ -347,11 +358,16 @@ export function createSystemCliCommands(): CliCommandDef[] {
347
358
  if ((isTelemetryConfigKey(key) || key === STAR_PROMPT_CONFIG_KEY) && value !== "true" && value !== "false") {
348
359
  ctx.fail(`Usage: gloomberb config set ${key} true|false`);
349
360
  }
361
+ if (key === UPDATE_CHANNEL_CONFIG_KEY && !parseUpdateChannel(value)) {
362
+ ctx.fail(`Usage: gloomberb config set ${key} stable|nightly`);
363
+ }
350
364
  // A display name may come unquoted: config set theme White Phosphor.
351
365
  const parsedValue = key === "theme"
352
366
  ? requireThemeId(args.slice(2).join(" "))
353
367
  : key === TIMEZONE_CONFIG_KEY
354
368
  ? parseTimezoneSetting(value)
369
+ : key === UPDATE_CHANNEL_CONFIG_KEY
370
+ ? parseUpdateChannel(value)
355
371
  : key === "refreshIntervalMinutes"
356
372
  ? Number(value)
357
373
  : isBooleanConfigKey(key)
@@ -363,7 +379,9 @@ export function createSystemCliCommands(): CliCommandDef[] {
363
379
  ? { ...context.config, starPrompt: { ...context.config.starPrompt, enabled: parsedValue as boolean } }
364
380
  : key === TIMEZONE_CONFIG_KEY
365
381
  ? withTimezone(context.config, parsedValue as string | null)
366
- : { ...context.config, [key]: parsedValue };
382
+ : key === UPDATE_CHANNEL_CONFIG_KEY
383
+ ? withUpdateChannel(context.config, parsedValue as "stable" | "nightly")
384
+ : { ...context.config, [key]: parsedValue };
367
385
  if (!ctx.cliOptions.dryRun) await saveConfig(nextConfig);
368
386
  ctx.printResult({
369
387
  data: {