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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.17.3",
3
+ "version": "0.17.4",
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"
@@ -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
  }
@@ -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",
@@ -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,
@@ -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(), []);
@@ -749,7 +749,7 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
749
749
  bloomberg: [],
750
750
  },
751
751
  FUT: {
752
- summary: "Each root's most active futures contract across equity index, rates, energy, metals, agriculture, livestock and FX with last price, unit and session change, grouped by sector.",
752
+ summary: "Each root's most active futures contract across equity index, equity sectors, rates, energy, metals, agriculture, livestock and FX with last price, unit and session change, grouped by sector.",
753
753
  usage: ["FUT"],
754
754
  keys: [SEARCH, OPEN],
755
755
  data: same(DELAYED),
@@ -147,7 +147,7 @@ export type {
147
147
  NoticeProps, NumberFieldProps, PaneStatusBodyProps, PopoverProps, ProseProps, QueryBarFilter,
148
148
  QueryBarMultiFilter, QueryBarProps, QueryBarSearch, QueryBarSearchFocus, QueryBarSelectFilter, QueryBarTextFilter,
149
149
  QueryBarToggleFilter, QueryBarView, RangeTrackProps, RatioBarProps, SectionHeadingProps, SectionProps, SegmentedControlProps,
150
- SelectButtonOption, SelectButtonProps, SelectControl, StatGridProps, StatItem, TabsProps,
150
+ SelectButtonOption, SelectButtonProps, SelectControl, StatGridProps, StatItem, StatusRetry, TabsProps,
151
151
  TextFieldProps, TextPromptDialogProps,
152
152
  } from "./ui";
153
153
  export { usePaneTicker } from "../state/app/context";
@@ -1,6 +1,7 @@
1
1
  import { memo, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
2
2
  import { Box, Text, TextAttributes, useUiCapabilities, type BoxRenderable } from "../../ui";
3
3
  import { useLiveStretch } from "../../ui/live-stretch";
4
+ import { useSettledLayoutSize } from "../../ui/resize-settle";
4
5
  import { blendHex } from "../../theme/colors";
5
6
  import {
6
7
  heatmapColorDistance,
@@ -91,9 +92,57 @@ export function buildHeatTreemapScene<T>(
91
92
  return buildMetricTreemapScene(items, canvas.width, canvas.height, canvas.options, previous);
92
93
  }
93
94
 
95
+ export interface HeatTreemapLayout<T> {
96
+ scene: MetricTreemapScene<T>;
97
+ /** The box is being resized (`useSettledLayoutSize`): hand it to the surface, which then holds still. */
98
+ resizing: boolean;
99
+ }
100
+
101
+ interface ShownScene<T> {
102
+ key: string;
103
+ canvas: HeatTreemapCanvas;
104
+ items: readonly MetricTreemapItem<T>[];
105
+ scene: MetricTreemapScene<T>;
106
+ }
107
+
108
+ /**
109
+ * The scene a heat map shows in a `width` by `height` box (cells). Each
110
+ * layout is seeded with the scene on screen under the same `key` (a tab), so
111
+ * a refresh nudges tiles instead of reshuffling the board. On the desktop,
112
+ * while the box is being resized the scene on screen stretches with it and a
113
+ * refresh's new sizes wait: the board is laid out once, at the size the box
114
+ * settles on.
115
+ */
116
+ export function useHeatTreemapScene<T>(
117
+ items: readonly MetricTreemapItem<T>[],
118
+ width: number,
119
+ height: number,
120
+ key = "",
121
+ ): HeatTreemapLayout<T> {
122
+ const { nativePaneChrome, cellWidthPx, cellHeightPx } = useUiCapabilities();
123
+ const layout = useSettledLayoutSize(width, height, { enabled: nativePaneChrome === true });
124
+ const canvas = useMemo(
125
+ () => heatTreemapCanvas(layout.width, layout.height, { nativePaneChrome, cellWidthPx, cellHeightPx }),
126
+ [cellHeightPx, cellWidthPx, layout.height, layout.width, nativePaneChrome],
127
+ );
128
+ const shownRef = useRef<ShownScene<T> | null>(null);
129
+ const scene = useMemo(() => {
130
+ const shown = shownRef.current?.key === key ? shownRef.current : null;
131
+ // While the box resizes, the scene on screen holds until it is laid out
132
+ // again, a refresh's new sizes with it: the board relays out once.
133
+ if (shown && shown.canvas === canvas && (layout.resizing || shown.items === items)) return shown.scene;
134
+ const next = buildHeatTreemapScene(items, canvas, shown?.scene);
135
+ shownRef.current = { key, canvas, items, scene: next };
136
+ return next;
137
+ }, [canvas, items, key, layout.resizing]);
138
+ return { scene, resizing: layout.resizing };
139
+ }
140
+
94
141
  export interface HeatTreemapSurfaceProps<T> {
95
- /** Geometry, from `buildHeatTreemapScene`; rebuilt only when sizes change. */
142
+ /** Geometry, from `useHeatTreemapScene`; rebuilt only when sizes change. */
96
143
  scene: MetricTreemapScene<T>;
144
+ /** From `useHeatTreemapScene`: while the box resizes, no tile glides, fades or pulses. */
145
+ resizing: boolean;
97
146
  /** Current colours and text, by id; they change with every tick. */
98
147
  items: readonly MetricTreemapItem<T>[];
99
148
  width: number;
@@ -223,7 +272,7 @@ const VERTICAL_LABEL_STYLE: CSSProperties = {
223
272
 
224
273
  const DomHeatTile = memo(function DomHeatTile({
225
274
  id, rect, canvasWidth, canvasHeight, gap, label, value, valueSuffix, background, foreground,
226
- selected, reducedMotion, glide, pulseCount, events,
275
+ selected, reducedMotion, resizing, glide, pulseCount, events,
227
276
  }: {
228
277
  id: string;
229
278
  rect: TreemapRect;
@@ -237,6 +286,8 @@ const DomHeatTile = memo(function DomHeatTile({
237
286
  foreground: string;
238
287
  selected: boolean;
239
288
  reducedMotion: boolean;
289
+ resizing: boolean;
290
+ /** A relayout this render may slide the tile: false for one that came with a new size. */
240
291
  glide: boolean;
241
292
  pulseCount: number;
242
293
  events: SurfaceEvents;
@@ -248,16 +299,22 @@ const DomHeatTile = memo(function DomHeatTile({
248
299
  });
249
300
  // Where this tile sat before the last relayout, kept to slide it from there.
250
301
  const placedRef = useRef(rect);
251
- const glideRef = useRef<{ dx: number; dy: number; count: number }>({ dx: 0, dy: 0, count: 0 });
302
+ const glideRef = useRef<{ dx: number; dy: number; count: number; playing: boolean }>({ dx: 0, dy: 0, count: 0, playing: false });
303
+ const allowGlide = glide && !resizing;
252
304
  if (placedRef.current !== rect) {
253
305
  const dx = placedRef.current.x - rect.x;
254
306
  const dy = placedRef.current.y - rect.y;
255
- if (glide && (Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5)) {
256
- glideRef.current = { dx, dy, count: glideRef.current.count + 1 };
257
- }
307
+ // A slide belongs to the relayout that moved the tile: a later render
308
+ // (a tick, a hover, the end of a resize) never plays it again.
309
+ glideRef.current = allowGlide && (Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5)
310
+ ? { dx, dy, count: glideRef.current.count + 1, playing: true }
311
+ : { ...glideRef.current, playing: false };
258
312
  placedRef.current = rect;
313
+ } else if (resizing && glideRef.current.playing) {
314
+ // A resize stops a slide under way: the tile sits where its layout has it.
315
+ glideRef.current = { ...glideRef.current, playing: false };
259
316
  }
260
- const motion = heatTileMotion({ reducedMotion, glide, glideCount: glideRef.current.count, pulseCount });
317
+ const motion = heatTileMotion({ reducedMotion, resizing, glide: glideRef.current.playing, glideCount: glideRef.current.count, pulseCount });
261
318
  const colors = useThemeColors();
262
319
  const ring = resolveHeatmapSelectionRing(colors);
263
320
  // The colour this tile showed before, kept to fade out over the new one.
@@ -274,10 +331,13 @@ const DomHeatTile = memo(function DomHeatTile({
274
331
  else if (visible) fadeRef.current = { from: shownRef.current, key: (fadeRef.current?.key ?? 0) + 1 };
275
332
  shownRef.current = background;
276
333
  }
334
+ // A resize ends a fade under way, and the pulses due meanwhile are dropped, not saved for later.
335
+ if (resizing) fadeRef.current = null;
277
336
  const fade = fadeRef.current;
278
337
  // A finished overlay leaves the DOM, so idle tiles carry no extra nodes.
279
338
  const [, setOverlayVersion] = useState(0);
280
339
  const pulseDoneRef = useRef(0);
340
+ if (resizing) pulseDoneRef.current = Math.max(pulseDoneRef.current, pulseCount);
281
341
  const pulsing = motion.pulseAnimation != null && pulseCount > pulseDoneRef.current;
282
342
  const style: CSSProperties = {
283
343
  position: "absolute",
@@ -491,7 +551,7 @@ function DomTooltip<T>({ state, itemsById, otherLines }: {
491
551
  const EMPTY_PULSES: ReadonlyMap<string, number> = new Map();
492
552
 
493
553
  function DomHeatTreemap<T>({
494
- scene, items, width, height, selectedId, onSelect, onActivate, otherTooltip, otherSummary: summarizeOther,
554
+ scene, resizing, items, width, height, selectedId, onSelect, onActivate, otherTooltip, otherSummary: summarizeOther,
495
555
  }: HeatTreemapSurfaceProps<T>) {
496
556
  const reducedMotion = usePrefersReducedMotion();
497
557
  const colors = useThemeColors();
@@ -519,13 +579,14 @@ function DomHeatTreemap<T>({
519
579
  );
520
580
  }, [itemsById, reducedMotion, scene]);
521
581
 
522
- // A resize relays out every tile; following the window with a glide would lag it.
582
+ // The layout a resize settles on comes in at once, as a chart or a table
583
+ // redraws at its new size: tiles slide only when sizes move at rest.
523
584
  const sizeKey = `${scene.width}x${scene.height}`;
524
- const lastSizeRef = useRef(sizeKey);
525
- const glide = lastSizeRef.current === sizeKey;
585
+ const restedRef = useRef({ sizeKey, resizing });
586
+ const glide = !resizing && !restedRef.current.resizing && restedRef.current.sizeKey === sizeKey;
526
587
  useEffect(() => {
527
- lastSizeRef.current = sizeKey;
528
- }, [sizeKey]);
588
+ restedRef.current = { sizeKey, resizing };
589
+ }, [resizing, sizeKey]);
529
590
 
530
591
  const gap = scene.width < NARROW_CANVAS_PX ? 0.5 : 1;
531
592
  const otherLines = useMemo(
@@ -603,6 +664,7 @@ function DomHeatTreemap<T>({
603
664
  foreground={tileColors.foreground}
604
665
  selected={tile.item.id === selectedId}
605
666
  reducedMotion={reducedMotion}
667
+ resizing={resizing}
606
668
  glide={glide}
607
669
  pulseCount={pulses.get(tile.item.id) ?? 0}
608
670
  events={events}
@@ -755,7 +817,7 @@ function TerminalHeatTreemap<T>({
755
817
  * calm tiles (ticker, then the move, as space allows), a continuous scale
756
818
  * from the theme's down colour to its up colour, and on the desktop a hover
757
819
  * tooltip, colour fades, a short pulse on a tile whose move jumps and a glide
758
- * when sizes change.
820
+ * when sizes change, none of them while the box is being resized.
759
821
  */
760
822
  export function HeatTreemapSurface<T>(props: HeatTreemapSurfaceProps<T>) {
761
823
  const { nativePaneChrome } = useUiCapabilities();
@@ -240,15 +240,17 @@ export interface HeatTileMotion {
240
240
  * tile from its old place (its new size applies at once), a colour change
241
241
  * fades the old colour out over the new one, and a jump in the move flashes
242
242
  * once. `glideCount` alternates two keyframe names, which restarts the slide.
243
- * Reduced motion keeps every change instant.
243
+ * Reduced motion keeps every change instant, and so does a resize: while the
244
+ * box is changing size nothing on the board moves on its own.
244
245
  */
245
- export function heatTileMotion({ reducedMotion, glide, glideCount, pulseCount }: {
246
+ export function heatTileMotion({ reducedMotion, resizing, glide, glideCount, pulseCount }: {
246
247
  reducedMotion: boolean;
248
+ resizing: boolean;
247
249
  glide: boolean;
248
250
  glideCount: number;
249
251
  pulseCount: number;
250
252
  }): HeatTileMotion {
251
- if (reducedMotion) return {};
253
+ if (reducedMotion || resizing) return {};
252
254
  return {
253
255
  ...(glide && glideCount > 0
254
256
  ? { glideAnimation: `gloom-heat-glide-${glideCount % 2 === 0 ? "b" : "a"} ${GLIDE_MS}ms ease` }
@@ -18,7 +18,7 @@ export {
18
18
  type MetricTreemapDirection,
19
19
  type MetricTreemapItem,
20
20
  } from "./layout";
21
- export { buildHeatTreemapScene, heatTreemapCanvas, HeatTreemapSurface } from "./heat-surface";
21
+ export { buildHeatTreemapScene, heatTreemapCanvas, HeatTreemapSurface, useHeatTreemapScene } from "./heat-surface";
22
22
  export { formatHeatTileMove, settleTreemapLayoutItems } from "./heat-tiles";
23
23
 
24
24
  type PreventableMouseEvent = { preventDefault(): void };
@@ -26,6 +26,14 @@ export interface ButtonProps {
26
26
  onPress?: () => void;
27
27
  variant?: ButtonVariant;
28
28
  disabled?: boolean;
29
+ /**
30
+ * The action it started is still running (a retry waiting for its answer).
31
+ * It reads as disabled and ignores presses, but keeps its key, so it does not
32
+ * change size and the key does not fall through to something else.
33
+ */
34
+ pending?: boolean;
35
+ /** What it says while pending, such as "Retrying...". Without it the label stays. */
36
+ pendingLabel?: string;
29
37
  active?: boolean;
30
38
  shortcut?: string;
31
39
  /**
@@ -86,9 +94,11 @@ export function Button({
86
94
  displayLabel,
87
95
  children,
88
96
  expanded,
89
- onPress,
97
+ onPress: pressAction,
90
98
  variant = "secondary",
91
99
  disabled = false,
100
+ pending = false,
101
+ pendingLabel,
92
102
  active = false,
93
103
  shortcut,
94
104
  desktopShortcut,
@@ -100,17 +110,21 @@ export function Button({
100
110
  title,
101
111
  }: ButtonProps) {
102
112
  const colors = useThemeColors();
103
- const label = t(rawLabel);
113
+ const label = t(pending && pendingLabel ? pendingLabel : rawLabel);
114
+ if (pending && pendingLabel) displayLabel = undefined;
115
+ // A pending press is ignored, but the button stays its scope's primary action.
116
+ const onPress = pressAction && pending ? () => {} : pressAction;
104
117
  const scopedShortcut = useScopedButtonAction(label, onPress, disabled);
105
118
  shortcut = shortcut ?? scopedShortcut;
119
+ const inert = disabled || pending;
106
120
  const remoteScope = useRemoteUiScope();
107
121
  useRemoteUiNode({
108
122
  role: "button",
109
123
  label,
110
- disabled,
124
+ disabled: inert,
111
125
  actions: {
112
126
  press: () => {
113
- if (!disabled) onPress?.();
127
+ if (!inert) onPress?.();
114
128
  },
115
129
  },
116
130
  metadata: { variant, active, shortcut, expanded, ...(remoteScope ? { scope: remoteScope } : {}) },
@@ -124,7 +138,8 @@ export function Button({
124
138
  expanded={expanded}
125
139
  onPress={onPress}
126
140
  variant={variant}
127
- disabled={disabled}
141
+ disabled={inert}
142
+ pending={pending}
128
143
  active={active}
129
144
  shortcut={shortcut ?? desktopShortcut}
130
145
  title={title}
@@ -137,7 +152,7 @@ export function Button({
137
152
  );
138
153
  }
139
154
 
140
- const palette = resolveButtonColors(variant, active, disabled, colors);
155
+ const palette = resolveButtonColors(variant, active, inert, colors);
141
156
 
142
157
  return (
143
158
  <Box
@@ -145,20 +160,20 @@ export function Button({
145
160
  height={height ?? 1}
146
161
  flexDirection="row"
147
162
  backgroundColor={palette.bg}
148
- cursor={disabled ? "default" : "pointer"}
163
+ cursor={inert ? "default" : "pointer"}
149
164
  onMouseDown={(event: { preventDefault?: () => void; stopPropagation?: () => void }) => {
150
165
  if (stopPropagation) {
151
166
  event.preventDefault?.();
152
167
  event.stopPropagation?.();
153
168
  }
154
- if (!disabled) onPress?.();
169
+ if (!inert) onPress?.();
155
170
  }}
156
171
  >
157
172
  {children ?? <Text fg={palette.fg} attributes={active ? TextAttributes.BOLD : 0}>
158
173
  {compact ? displayLabel ?? label : ` ${displayLabel ?? label} `}
159
174
  </Text>}
160
175
  {shortcut && (
161
- <Text fg={disabled ? colors.textMuted : colors.textDim}>
176
+ <Text fg={inert ? colors.textMuted : colors.textDim}>
162
177
  {` ${shortcut}`}
163
178
  </Text>
164
179
  )}
@@ -60,7 +60,7 @@ export type { TabsProps } from "./tabs";
60
60
  export { SegmentedControl } from "./toggle";
61
61
  export type { SegmentedControlProps } from "./toggle";
62
62
  export { EmptyState, PaneStatusBody, Notice, loadingText, unavailableText } from "./status";
63
- export type { EmptyStateProps, PaneStatusBodyProps, NoticeProps } from "./status";
63
+ export type { EmptyStateProps, PaneStatusBodyProps, NoticeProps, StatusRetry } from "./status";
64
64
  export { Badge, BulletList, Divider, FigureList, KeyValueRow, Prose, READING_WIDTH, Section, SectionHeading } from "./display";
65
65
  export type {
66
66
  BadgeProps, BulletListProps, DividerProps, FigureListItem, FigureListProps, KeyValueRowProps, ProseProps, SectionProps,