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.
- package/README.md +9 -7
- package/package.json +14 -13
- package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
- package/src/app/global-shortcuts.test.tsx +54 -4
- package/src/app/global-shortcuts.ts +11 -5
- package/src/components/command-bar/action-runtime.ts +1 -0
- package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
- package/src/components/command-bar/layout-items/current-layout.ts +33 -0
- package/src/components/command-bar/list/view.tsx +3 -1
- package/src/components/command-bar/pane-templates/items.ts +4 -1
- package/src/components/command-bar/panel/body.tsx +1 -0
- package/src/components/command-bar/routes/root/results.ts +0 -30
- package/src/components/command-bar/routes/root/runtime.ts +2 -20
- package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
- package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
- package/src/components/command-bar/routing/launch-request.ts +14 -7
- package/src/components/command-bar/routing/list-state.ts +1 -3
- package/src/components/command-bar/surface/index.test.tsx +76 -4
- package/src/components/command-bar/surface/index.tsx +0 -2
- package/src/components/command-bar/surface/pane.test.tsx +1 -0
- package/src/components/command-bar/surface/test-harness.tsx +1 -0
- package/src/components/command-bar/view-model.ts +3 -3
- package/src/components/layout/shell/index.test.tsx +120 -2
- package/src/components/layout/shell/index.tsx +2 -0
- package/src/components/layout/shell/menu.ts +1 -7
- package/src/components/layout/shell/pane/actions.ts +8 -0
- package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
- package/src/components/layout/shell/shortcuts.ts +14 -6
- package/src/components/layout/shell/window-mode/index.ts +8 -3
- package/src/components/layout/window-edit/presentation.ts +4 -4
- package/src/components/onboarding/onboarding-steps.tsx +3 -2
- package/src/components/ticker/list-table-view.tsx +6 -0
- package/src/components/ui/remote-image.tsx +1 -14
- package/src/components/ui/tabs.tsx +95 -80
- package/src/components/ui/ui.test.tsx +54 -0
- package/src/core/state/app/types.ts +12 -4
- package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
- package/src/plugins/builtin/chat/content/index.tsx +5 -4
- package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
- package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
- package/src/plugins/builtin/help/components.tsx +1 -1
- package/src/plugins/builtin/help/index.test.tsx +105 -30
- package/src/plugins/builtin/help/index.tsx +324 -168
- package/src/plugins/builtin/help/shortcut-model.ts +5 -6
- package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
- package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
- package/src/plugins/builtin/substack/api/auth.ts +185 -0
- package/src/plugins/builtin/substack/api/cache.ts +78 -0
- package/src/plugins/builtin/substack/api/loaders.ts +146 -0
- package/src/plugins/builtin/substack/api/store.ts +186 -0
- package/src/plugins/builtin/substack/api/types.ts +40 -0
- package/src/plugins/builtin/substack/api.test.ts +298 -0
- package/src/plugins/builtin/substack/article-detail.tsx +359 -0
- package/src/plugins/builtin/substack/article-stack.tsx +129 -0
- package/src/plugins/builtin/substack/content.ts +478 -0
- package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
- package/src/plugins/builtin/substack/index.tsx +49 -0
- package/src/plugins/builtin/substack/login-view.tsx +126 -0
- package/src/plugins/builtin/substack/model.test.ts +155 -0
- package/src/plugins/builtin/substack/normalize.ts +343 -0
- package/src/plugins/builtin/substack/pane-footer.ts +71 -0
- package/src/plugins/builtin/substack/pane-state.ts +170 -0
- package/src/plugins/builtin/substack/pane.tsx +500 -0
- package/src/plugins/builtin/substack/table.ts +111 -0
- package/src/plugins/builtin/substack/types.ts +71 -0
- package/src/plugins/builtin/substack/utils.ts +131 -0
- package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/pane-manager/layout-state.ts +11 -0
- package/src/plugins/pane-manager.ts +1 -1
- package/src/react/use-throttled-commit-value.ts +116 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
- package/src/renderers/electrobun/view/http-fetch.ts +10 -3
- package/src/state/app/context/index.test.tsx +16 -0
- package/src/tickers/search/index.ts +1 -0
- 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
|
-
|
|
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
|
|
29
|
+
interface CommandBarLaunchRequestBase {
|
|
30
|
+
sequence: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CommandBarPluginLaunchRequest extends CommandBarLaunchRequestBase {
|
|
30
34
|
kind: "plugin-command";
|
|
31
35
|
commandId: string;
|
|
32
|
-
|
|
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:
|
|
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
|
-
|
|
93
|
+
setSidebarCursorChannelId(channel.id, { immediate: true });
|
|
83
94
|
closeProfilePopover();
|
|
84
95
|
} catch {}
|
|
85
|
-
}, [
|
|
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
|
-
|
|
123
|
+
setSidebarCursorChannelId(nextChannel.id, { immediate: true });
|
|
113
124
|
return true;
|
|
114
|
-
}, [
|
|
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
|
-
}, [
|
|
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 ===
|
|
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
|
-
|
|
163
|
+
setSidebarCursorChannelId(nextChannel.id);
|
|
142
164
|
return true;
|
|
143
|
-
}, [
|
|
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={
|
|
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={() =>
|
|
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>
|