gloomberb 0.17.3 → 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 (50) 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/index.ts +4 -1
  5. package/src/api-client/normalizers.ts +21 -0
  6. package/src/api-client/socket.ts +27 -0
  7. package/src/app/runtime/startup.ts +4 -0
  8. package/src/cli/pane-functions/function-help.ts +1 -1
  9. package/src/components/index.ts +1 -1
  10. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  11. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  12. package/src/components/metric-treemap/index.tsx +1 -1
  13. package/src/components/ui/button.tsx +24 -9
  14. package/src/components/ui/index.ts +1 -1
  15. package/src/components/ui/status.tsx +148 -5
  16. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  17. package/src/market-data/futures-board.ts +44 -1
  18. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  19. package/src/plugins/builtin/alerts/cli.ts +72 -11
  20. package/src/plugins/builtin/awards/headless.ts +5 -1
  21. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  22. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  23. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  24. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  25. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  26. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  27. package/src/plugins/builtin/cot/model.ts +20 -0
  28. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  29. package/src/plugins/builtin/docs/pane.tsx +1 -1
  30. package/src/plugins/builtin/futures/index.tsx +2 -3
  31. package/src/plugins/builtin/futures/model.ts +4 -2
  32. package/src/plugins/builtin/futures/series.ts +3 -3
  33. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  34. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  35. package/src/plugins/builtin/news/headless.ts +4 -2
  36. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  37. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -3
  38. package/src/renderers/dom/build-assets.ts +17 -4
  39. package/src/renderers/dom/desktop/controls.tsx +2 -0
  40. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  41. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  42. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  43. package/src/sources/gloomberb-cloud/normalizers.ts +12 -4
  44. package/src/time-series/resolve.ts +5 -3
  45. package/src/ui/resize-settle.ts +128 -0
  46. package/src/utils/calendar-date.ts +56 -0
  47. package/src/utils/decimal-comma.ts +31 -0
  48. package/src/utils/futures-generic.ts +25 -4
  49. package/src/utils/plugin-proxy-hosts.json +5 -0
  50. package/src/utils/utc-time.ts +14 -3
@@ -11,6 +11,7 @@ import {
11
11
  type ChatChannel,
12
12
  type ChatChannelState,
13
13
  type ChatMessage,
14
+ type ChatNotification,
14
15
  type ChatReaction,
15
16
  type ChatReactionsUpdate,
16
17
  type PersistedAuthUser,
@@ -56,10 +57,9 @@ import {
56
57
  ensureChatChannelConnection,
57
58
  getOpenConnectionChannelIds,
58
59
  getSafetyRefreshChannelIds as getChannelIdsForSafetyRefresh,
60
+ markConnectedChannelsForBackfill,
59
61
  } from "./connections";
60
- import {
61
- handleChatNotification as handleChatNotificationEvent,
62
- } from "./notifications";
62
+ import { ChatNotificationAlerts } from "./notifications";
63
63
  import {
64
64
  CHAT_MESSAGE_EDIT_WINDOW_LABEL,
65
65
  DISCORD_MESSAGE_EDIT_NOTICE,
@@ -116,7 +116,14 @@ export class ChatController {
116
116
  );
117
117
  private notifyFn: (notification: AppNotificationRequest) => AppNotificationDelivery | void = () => {};
118
118
  private openMessageFn: ((channelId: string, messageId: string) => void) | undefined;
119
- private notifiedMessageIds = new Set<string>();
119
+ private readonly alerts = new ChatNotificationAlerts({
120
+ getAppActive: () => this.appActive,
121
+ ensureChannelState: (channelId) => this.ensureChannelState(channelId),
122
+ mergeMessages: (channelId, messages, options) => this.mergeMessages(channelId, messages, options),
123
+ getChannel: (channelId) => this.channelCatalog.getChannels().find((channel) => channel.id === channelId),
124
+ getNotify: () => this.notifyFn,
125
+ getOpenMessage: () => this.openMessageFn,
126
+ });
120
127
 
121
128
  private readonly storage = new ChatControllerStorage({
122
129
  emit: (channelId) => this.emit(channelId),
@@ -156,6 +163,11 @@ export class ChatController {
156
163
  getUser: () => this.session.user,
157
164
  refreshSession: () => this.refreshSession(),
158
165
  handleNotification: (notification) => this.handleChatNotification(notification),
166
+ handleChannelState: (state) => {
167
+ this.applyChannelState(state);
168
+ this.emit(state.channelId);
169
+ this.emit();
170
+ },
159
171
  handleReactions: (update) => this.applyReactionsUpdate(update),
160
172
  setOnlineCount: (onlineCount) => {
161
173
  this.channelCatalog.setOnlineCount(onlineCount);
@@ -163,6 +175,7 @@ export class ChatController {
163
175
  emit: () => this.emit(),
164
176
  getSafetyRefreshChannelIds: () => getChannelIdsForSafetyRefresh(this.storage.channelStates),
165
177
  runSafetyRefresh: (channelId) => this.messageLoading.runMessagesRefresh(channelId, { showLoading: false }),
178
+ resyncChannels: () => this.resyncConnectedChannels(),
166
179
  });
167
180
 
168
181
  attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
@@ -170,6 +183,14 @@ export class ChatController {
170
183
  this.hydrate();
171
184
  }
172
185
 
186
+ /**
187
+ * A popped-out window has its own chat state but leaves background banners
188
+ * to the main window, so one message raises one banner per app.
189
+ */
190
+ setRaisesBackgroundAlerts(raises: boolean): void {
191
+ this.alerts.raisesBackgroundAlerts = raises;
192
+ }
193
+
173
194
  setNotifier(
174
195
  notify: (notification: AppNotificationRequest) => AppNotificationDelivery | void,
175
196
  openMessage?: (channelId: string, messageId: string) => void,
@@ -466,7 +487,7 @@ export class ChatController {
466
487
  },
467
488
  closeAllConnections: () => this.closeAllConnections(),
468
489
  emit: () => this.emit(),
469
- stopRealtime: () => this.realtime.stopAll(),
490
+ stopRealtime: () => this.stopRealtime(),
470
491
  });
471
492
  }
472
493
 
@@ -502,7 +523,7 @@ export class ChatController {
502
523
  persistChannelState: (channelId) => this.storage.persistChannelState(channelId),
503
524
  persistSession: () => this.storage.persistSession(this.session.sessionToken, this.session.user),
504
525
  shouldClearSession: clearSession,
505
- stopRealtime: () => this.realtime.stopAll(),
526
+ stopRealtime: () => this.stopRealtime(),
506
527
  });
507
528
  }
508
529
 
@@ -514,7 +535,7 @@ export class ChatController {
514
535
  });
515
536
  disposeChatControllerRuntime({
516
537
  channelStates: this.storage.channelStates,
517
- clearNotifications: () => this.notifiedMessageIds.clear(),
538
+ clearNotifications: () => this.alerts.clear(),
518
539
  clearView: () => this.view.clear(),
519
540
  closeAllConnections: () => this.closeAllConnections(),
520
541
  flushDraftSync: (channelId) => this.storage.flushDraftSync(channelId),
@@ -522,7 +543,7 @@ export class ChatController {
522
543
  this.notifyFn = () => {};
523
544
  this.openMessageFn = undefined;
524
545
  },
525
- stopRealtime: () => this.realtime.stopAll(),
546
+ stopRealtime: () => this.stopRealtime(),
526
547
  });
527
548
  }
528
549
 
@@ -778,6 +799,12 @@ export class ChatController {
778
799
  closeAllChannelConnections(this.storage.channelStates.values());
779
800
  }
780
801
 
802
+ /** Live updates stop, and so do alerts still waiting to go up: nobody signed in reads them. */
803
+ private stopRealtime(): void {
804
+ this.realtime.stopAll();
805
+ this.alerts.clear();
806
+ }
807
+
781
808
  private applySignedOutSession(): void {
782
809
  applySignedOutChatControllerSession({
783
810
  channelStates: this.storage.channelStates.values(),
@@ -785,10 +812,21 @@ export class ChatController {
785
812
  emit: () => this.emit(),
786
813
  persistSession: (sessionToken, user) => this.storage.persistSession(sessionToken, user),
787
814
  session: this.session,
788
- stopRealtime: () => this.realtime.stopAll(),
815
+ stopRealtime: () => this.stopRealtime(),
789
816
  });
790
817
  }
791
818
 
819
+ /** Each channel read live reads its latest page in full, after any read already out. */
820
+ private resyncConnectedChannels(): void {
821
+ for (const channelId of markConnectedChannelsForBackfill(this.storage.channelStates)) {
822
+ const inFlight = this.ensureChannelState(channelId).refreshMessagesPromise ?? Promise.resolve();
823
+ void inFlight
824
+ .catch(() => {})
825
+ .then(() => this.messageLoading.runMessagesRefresh(channelId, { showLoading: false }))
826
+ .catch(() => {});
827
+ }
828
+ }
829
+
792
830
  private ensureOpenChannelConnections(): void {
793
831
  const channelIds = getOpenConnectionChannelIds(this.storage.channelStates);
794
832
  closeInactiveChannelConnections(this.storage.channelStates, channelIds);
@@ -823,20 +861,10 @@ export class ChatController {
823
861
  }
824
862
 
825
863
  private handleChatNotification(
826
- notification: Parameters<typeof handleChatNotificationEvent>[0]["notification"],
864
+ notification: ChatNotification,
827
865
  options: { countUnread?: boolean } = {},
828
866
  ): void {
829
- handleChatNotificationEvent({
830
- notification,
831
- options,
832
- appActive: this.appActive,
833
- ensureChannelState: (channelId) => this.ensureChannelState(channelId),
834
- mergeMessages: (channelId, messages, mergeOptions) => this.mergeMessages(channelId, messages, mergeOptions),
835
- getChannel: (channelId) => this.channelCatalog.getChannels().find((channel) => channel.id === channelId),
836
- notifiedMessageIds: this.notifiedMessageIds,
837
- notify: this.notifyFn,
838
- openMessage: this.openMessageFn,
839
- });
867
+ this.alerts.handle(notification, options);
840
868
  }
841
869
 
842
870
  private getUnreadMentionCount(channelId: string): number {
@@ -11,83 +11,143 @@ import {
11
11
  formatReplyToast,
12
12
  } from "./utils";
13
13
 
14
- function wasNotificationDelivered(delivery: AppNotificationDelivery | void): boolean {
15
- return !!delivery && (delivery.toastVisible || delivery.desktopRequested);
16
- }
14
+ /**
15
+ * How long an app in the background holds a chat banner. The reader may be
16
+ * reading the message at that moment on another device or in a popped-out
17
+ * window, which tells the server within a second; the banner goes up only if
18
+ * the server still has the message unseen.
19
+ */
20
+ const BACKGROUND_ALERT_DELAY_MS = 2_000;
17
21
 
18
- function notifyChatServerMessage({
19
- notification,
20
- channel,
21
- notifiedMessageIds,
22
- notify,
23
- openMessage,
24
- }: {
25
- notification: ChatNotification;
26
- channel: ChatChannel | undefined;
27
- notifiedMessageIds: Set<string>;
28
- notify: (notification: AppNotificationRequest) => AppNotificationDelivery | void;
29
- openMessage?: (channelId: string, messageId: string) => void;
30
- }): boolean {
31
- if (notifiedMessageIds.has(notification.messageId)) return true;
32
- const channelTitle = formatChatPaneTitle(channel, notification.channelId);
33
- const body = notification.type === "reply"
34
- ? formatReplyToast(notification.message)
35
- : notification.type === "mention"
36
- ? formatMentionToast(notification.message)
37
- : formatChannelToast(notification.message, channel?.kind === "direct");
38
- const delivered = wasNotificationDelivered(notify({
39
- title: channelTitle,
40
- body,
41
- type: "info",
42
- desktop: "when-inactive",
43
- ...(openMessage ? {
44
- action: {
45
- label: "Open",
46
- onClick: () => openMessage(notification.channelId, notification.messageId),
47
- },
48
- } : {}),
49
- }));
50
- if (delivered) {
51
- notifiedMessageIds.add(notification.messageId);
52
- }
53
- return delivered;
54
- }
22
+ type Notify = (notification: AppNotificationRequest) => AppNotificationDelivery | void;
55
23
 
56
- export function handleChatNotification({
57
- notification,
58
- options = {},
59
- ensureChannelState,
60
- mergeMessages,
61
- appActive,
62
- getChannel,
63
- notifiedMessageIds,
64
- notify,
65
- openMessage,
66
- }: {
67
- notification: ChatNotification;
68
- options?: { countUnread?: boolean };
24
+ interface ChatNotificationAlertsOptions {
25
+ getAppActive: () => boolean;
69
26
  ensureChannelState: (channelId: string) => ChannelRuntimeState;
70
27
  mergeMessages: (channelId: string, messages: ChatMessage[], options?: MergeMessagesOptions) => void;
71
- appActive: boolean;
72
28
  getChannel: (channelId: string) => ChatChannel | undefined;
73
- notifiedMessageIds: Set<string>;
74
- notify: (notification: AppNotificationRequest) => AppNotificationDelivery | void;
75
- openMessage?: (channelId: string, messageId: string) => void;
76
- }): void {
77
- mergeMessages(notification.channelId, [notification.message], { countUnread: options.countUnread });
78
- const channel = ensureChannelState(notification.channelId);
79
- const activelyViewed = appActive && channel.focusedViewCount > 0;
80
- const delivered = activelyViewed || notifyChatServerMessage({
81
- notification,
82
- channel: getChannel(notification.channelId),
83
- notifiedMessageIds,
84
- notify,
85
- openMessage,
86
- });
87
- if (activelyViewed) {
88
- notifiedMessageIds.add(notification.messageId);
29
+ getNotify: () => Notify;
30
+ getOpenMessage: () => ((channelId: string, messageId: string) => void) | undefined;
31
+ }
32
+
33
+ /** Whether this window holds the message as read: its read marker is at or after it. */
34
+ function isReadHere(channel: ChannelRuntimeState, messageId: string): boolean {
35
+ const viewedId = channel.lastViewedMessageId;
36
+ if (!viewedId) return false;
37
+ if (viewedId === messageId) return true;
38
+ const viewedIndex = channel.messages.findIndex((message) => message.id === viewedId);
39
+ if (viewedIndex < 0) return false;
40
+ const messageIndex = channel.messages.findIndex((message) => message.id === messageId);
41
+ return messageIndex >= 0 && messageIndex <= viewedIndex;
42
+ }
43
+
44
+ /**
45
+ * Tells the server how a notification reached the reader. `seen`: where they
46
+ * were looking (the open channel, a toast in the foreground), which also
47
+ * stops the phone push waiting on it. Without it, only a banner on a
48
+ * computer nobody may be at, which still stops the email and the reminder on
49
+ * the next reconnect.
50
+ */
51
+ function acknowledge(notification: ChatNotification, seen: boolean): void {
52
+ void apiClient.markChatNotificationsDelivered([notification.id], { seen }).catch(() => {});
53
+ }
54
+
55
+ /**
56
+ * Decides, once per message, whether a chat notification becomes a toast or
57
+ * a desktop banner. A message already read here or on another device stays
58
+ * quiet, whether it arrives live or again on reconnect.
59
+ */
60
+ export class ChatNotificationAlerts {
61
+ private readonly notifiedMessageIds = new Set<string>();
62
+ private readonly waiting = new Map<string, ReturnType<typeof setTimeout>>();
63
+ private generation = 0;
64
+ /** False in a popped-out window: the main window raises the background banners. */
65
+ raisesBackgroundAlerts = true;
66
+ backgroundDelayMs = BACKGROUND_ALERT_DELAY_MS;
67
+
68
+ constructor(private readonly options: ChatNotificationAlertsOptions) {}
69
+
70
+ handle(notification: ChatNotification, options: { countUnread?: boolean } = {}): void {
71
+ this.options.mergeMessages(notification.channelId, [notification.message], { countUnread: options.countUnread });
72
+ if (this.notifiedMessageIds.has(notification.messageId)) {
73
+ // Shown here already; this copy came back on a reconnect.
74
+ acknowledge(notification, false);
75
+ return;
76
+ }
77
+ if (this.waiting.has(notification.id) || this.settledHere(notification)) return;
78
+
79
+ if (this.options.getAppActive()) {
80
+ this.present(notification);
81
+ return;
82
+ }
83
+ if (!this.raisesBackgroundAlerts) return;
84
+ const generation = this.generation;
85
+ this.waiting.set(notification.id, setTimeout(() => {
86
+ this.waiting.delete(notification.id);
87
+ void this.presentIfUnseen(notification, generation);
88
+ }, this.backgroundDelayMs));
89
89
  }
90
- if (delivered) {
91
- void apiClient.markChatNotificationsDelivered([notification.id]).catch(() => {});
90
+
91
+ /** Drops what is waiting and what was shown, as on sign-out. */
92
+ clear(): void {
93
+ this.generation += 1;
94
+ for (const timer of this.waiting.values()) clearTimeout(timer);
95
+ this.waiting.clear();
96
+ this.notifiedMessageIds.clear();
97
+ }
98
+
99
+ /** Read here, or open in front of the reader: nothing to raise, and the server hears it was seen. */
100
+ private settledHere(notification: ChatNotification): boolean {
101
+ const channel = this.options.ensureChannelState(notification.channelId);
102
+ const viewing = this.options.getAppActive() && channel.focusedViewCount > 0;
103
+ if (!viewing && !isReadHere(channel, notification.messageId)) return false;
104
+ this.notifiedMessageIds.add(notification.messageId);
105
+ acknowledge(notification, true);
106
+ return true;
107
+ }
108
+
109
+ private async presentIfUnseen(notification: ChatNotification, generation: number): Promise<void> {
110
+ let unseen = true;
111
+ try {
112
+ const ids = await apiClient.getUnseenChatNotificationIds([notification.id]);
113
+ unseen = ids.includes(notification.id);
114
+ } catch {
115
+ // A server that cannot say (an older one, a dropped request) gets the banner, as before.
116
+ }
117
+ if (generation !== this.generation || this.notifiedMessageIds.has(notification.messageId)) return;
118
+ if (!unseen) {
119
+ // Read, or shown in front of the reader, on another device or window.
120
+ this.notifiedMessageIds.add(notification.messageId);
121
+ return;
122
+ }
123
+ if (this.settledHere(notification)) return;
124
+ this.present(notification);
125
+ }
126
+
127
+ private present(notification: ChatNotification): void {
128
+ const channel = this.options.getChannel(notification.channelId);
129
+ const body = notification.type === "reply"
130
+ ? formatReplyToast(notification.message)
131
+ : notification.type === "mention"
132
+ ? formatMentionToast(notification.message)
133
+ : formatChannelToast(notification.message, channel?.kind === "direct");
134
+ const openMessage = this.options.getOpenMessage();
135
+ const delivery = this.options.getNotify()({
136
+ title: formatChatPaneTitle(channel, notification.channelId),
137
+ body,
138
+ type: "info",
139
+ desktop: "when-inactive",
140
+ ...(openMessage ? {
141
+ action: {
142
+ label: "Open",
143
+ onClick: () => openMessage(notification.channelId, notification.messageId),
144
+ },
145
+ } : {}),
146
+ });
147
+ const toastVisible = !!delivery && delivery.toastVisible;
148
+ // Nothing reached the reader: it stays pending for the email and the next reconnect.
149
+ if (!toastVisible && !(delivery && delivery.desktopRequested)) return;
150
+ this.notifiedMessageIds.add(notification.messageId);
151
+ acknowledge(notification, toastVisible);
92
152
  }
93
153
  }
@@ -1,4 +1,9 @@
1
- import { apiClient, type ChatNotification, type ChatReactionsUpdate } from "../../../../api-client";
1
+ import {
2
+ apiClient,
3
+ type ChatChannelState,
4
+ type ChatNotification,
5
+ type ChatReactionsUpdate,
6
+ } from "../../../../api-client";
2
7
  import {
3
8
  SAFETY_REFRESH_MS,
4
9
  SESSION_RETRY_MS,
@@ -11,20 +16,25 @@ interface ChatControllerRealtimeOptions {
11
16
  getUser: () => { emailVerified?: boolean } | null;
12
17
  refreshSession: () => Promise<void>;
13
18
  handleNotification: (notification: ChatNotification) => void;
19
+ handleChannelState: (state: ChatChannelState) => void;
14
20
  handleReactions: (update: ChatReactionsUpdate) => void;
15
21
  setOnlineCount: (onlineCount: number) => void;
16
22
  emit: () => void;
17
23
  getSafetyRefreshChannelIds: () => string[];
18
24
  runSafetyRefresh: (channelId: string) => Promise<void>;
25
+ /** The socket came back after a drop: the channels read live read their latest page in full. */
26
+ resyncChannels: () => void;
19
27
  }
20
28
 
21
29
  export class ChatControllerRealtime {
22
30
  private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
23
31
  private sessionRetryTimer: ReturnType<typeof setTimeout> | null = null;
24
32
  private safetyRefreshTimer: ReturnType<typeof setInterval> | null = null;
33
+ private socketConnectionUnsubscribe: (() => void) | null = null;
25
34
  private chatNotificationUnsubscribe: (() => void) | null = null;
26
35
  private chatPresenceUnsubscribe: (() => void) | null = null;
27
36
  private chatReactionsUnsubscribe: (() => void) | null = null;
37
+ private channelStateUnsubscribe: (() => void) | null = null;
28
38
 
29
39
  constructor(private readonly options: ChatControllerRealtimeOptions) {}
30
40
 
@@ -72,21 +82,47 @@ export class ChatControllerRealtime {
72
82
  this.options.handleReactions(update);
73
83
  });
74
84
  }
85
+ if (!this.channelStateUnsubscribe) {
86
+ // Read or muted on another device or window: the unread count, the
87
+ // Dock badge and alerts still waiting follow without a refocus.
88
+ this.channelStateUnsubscribe = apiClient.subscribeChatChannelState((state) => {
89
+ this.options.handleChannelState(state);
90
+ });
91
+ }
75
92
  if (!this.chatPresenceUnsubscribe) {
76
93
  this.chatPresenceUnsubscribe = apiClient.subscribeChatPresence((onlineCount) => {
77
94
  this.options.setOnlineCount(onlineCount);
78
95
  this.options.emit();
79
96
  });
80
97
  }
98
+ if (!this.socketConnectionUnsubscribe) {
99
+ // Frames sent while the socket was down are gone, and the catch-up
100
+ // only reads newer messages, so a reaction, edit or removal made
101
+ // meanwhile is read with a full page once it is back.
102
+ let dropped = false;
103
+ this.socketConnectionUnsubscribe = apiClient.subscribeSocketConnection((event) => {
104
+ if (event === "closed") {
105
+ dropped = true;
106
+ return;
107
+ }
108
+ if (!dropped) return;
109
+ dropped = false;
110
+ this.options.resyncChannels();
111
+ });
112
+ }
81
113
  }
82
114
 
83
115
  stopRealtimeSubscriptions(): void {
116
+ this.socketConnectionUnsubscribe?.();
117
+ this.socketConnectionUnsubscribe = null;
84
118
  this.chatNotificationUnsubscribe?.();
85
119
  this.chatNotificationUnsubscribe = null;
86
120
  this.chatPresenceUnsubscribe?.();
87
121
  this.chatPresenceUnsubscribe = null;
88
122
  this.chatReactionsUnsubscribe?.();
89
123
  this.chatReactionsUnsubscribe = null;
124
+ this.channelStateUnsubscribe?.();
125
+ this.channelStateUnsubscribe = null;
90
126
  }
91
127
 
92
128
  stopVerificationPolling(): void {
@@ -67,11 +67,31 @@ const PRICE_OVERLAYS: Record<string, { exchange: string; priceSymbol: string }>
67
67
  "040701": { exchange: "NYB", priceSymbol: "OJ=F" }, "058644": { exchange: "CME", priceSymbol: "LBR=F" },
68
68
  "057642": { exchange: "CME", priceSymbol: "LE=F" }, "054642": { exchange: "CME", priceSymbol: "HE=F" },
69
69
  "061641": { exchange: "CME", priceSymbol: "GF=F" },
70
+ // Sector, index and energy roots added on 2026-10-14, each CFTC market matched
71
+ // by name and open interest against the board and checked for five years of
72
+ // front-price history the same day.
73
+ "138749": { exchange: "CME", priceSymbol: "XAE=F" }, "13874H": { exchange: "CME", priceSymbol: "XAB=F" },
74
+ "13874F": { exchange: "CME", priceSymbol: "XAI=F" }, "138748": { exchange: "CME", priceSymbol: "XAP=F" },
75
+ "13874E": { exchange: "CME", priceSymbol: "XAV=F" }, "13874C": { exchange: "CME", priceSymbol: "XAF=F" },
76
+ "13874I": { exchange: "CME", priceSymbol: "XAK=F" }, "13874P": { exchange: "CME", priceSymbol: "XAZ=F" },
77
+ "13874J": { exchange: "CME", priceSymbol: "XAU=F" }, "240741": { exchange: "CME", priceSymbol: "NKD=F" },
78
+ "240743": { exchange: "CME", priceSymbol: "NIY=F" }, "13874U": { exchange: "CME", priceSymbol: "MES=F" },
79
+ "209747": { exchange: "CME", priceSymbol: "MNQ=F" }, "239747": { exchange: "CME", priceSymbol: "M2K=F" },
80
+ "124608": { exchange: "CBT", priceSymbol: "MYM=F" },
81
+ };
82
+ /**
83
+ * Board roots the CFTC reports whose front alias has no five years of daily
84
+ * history to overlay (checked 2026-10-14): positioning without a price line.
85
+ * The CFTC reports the consumer discretionary sector some weeks only.
86
+ */
87
+ const UNPRICED_BOARD_MARKETS: Readonly<Record<string, string>> = {
88
+ EMD: "33874A", RX: "124606", XAY: "138747", HH: "023A55",
70
89
  };
71
90
  /** The CFTC market a FUT board root reports under, by its price symbol; null for one the CFTC does not report (TTF). */
72
91
  function boardMarketCode(contract: FuturesContract): string | null {
73
92
  return Object.values(ROOTS).find((row) => row.priceSymbol === contract.symbol)?.code
74
93
  ?? Object.entries(PRICE_OVERLAYS).find(([, row]) => row.priceSymbol === contract.symbol)?.[0]
94
+ ?? UNPRICED_BOARD_MARKETS[contract.code]
75
95
  ?? null;
76
96
  }
77
97
  /**
@@ -10,7 +10,7 @@ import { teamViewsStore } from "../cloud/team/views";
10
10
  import { setPaneSettings } from "../../../pane-settings";
11
11
  import { customViewInstanceSettings } from "./index";
12
12
  import type { DataTableColumn } from "../../../components/ui/data-table/types";
13
- import { Button, PaneStatusBody, TextPromptDialog, usePaneFooter } from "../../../components";
13
+ import { PaneStatusBody, TextPromptDialog, usePaneFooter } from "../../../components";
14
14
  import { useAsyncResource } from "../../../react/async-resource";
15
15
  import { useShortcut } from "../../../react/input";
16
16
  import { isPlainKey } from "../../../utils/keyboard";
@@ -370,7 +370,7 @@ export function CustomViewPane({ focused, width, height }: PaneProps) {
370
370
  <PaneStatusBody
371
371
  error={error}
372
372
  errorTitle="This view could not load."
373
- actions={<Button label="Retry" compact onPress={() => { void reload(); }} />}
373
+ retry={{ onRetry: reload }}
374
374
  />
375
375
  );
376
376
  }
@@ -182,7 +182,7 @@ function LessonEntry({ url, width, onOpenLink }: { url: string; width: number; o
182
182
  <EmptyState
183
183
  title="This lesson is not saved for offline reading."
184
184
  hint="It is kept once it has been read with a connection."
185
- actions={<Button label="Try Again" onPress={lesson.retry} compact />}
185
+ retry={{ onRetry: lesson.retry }}
186
186
  />
187
187
  );
188
188
  }
@@ -1,6 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
- Button,
4
3
  DataTableView,
5
4
  PaneStatusBody,
6
5
  QueryBar,
@@ -286,7 +285,7 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
286
285
  height={height}
287
286
  error={errorMessage ?? "No quotes returned."}
288
287
  subject="Futures quotes"
289
- actions={<Button label="Retry" variant="secondary" compact onPress={refresh} />}
288
+ retry={{ onRetry: refresh }}
290
289
  />
291
290
  );
292
291
  }
@@ -389,7 +388,7 @@ export const futuresModule: PluginModule = {
389
388
  paneId: FUTURES_PANE_ID,
390
389
  label: "Futures Board",
391
390
  description:
392
- "Each root's most active futures contract across equity index, rates, energy, metals, agriculture, livestock, and FX with last price, unit, session change, search, and collapsible sectors.",
391
+ "Each root's most active futures contract across equity index, equity sectors, rates, energy, metals, agriculture, livestock, and FX with last price, unit, session change, search, and collapsible sectors.",
393
392
  keywords: [
394
393
  "futures",
395
394
  "commodities",
@@ -40,7 +40,8 @@ export function futuresRowId(row: FuturesTableRow): string {
40
40
  return row.type === "header" ? `header-${row.sector}` : row.contract.symbol;
41
41
  }
42
42
 
43
- const CONTRACT_MONTH = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[ -](\d{4}|\d{2})$/i;
43
+ /** "Nov 26" or "Futures,Nov-2026"; a hyphen takes only a whole year, since "Futures,Nov-20" is 2026 cut short. */
44
+ const CONTRACT_MONTH = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?: (\d{4}|\d{2})|-(\d{4}))$/i;
44
45
 
45
46
  /**
46
47
  * The alias stays the row identity while its quoted contract rolls, so the
@@ -66,7 +67,8 @@ export function quotedContractMonth(
66
67
  const match = CONTRACT_MONTH.exec(quote.name?.trim() ?? "");
67
68
  if (!match) return null;
68
69
  const month = MONTH_NAMES.findIndex((name) => name.toLowerCase() === match[1]!.toLowerCase());
69
- const year = match[2]!.length === 2 ? 2000 + Number(match[2]) : Number(match[2]);
70
+ const written = match[2] ?? match[3]!;
71
+ const year = written.length === 2 ? 2000 + Number(written) : Number(written);
70
72
  return { year, month };
71
73
  }
72
74
 
@@ -9,8 +9,8 @@ import { FUTURES_CONTRACTS, FUTURES_SECTOR_LABELS, type FuturesContract } from "
9
9
  const US_CENT_CONTRACTS: ReadonlySet<string> = new Set(["ZC", "ZS", "ZL", "ZW", "KE", "KC", "SB", "CT"]);
10
10
 
11
11
  /**
12
- * The unit the board prints, not a blanket dollar price. Index futures are
13
- * points, rates are points (the board prints decimals, not 32nds), cents
12
+ * The unit the board prints, not a blanket dollar price. Index and sector
13
+ * futures are points, rates are points (the board prints decimals, not 32nds), cents
14
14
  * contracts are USX, and a declared quote unit wins over USD. Currency
15
15
  * futures are dollars per unit of the foreign currency.
16
16
  */
@@ -18,7 +18,7 @@ function futuresSeriesUnit(contract: FuturesContract): string {
18
18
  if (contract.unit?.startsWith("US cents") || US_CENT_CONTRACTS.has(contract.code)) return "USX";
19
19
  if (contract.unit?.startsWith("EUR")) return "EUR";
20
20
  if (contract.unit?.startsWith("USD")) return "USD";
21
- if (contract.sector === "equity-index" || contract.sector === "rates") return "points";
21
+ if (contract.sector === "equity-index" || contract.sector === "equity-sectors" || contract.sector === "rates") return "points";
22
22
  return "USD";
23
23
  }
24
24
 
@@ -4,7 +4,6 @@ import type { CloudJobsMoverPayload, CloudJobsPosting, CloudJobsSummaryPayload }
4
4
  import {
5
5
  chartTableChromeRows,
6
6
  Badge,
7
- Button,
8
7
  ChartTableHeader,
9
8
  CompositeChart,
10
9
  DataTableStackView,
@@ -536,7 +535,7 @@ function CompanyPanel({
536
535
  [symbol, companyName],
537
536
  );
538
537
  const resource = useAsyncResource<JobsCompanyState>(request);
539
- const { data, status, error, reload } = resource;
538
+ const { data, status, error, loading, reload } = resource;
540
539
 
541
540
  // `r` asks again in every state, including a failed or uncovered company.
542
541
  usePaneRefreshKey(reload, { focused });
@@ -555,8 +554,10 @@ function CompanyPanel({
555
554
  if ((status === "idle" || status === "loading") && !data) {
556
555
  return <PaneStatusBody loading align="center" loadingLabel={`Loading ${symbol} hiring...`} />;
557
556
  }
558
- if (status === "error" && !data) {
559
- return <PaneStatusBody error={error ?? "Could not load hiring data."} errorTitle="Could not load hiring data." actions={<Button label="Try again" onPress={reload} />} />;
557
+ // A failed look at an uncovered company says so: the old answer left up
558
+ // would read as the new one.
559
+ if (status === "error" && (!data || data.kind === "uncovered")) {
560
+ return <PaneStatusBody error={error ?? "Could not load hiring data."} errorTitle="Could not load hiring data." retry={{ onRetry: reload }} />;
560
561
  }
561
562
  if (!data) return null;
562
563
  if (data.kind === "pending") {
@@ -569,11 +570,13 @@ function CompanyPanel({
569
570
  );
570
571
  }
571
572
  if (data.kind === "uncovered") {
573
+ // The answer stays on screen while it asks again, so the button says it is asking.
572
574
  return (
573
- <EmptyState
574
- title={`No careers system found for ${symbol}.`}
575
- message="Gloomberb reads companies' own careers systems. This one either has none we can read or lists roles only on third-party job boards."
576
- actions={<Button label="Look again" onPress={reload} />}
575
+ <PaneStatusBody
576
+ empty
577
+ emptyTitle={`No careers system found for ${symbol}.`}
578
+ emptyMessage="Gloomberb reads companies' own careers systems. This one either has none we can read or lists roles only on third-party job boards."
579
+ retry={{ onRetry: reload, pending: loading, label: "Look again", pendingLabel: "Looking again...", unchanged: "Still no careers system." }}
577
580
  />
578
581
  );
579
582
  }
@@ -684,7 +687,7 @@ function HomeView({ width, height, focused, registrationId }: { width: number; h
684
687
  }, []);
685
688
 
686
689
  if (status === "loading" && !data) return <PaneStatusBody loading align="center" loadingLabel="Loading hiring data..." />;
687
- if (status === "error" && !data) return <PaneStatusBody error={error ?? "Could not load hiring data."} errorTitle="Could not load hiring data." actions={<Button label="Try again" onPress={reload} />} />;
690
+ if (status === "error" && !data) return <PaneStatusBody error={error ?? "Could not load hiring data."} errorTitle="Could not load hiring data." retry={{ onRetry: reload }} />;
688
691
 
689
692
  const tableHeight = Math.max(3, height - (nativePaneChrome ? 1 : 0));
690
693
  return (