gloomberb 0.8.2 → 0.9.0

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 (85) hide show
  1. package/README.md +9 -7
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/app/global-shortcuts.test.tsx +54 -4
  5. package/src/app/global-shortcuts.ts +11 -5
  6. package/src/components/command-bar/action-runtime.ts +1 -0
  7. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  8. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  9. package/src/components/command-bar/list/view.tsx +3 -1
  10. package/src/components/command-bar/pane-templates/items.ts +4 -1
  11. package/src/components/command-bar/panel/body.tsx +1 -0
  12. package/src/components/command-bar/routes/root/results.ts +0 -30
  13. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  14. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  15. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  16. package/src/components/command-bar/routing/launch-request.ts +14 -7
  17. package/src/components/command-bar/routing/list-state.ts +1 -3
  18. package/src/components/command-bar/surface/index.test.tsx +76 -4
  19. package/src/components/command-bar/surface/index.tsx +0 -2
  20. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  21. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  22. package/src/components/command-bar/view-model.ts +3 -3
  23. package/src/components/layout/shell/index.test.tsx +120 -2
  24. package/src/components/layout/shell/index.tsx +2 -0
  25. package/src/components/layout/shell/menu.ts +1 -7
  26. package/src/components/layout/shell/pane/actions.ts +8 -0
  27. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  28. package/src/components/layout/shell/shortcuts.ts +14 -6
  29. package/src/components/layout/shell/window-mode/index.ts +8 -3
  30. package/src/components/layout/window-edit/presentation.ts +4 -4
  31. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  32. package/src/components/ticker/list-table-view.tsx +6 -0
  33. package/src/components/ui/remote-image.tsx +1 -14
  34. package/src/components/ui/tabs.tsx +95 -80
  35. package/src/components/ui/ui.test.tsx +54 -0
  36. package/src/core/state/app/types.ts +12 -4
  37. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  38. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  39. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  40. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  41. package/src/plugins/builtin/help/components.tsx +1 -1
  42. package/src/plugins/builtin/help/index.test.tsx +105 -30
  43. package/src/plugins/builtin/help/index.tsx +324 -168
  44. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  45. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  46. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  47. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  48. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  49. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  50. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  51. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  52. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  53. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  54. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  55. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  56. package/src/plugins/builtin/substack/api/store.ts +186 -0
  57. package/src/plugins/builtin/substack/api/types.ts +40 -0
  58. package/src/plugins/builtin/substack/api.test.ts +298 -0
  59. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  60. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  61. package/src/plugins/builtin/substack/content.ts +478 -0
  62. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  63. package/src/plugins/builtin/substack/index.tsx +49 -0
  64. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  65. package/src/plugins/builtin/substack/model.test.ts +155 -0
  66. package/src/plugins/builtin/substack/normalize.ts +343 -0
  67. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  68. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  69. package/src/plugins/builtin/substack/pane.tsx +500 -0
  70. package/src/plugins/builtin/substack/table.ts +111 -0
  71. package/src/plugins/builtin/substack/types.ts +71 -0
  72. package/src/plugins/builtin/substack/utils.ts +131 -0
  73. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  74. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  75. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  76. package/src/plugins/catalog-ui.ts +2 -0
  77. package/src/plugins/pane-manager/layout-state.ts +11 -0
  78. package/src/plugins/pane-manager.ts +1 -1
  79. package/src/react/use-throttled-commit-value.ts +116 -0
  80. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  81. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  82. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  83. package/src/state/app/context/index.test.tsx +16 -0
  84. package/src/tickers/search/index.ts +1 -0
  85. package/src/version.ts +1 -1
@@ -30,6 +30,7 @@ export interface TabsProps {
30
30
  onAdd?: () => void;
31
31
  focused?: boolean;
32
32
  keyboardNavigation?: boolean;
33
+ scrollable?: boolean;
33
34
  }
34
35
 
35
36
  const WHEEL_DELTA_PER_CELL = 8;
@@ -45,6 +46,7 @@ export function Tabs({
45
46
  onAdd,
46
47
  focused = false,
47
48
  keyboardNavigation = true,
49
+ scrollable = true,
48
50
  }: TabsProps) {
49
51
  const ui = useUiHost();
50
52
  const NativeTabs = ui.Tabs;
@@ -131,6 +133,7 @@ export function Tabs({
131
133
  closeMode={closeMode}
132
134
  addLabel={addLabel}
133
135
  onAdd={onAdd}
136
+ scrollable={scrollable}
134
137
  palette={palette}
135
138
  />
136
139
  );
@@ -154,6 +157,7 @@ function OpenTuiTabs({
154
157
  closeMode = "always",
155
158
  addLabel = "+",
156
159
  onAdd,
160
+ scrollable = true,
157
161
  palette,
158
162
  }: TabsProps & {
159
163
  palette: {
@@ -227,6 +231,96 @@ function OpenTuiTabs({
227
231
  scrollBox.scrollTo({ x: nextLeft, y: scrollBox.scrollTop });
228
232
  };
229
233
 
234
+ const tabRow = (
235
+ <Box flexDirection="row" width={totalWidth} height={1}>
236
+ {tabs.map((tab, index) => {
237
+ const active = tab.value === activeValue;
238
+ const hovered = hoveredValue === tab.value && !tab.disabled;
239
+ const tabWidth = tabWidths[index] ?? tab.label.length + 2;
240
+ const showClose = !!tab.onClose && (closeMode === "always" || active);
241
+ const attributes = (active ? TextAttributes.BOLD : 0)
242
+ | (variant === "underline" && !compact && (active || hovered) ? TextAttributes.UNDERLINE : 0);
243
+ const startHover = tab.disabled
244
+ ? undefined
245
+ : () => {
246
+ setHoveredValue((current) => (current === tab.value ? current : tab.value));
247
+ };
248
+ const endHover = tab.disabled
249
+ ? undefined
250
+ : () => {
251
+ setHoveredValue((current) => (current === tab.value ? null : current));
252
+ };
253
+ const selectTab = tab.disabled
254
+ ? undefined
255
+ : (event: TabPointerEvent) => {
256
+ if (event.button === 2 && tab.onContextMenu) {
257
+ event.preventDefault?.();
258
+ event.stopPropagation?.();
259
+ tab.onContextMenu(tab.value, event);
260
+ return;
261
+ }
262
+ event.preventDefault();
263
+ event.stopPropagation?.();
264
+ onSelect(tab.value);
265
+ };
266
+
267
+ return (
268
+ <Box
269
+ key={tab.value}
270
+ width={tabWidth}
271
+ height={1}
272
+ flexDirection="row"
273
+ backgroundColor={active && variant === "pill" ? palette.activeBg : hovered ? palette.hoverBg : undefined}
274
+ onMouseOver={startHover}
275
+ onMouseMove={startHover}
276
+ onMouseOut={endHover}
277
+ onMouseDown={selectTab}
278
+ onDoubleClick={tab.disabled || !tab.onDoubleClick ? undefined : () => tab.onDoubleClick?.(tab.value)}
279
+ >
280
+ <Text
281
+ fg={tab.disabled ? palette.disabledFg : active && variant === "pill" ? palette.activePillFg : active ? palette.activeFg : hovered ? palette.hoverFg : palette.inactiveFg}
282
+ attributes={attributes}
283
+ onMouseDown={selectTab}
284
+ >
285
+ {` ${tab.label} `}
286
+ </Text>
287
+ {showClose && (
288
+ <Text
289
+ fg={active && variant === "pill" ? palette.activePillFg : palette.closeFg}
290
+ onMouseDown={(event: any) => {
291
+ event.preventDefault?.();
292
+ event.stopPropagation?.();
293
+ tab.onClose?.(tab.value);
294
+ }}
295
+ >
296
+ {"x "}
297
+ </Text>
298
+ )}
299
+ </Box>
300
+ );
301
+ })}
302
+ {onAdd && (
303
+ <Box
304
+ width={addWidth}
305
+ height={1}
306
+ backgroundColor={hoveredValue === "__add__" ? palette.hoverBg : undefined}
307
+ onMouseMove={() => setHoveredValue((current) => (current === "__add__" ? current : "__add__"))}
308
+ onMouseOut={() => setHoveredValue((current) => (current === "__add__" ? null : current))}
309
+ onMouseDown={(event: TabPointerEvent) => {
310
+ event.preventDefault();
311
+ onAdd();
312
+ }}
313
+ >
314
+ <Text fg={hoveredValue === "__add__" ? palette.hoverFg : palette.addFg}>
315
+ {` ${addLabel} `}
316
+ </Text>
317
+ </Box>
318
+ )}
319
+ </Box>
320
+ );
321
+
322
+ if (!scrollable) return tabRow;
323
+
230
324
  return (
231
325
  <ScrollBox
232
326
  ref={scrollRef}
@@ -237,86 +331,7 @@ function OpenTuiTabs({
237
331
  horizontalScrollbarOptions={{ visible: false }}
238
332
  onMouseScroll={handleMouseScroll}
239
333
  >
240
- <Box flexDirection="row" width={totalWidth} height={1}>
241
- {tabs.map((tab, index) => {
242
- const active = tab.value === activeValue;
243
- const hovered = hoveredValue === tab.value && !tab.disabled;
244
- const tabWidth = tabWidths[index] ?? tab.label.length + 2;
245
- const showClose = !!tab.onClose && (closeMode === "always" || active);
246
- const attributes = (active ? TextAttributes.BOLD : 0)
247
- | (variant === "underline" && !compact && (active || hovered) ? TextAttributes.UNDERLINE : 0);
248
- const startHover = tab.disabled
249
- ? undefined
250
- : () => {
251
- setHoveredValue((current) => (current === tab.value ? current : tab.value));
252
- };
253
- const endHover = tab.disabled
254
- ? undefined
255
- : () => {
256
- setHoveredValue((current) => (current === tab.value ? null : current));
257
- };
258
-
259
- return (
260
- <Box
261
- key={tab.value}
262
- width={tabWidth}
263
- height={1}
264
- flexDirection="row"
265
- backgroundColor={active && variant === "pill" ? palette.activeBg : hovered ? palette.hoverBg : undefined}
266
- onMouseOver={startHover}
267
- onMouseMove={startHover}
268
- onMouseOut={endHover}
269
- onMouseDown={tab.disabled ? undefined : (event: TabPointerEvent) => {
270
- if (event.button === 2 && tab.onContextMenu) {
271
- event.preventDefault?.();
272
- event.stopPropagation?.();
273
- tab.onContextMenu(tab.value, event);
274
- return;
275
- }
276
- event.preventDefault();
277
- onSelect(tab.value);
278
- }}
279
- onDoubleClick={tab.disabled || !tab.onDoubleClick ? undefined : () => tab.onDoubleClick?.(tab.value)}
280
- >
281
- <Text
282
- fg={tab.disabled ? palette.disabledFg : active && variant === "pill" ? palette.activePillFg : active ? palette.activeFg : hovered ? palette.hoverFg : palette.inactiveFg}
283
- attributes={attributes}
284
- >
285
- {` ${tab.label} `}
286
- </Text>
287
- {showClose && (
288
- <Text
289
- fg={active && variant === "pill" ? palette.activePillFg : palette.closeFg}
290
- onMouseDown={(event: any) => {
291
- event.preventDefault?.();
292
- event.stopPropagation?.();
293
- tab.onClose?.(tab.value);
294
- }}
295
- >
296
- {"x "}
297
- </Text>
298
- )}
299
- </Box>
300
- );
301
- })}
302
- {onAdd && (
303
- <Box
304
- width={addWidth}
305
- height={1}
306
- backgroundColor={hoveredValue === "__add__" ? palette.hoverBg : undefined}
307
- onMouseMove={() => setHoveredValue((current) => (current === "__add__" ? current : "__add__"))}
308
- onMouseOut={() => setHoveredValue((current) => (current === "__add__" ? null : current))}
309
- onMouseDown={(event: TabPointerEvent) => {
310
- event.preventDefault();
311
- onAdd();
312
- }}
313
- >
314
- <Text fg={hoveredValue === "__add__" ? palette.hoverFg : palette.addFg}>
315
- {` ${addLabel} `}
316
- </Text>
317
- </Box>
318
- )}
319
- </Box>
334
+ {tabRow}
320
335
  </ScrollBox>
321
336
  );
322
337
  }
@@ -360,6 +360,60 @@ describe("shared UI kit", () => {
360
360
  expect(lastSelected).toBe("chart");
361
361
  });
362
362
 
363
+ test("selects tabs by clicking their text labels", async () => {
364
+ let lastSelected = "overview";
365
+ const selectedValues: string[] = [];
366
+
367
+ function PointerTabsHarness() {
368
+ const [activeValue, setActiveValue] = useState("overview");
369
+ return (
370
+ <Tabs
371
+ tabs={[
372
+ { label: "Overview", value: "overview" },
373
+ { label: "News", value: "news" },
374
+ { label: "Chart", value: "chart" },
375
+ ]}
376
+ activeValue={activeValue}
377
+ onSelect={(value) => {
378
+ lastSelected = value;
379
+ selectedValues.push(value);
380
+ setActiveValue(value);
381
+ }}
382
+ scrollable={false}
383
+ />
384
+ );
385
+ }
386
+
387
+ testSetup = await testRender(<PointerTabsHarness />, { width: 40, height: 4 });
388
+
389
+ await act(async () => {
390
+ await testSetup!.renderOnce();
391
+ });
392
+
393
+ let frame = testSetup.captureCharFrame();
394
+ const newsCol = frame.split("\n")[0]!.indexOf("News");
395
+ expect(newsCol).toBeGreaterThanOrEqual(0);
396
+
397
+ await act(async () => {
398
+ await testSetup!.mockMouse.click(newsCol + 1, 0);
399
+ await testSetup!.renderOnce();
400
+ });
401
+
402
+ expect(lastSelected).toBe("news");
403
+
404
+ frame = testSetup.captureCharFrame();
405
+ const chartCol = frame.split("\n")[0]!.indexOf("Chart");
406
+ expect(chartCol).toBeGreaterThanOrEqual(0);
407
+
408
+ await act(async () => {
409
+ await testSetup!.mockMouse.click(chartCol + 1, 0);
410
+ await testSetup!.renderOnce();
411
+ });
412
+
413
+ expect(selectedValues).toEqual(["news", "chart"]);
414
+ expect(lastSelected).toBe("chart");
415
+ });
416
+
363
417
  test("renders tab actions for editable tab sets", async () => {
364
418
  testSetup = await testRender(
365
419
  <Tabs
@@ -26,10 +26,18 @@ export interface CollectionSortPreference {
26
26
  direction: SortDirection;
27
27
  }
28
28
 
29
- interface CommandBarLaunchRequest {
29
+ interface CommandBarLaunchRequestBase {
30
+ sequence: number;
31
+ }
32
+
33
+ interface CommandBarPluginLaunchRequest extends CommandBarLaunchRequestBase {
30
34
  kind: "plugin-command";
31
35
  commandId: string;
32
- sequence: number;
36
+ }
37
+
38
+ interface CommandBarTickerSearchLaunchRequest extends CommandBarLaunchRequestBase {
39
+ kind: "ticker-search";
40
+ query?: string;
33
41
  }
34
42
 
35
43
  export interface AppState {
@@ -44,7 +52,7 @@ export interface AppState {
44
52
  recentTickers: string[];
45
53
  commandBarOpen: boolean;
46
54
  commandBarQuery: string;
47
- commandBarLaunchRequest: CommandBarLaunchRequest | null;
55
+ commandBarLaunchRequest: CommandBarPluginLaunchRequest | CommandBarTickerSearchLaunchRequest | null;
48
56
  themePreview: string | null;
49
57
  refreshing: Set<string>;
50
58
  initialized: boolean;
@@ -74,7 +82,7 @@ export type AppAction =
74
82
  type: "SET_COMMAND_BAR";
75
83
  open: boolean;
76
84
  query?: string;
77
- launch?: { kind: "plugin-command"; commandId: string } | null;
85
+ launch?: { kind: "plugin-command"; commandId: string } | { kind: "ticker-search"; query?: string } | null;
78
86
  }
79
87
  | { type: "SET_COMMAND_BAR_QUERY"; query: string }
80
88
  | { type: "SET_REFRESHING"; symbol: string; refreshing: boolean }
@@ -1,12 +1,15 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
3
3
  import type { ChatChannel, ChatUserSummary } from "../../../../api-client";
4
+ import { useThrottledCommitValue } from "../../../../react/use-throttled-commit-value";
4
5
  import {
5
6
  DEFAULT_CHAT_CHANNEL_ID,
6
7
  normalizeChannelId,
7
8
  } from "../channels";
8
9
  import type { ChatContentController } from "./types";
9
10
 
11
+ const CHANNEL_NAVIGATION_COMMIT_DELAY_MS = 150;
12
+
10
13
  function isConversationChannelId(channelId: string): boolean {
11
14
  return channelId.startsWith("dm:") || channelId.startsWith("grp:") || channelId.startsWith("group:");
12
15
  }
@@ -38,15 +41,16 @@ export function useChatChannelNavigation({
38
41
  resetTranscriptSelection: () => void;
39
42
  showChannelSidebar: boolean;
40
43
  }): {
41
- changeChannel: (nextChannelId: string) => void;
42
44
  cycleChannel: (direction: 1 | -1) => boolean;
43
45
  directExpanded: boolean;
44
46
  focusChannelSidebar: () => boolean;
45
47
  focusChatContent: () => boolean;
46
48
  moveSidebarChannelSelection: (direction: "up" | "down") => boolean;
47
49
  openDirectMessage: (target: ChatUserSummary) => Promise<void>;
50
+ selectSidebarChannel: (channelId: string) => void;
48
51
  setDirectExpanded: Dispatch<SetStateAction<boolean>>;
49
52
  setSidebarFocused: (nextFocused: boolean) => void;
53
+ sidebarCursorChannelId: string;
50
54
  sidebarFocused: boolean;
51
55
  sidebarFocusedRef: MutableRefObject<boolean>;
52
56
  } {
@@ -70,6 +74,13 @@ export function useChatChannelNavigation({
70
74
  resetTranscriptSelection();
71
75
  onChannelChange?.(normalized);
72
76
  }, [channelIdRef, onChannelChange, resetTranscriptSelection]);
77
+ const {
78
+ value: sidebarCursorChannelId,
79
+ valueRef: sidebarCursorChannelIdRef,
80
+ setValue: setSidebarCursorChannelId,
81
+ flushValue: flushSidebarCursorChannelId,
82
+ replaceValue: replaceSidebarCursorChannelId,
83
+ } = useThrottledCommitValue(channelId, changeChannel, CHANNEL_NAVIGATION_COMMIT_DELAY_MS);
73
84
 
74
85
  const openDirectMessage = useCallback(async (target: ChatUserSummary) => {
75
86
  if (!target.id && !target.username) return;
@@ -79,10 +90,10 @@ export function useChatChannelNavigation({
79
90
  username: target.username ?? undefined,
80
91
  });
81
92
  setDirectExpanded(true);
82
- changeChannel(channel.id);
93
+ setSidebarCursorChannelId(channel.id, { immediate: true });
83
94
  closeProfilePopover();
84
95
  } catch {}
85
- }, [changeChannel, closeProfilePopover, controller]);
96
+ }, [closeProfilePopover, controller, setSidebarCursorChannelId]);
86
97
 
87
98
  useEffect(() => {
88
99
  if (!onChannelChange || channelsLoading || channels.length === 0) return;
@@ -109,9 +120,9 @@ export function useChatChannelNavigation({
109
120
  const nextIndex = (currentIndex + direction + channels.length) % channels.length;
110
121
  const nextChannel = channels[nextIndex];
111
122
  if (!nextChannel) return false;
112
- changeChannel(nextChannel.id);
123
+ setSidebarCursorChannelId(nextChannel.id, { immediate: true });
113
124
  return true;
114
- }, [changeChannel, channelIdRef, channels, onChannelChange]);
125
+ }, [channelIdRef, channels, onChannelChange, setSidebarCursorChannelId]);
115
126
 
116
127
  const focusChannelSidebar = useCallback(() => {
117
128
  if (!showChannelSidebar || !onChannelChange) return false;
@@ -119,28 +130,49 @@ export function useChatChannelNavigation({
119
130
  blurInput();
120
131
  }
121
132
  resetTranscriptSelection();
133
+ replaceSidebarCursorChannelId(channelId);
122
134
  setSidebarFocused(true);
123
135
  return true;
124
- }, [blurInput, inputFocused, onChannelChange, resetTranscriptSelection, setSidebarFocused, showChannelSidebar]);
136
+ }, [
137
+ blurInput,
138
+ channelId,
139
+ inputFocused,
140
+ onChannelChange,
141
+ replaceSidebarCursorChannelId,
142
+ resetTranscriptSelection,
143
+ setSidebarFocused,
144
+ showChannelSidebar,
145
+ ]);
125
146
 
126
147
  const focusChatContent = useCallback(() => {
127
148
  if (!showChannelSidebar) return false;
149
+ flushSidebarCursorChannelId(sidebarCursorChannelIdRef.current);
128
150
  setSidebarFocused(false);
129
151
  return true;
130
- }, [setSidebarFocused, showChannelSidebar]);
152
+ }, [flushSidebarCursorChannelId, setSidebarFocused, showChannelSidebar, sidebarCursorChannelIdRef]);
131
153
 
132
154
  const moveSidebarChannelSelection = useCallback((direction: "up" | "down") => {
133
155
  if (!showChannelSidebar || sidebarNavigationChannels.length <= 1 || !onChannelChange) return false;
134
- const currentIndex = sidebarNavigationChannels.findIndex((channel) => channel.id === channelIdRef.current);
156
+ const currentIndex = sidebarNavigationChannels.findIndex((channel) => channel.id === sidebarCursorChannelIdRef.current);
135
157
  const baseIndex = currentIndex >= 0 ? currentIndex : 0;
136
158
  const nextIndex = direction === "down"
137
159
  ? Math.min(baseIndex + 1, sidebarNavigationChannels.length - 1)
138
160
  : Math.max(baseIndex - 1, 0);
139
161
  const nextChannel = sidebarNavigationChannels[nextIndex];
140
162
  if (!nextChannel || nextIndex === baseIndex) return true;
141
- changeChannel(nextChannel.id);
163
+ setSidebarCursorChannelId(nextChannel.id);
142
164
  return true;
143
- }, [changeChannel, channelIdRef, onChannelChange, showChannelSidebar, sidebarNavigationChannels]);
165
+ }, [
166
+ onChannelChange,
167
+ setSidebarCursorChannelId,
168
+ showChannelSidebar,
169
+ sidebarCursorChannelIdRef,
170
+ sidebarNavigationChannels,
171
+ ]);
172
+
173
+ const selectSidebarChannel = useCallback((nextChannelId: string) => {
174
+ setSidebarCursorChannelId(nextChannelId, { immediate: true });
175
+ }, [setSidebarCursorChannelId]);
144
176
 
145
177
  useEffect(() => {
146
178
  if (focused && showChannelSidebar) return;
@@ -148,15 +180,16 @@ export function useChatChannelNavigation({
148
180
  }, [focused, setSidebarFocused, showChannelSidebar]);
149
181
 
150
182
  return {
151
- changeChannel,
152
183
  cycleChannel,
153
184
  directExpanded,
154
185
  focusChannelSidebar,
155
186
  focusChatContent,
156
187
  moveSidebarChannelSelection,
157
188
  openDirectMessage,
189
+ selectSidebarChannel,
158
190
  setDirectExpanded,
159
191
  setSidebarFocused,
192
+ sidebarCursorChannelId,
160
193
  sidebarFocused,
161
194
  sidebarFocusedRef,
162
195
  };
@@ -212,15 +212,16 @@ export function ChatContent({
212
212
  });
213
213
 
214
214
  const {
215
- changeChannel,
216
215
  cycleChannel,
217
216
  directExpanded,
218
217
  focusChannelSidebar,
219
218
  focusChatContent,
220
219
  moveSidebarChannelSelection,
221
220
  openDirectMessage,
221
+ selectSidebarChannel,
222
222
  setDirectExpanded,
223
223
  setSidebarFocused,
224
+ sidebarCursorChannelId,
224
225
  sidebarFocused,
225
226
  sidebarFocusedRef,
226
227
  } = useChatChannelNavigation({
@@ -367,7 +368,7 @@ export function ChatContent({
367
368
  <ChannelSidebar
368
369
  channels={channels}
369
370
  channelStates={channelStates}
370
- activeChannelId={channelId}
371
+ activeChannelId={sidebarFocused ? sidebarCursorChannelId : channelId}
371
372
  onlineCount={onlineCount}
372
373
  width={channelSidebarWidth}
373
374
  height={height}
@@ -376,7 +377,7 @@ export function ChatContent({
376
377
  loading={channelsLoading}
377
378
  canManageNotifications={!!user?.emailVerified}
378
379
  directExpanded={directExpanded}
379
- onSelect={changeChannel}
380
+ onSelect={selectSidebarChannel}
380
381
  onFocusRequest={() => setSidebarFocused(true)}
381
382
  onToggleNotifications={(nextChannelId, enabled) => {
382
383
  controller.setChannelNotificationsEnabled(nextChannelId, enabled);
@@ -392,7 +393,7 @@ export function ChatContent({
392
393
  flexGrow={nativePaneChrome ? 1 : undefined}
393
394
  backgroundColor={chatContentBg}
394
395
  position="relative"
395
- onMouseDown={() => setSidebarFocused(false)}
396
+ onMouseDown={() => focusChatContent()}
396
397
  style={nativeFillStyle}
397
398
  >
398
399
  {!nativePaneChrome && (
@@ -75,6 +75,13 @@ export function useChatContentShortcuts({
75
75
  const isEnterKey = event.name === "return" || event.name === "enter";
76
76
 
77
77
  if (sidebarFocusedRef.current && showChannelSidebar) {
78
+ if (isEnterKey) {
79
+ event.preventDefault?.();
80
+ event.stopPropagation?.();
81
+ focusChatContent();
82
+ return;
83
+ }
84
+
78
85
  if (isPlainKey(event, "left")) {
79
86
  event.preventDefault?.();
80
87
  event.stopPropagation?.();
@@ -9,6 +9,7 @@ import { PluginRenderProvider } from "../../runtime";
9
9
  import { gloomberbCloudPlugin } from "../cloud";
10
10
  import { ChatContent } from "../chat";
11
11
  import { chatController } from "./controller";
12
+ import { useChatChannelNavigation } from "./content/channel-navigation";
12
13
  import {
13
14
  cleanupChatTest,
14
15
  createChatTestControls,
@@ -285,6 +286,76 @@ describe("ChatContent channel sidebar", () => {
285
286
  expect(setup().captureCharFrame()).toContain("#equities");
286
287
  });
287
288
 
289
+ test("coalesces rapid sidebar navigation to the final channel", async () => {
290
+ const controller = createController({ sessionToken: "token-123" });
291
+ const channels = [
292
+ { id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
293
+ { id: "equities", name: "equities", created_at: "2026-05-09T00:00:00.000Z" },
294
+ { id: "options", name: "options", created_at: "2026-05-09T00:00:00.000Z" },
295
+ { id: "macro", name: "macro", created_at: "2026-05-09T00:00:00.000Z" },
296
+ { id: "crypto", name: "crypto", created_at: "2026-05-09T00:00:00.000Z" },
297
+ ];
298
+ const channelChanges: string[] = [];
299
+ let navigation: ReturnType<typeof useChatChannelNavigation> | null = null;
300
+ let latestCursorChannelId = "";
301
+ let latestCommittedChannelId = "";
302
+
303
+ function NavigationHarness() {
304
+ const [channelId, setChannelId] = useState("equities");
305
+ const channelIdRef = useRef(channelId);
306
+ channelIdRef.current = channelId;
307
+ const handleChannelChange = useCallback((nextChannelId: string) => {
308
+ channelChanges.push(nextChannelId);
309
+ setChannelId(nextChannelId);
310
+ }, []);
311
+ navigation = useChatChannelNavigation({
312
+ blurInput: () => {},
313
+ channelId,
314
+ channelIdRef,
315
+ channels,
316
+ channelsLoading: false,
317
+ closeProfilePopover: () => {},
318
+ controller,
319
+ focused: true,
320
+ inputFocused: false,
321
+ onChannelChange: handleChannelChange,
322
+ resetTranscriptSelection: () => {},
323
+ showChannelSidebar: true,
324
+ });
325
+ latestCursorChannelId = navigation.sidebarCursorChannelId;
326
+ latestCommittedChannelId = channelId;
327
+
328
+ return null;
329
+ }
330
+
331
+ await act(async () => {
332
+ testSetup = await testRender(<NavigationHarness />, {
333
+ width: 90,
334
+ height: 12,
335
+ });
336
+ });
337
+
338
+ await act(async () => {
339
+ navigation?.focusChannelSidebar();
340
+ navigation?.moveSidebarChannelSelection("down");
341
+ navigation?.moveSidebarChannelSelection("down");
342
+ navigation?.moveSidebarChannelSelection("down");
343
+ await setup().renderOnce();
344
+ });
345
+
346
+ expect(latestCursorChannelId).toBe("crypto");
347
+ expect(latestCommittedChannelId).toBe("equities");
348
+ expect(channelChanges).toEqual([]);
349
+
350
+ await act(async () => {
351
+ await new Promise((resolve) => setTimeout(resolve, 180));
352
+ await setup().renderOnce();
353
+ });
354
+
355
+ expect(channelChanges).toEqual(["crypto"]);
356
+ expect(latestCommittedChannelId).toBe("crypto");
357
+ });
358
+
288
359
  test("keeps the channel sidebar visible while a channel loads", async () => {
289
360
  const controller = createController({ sessionToken: "token-123" });
290
361
  installServerChannels(controller);
@@ -29,7 +29,7 @@ export function ShortcutRow({
29
29
  return (
30
30
  <Box flexDirection="row" gap={1}>
31
31
  <Box flexDirection="row" gap={1} flexShrink={0}>
32
- {badges.map((badge) => <ShortcutBadge key={badge} label={badge} />)}
32
+ {badges.map((badge, index) => <ShortcutBadge key={`${badge}:${index}`} label={badge} />)}
33
33
  </Box>
34
34
  <Box flexGrow={1}>
35
35
  <Text fg={colors.text} wrapText>{description}</Text>