gloomberb 0.4.0 → 0.4.1
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/package.json +1 -1
- package/src/app.tsx +16 -4
- package/src/components/command-bar/command-bar.tsx +4 -0
- package/src/components/layout/floating-pane.tsx +3 -1
- package/src/components/layout/header.tsx +3 -3
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +77 -5
- package/src/components/layout/shell.tsx +5 -2
- package/src/components/ticker-list-table.tsx +8 -1
- package/src/components/ui/data-table.tsx +23 -3
- package/src/plugins/builtin/chat-controller.test.ts +30 -0
- package/src/plugins/builtin/chat-controller.ts +18 -0
- package/src/plugins/builtin/chat.tsx +5 -0
- package/src/plugins/builtin/layout-manager.tsx +9 -0
- package/src/plugins/ibkr/gateway-service.test.ts +22 -0
- package/src/plugins/ibkr/gateway-service.ts +6 -0
- package/src/plugins/ibkr/index.tsx +7 -1
- package/src/plugins/prediction-markets/controller-data.ts +121 -14
- package/src/plugins/prediction-markets/controller.ts +1 -1
- package/src/plugins/prediction-markets/index.tsx +5 -1
- package/src/plugins/prediction-markets/pane.test.tsx +78 -0
- package/src/plugins/prediction-markets/pane.tsx +7 -3
- package/src/plugins/prediction-markets/services/fetch.ts +4 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
- package/src/plugins/registry.ts +23 -0
- package/src/updater.test.ts +2 -0
- package/src/utils/api-client.ts +12 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -51,7 +51,7 @@ import { setLayoutManagerDispatch } from "./plugins/builtin/layout-manager";
|
|
|
51
51
|
import { getLoadablePlugins } from "./plugins/catalog";
|
|
52
52
|
import { saveConfig } from "./data/config-store";
|
|
53
53
|
import { Toaster, toast } from "@opentui-ui/toast/react";
|
|
54
|
-
import { canSelfUpdate, checkForUpdateDetailed, performUpdate } from "./updater";
|
|
54
|
+
import { canSelfUpdate, checkForUpdateDetailed, performUpdate, type ReleaseInfo } from "./updater";
|
|
55
55
|
import { VERSION } from "./version";
|
|
56
56
|
import { join } from "path";
|
|
57
57
|
import {
|
|
@@ -525,6 +525,13 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
|
|
|
525
525
|
}
|
|
526
526
|
}, [state.config.brokerInstances, importBrokerPositions]);
|
|
527
527
|
|
|
528
|
+
const startUpdate = useCallback((release: ReleaseInfo) => {
|
|
529
|
+
dispatch({ type: "SET_UPDATE_PROGRESS", progress: { phase: "downloading", percent: 0 } });
|
|
530
|
+
void performUpdate(release, (progress) => {
|
|
531
|
+
dispatch({ type: "SET_UPDATE_PROGRESS", progress });
|
|
532
|
+
});
|
|
533
|
+
}, [dispatch]);
|
|
534
|
+
|
|
528
535
|
const runUpdateCheck = useCallback(async (manual = false) => {
|
|
529
536
|
if (manual) {
|
|
530
537
|
dispatch({ type: "SET_UPDATE_CHECK_IN_PROGRESS", checking: true });
|
|
@@ -566,6 +573,12 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
|
|
|
566
573
|
void runUpdateCheck(false);
|
|
567
574
|
}, [runUpdateCheck]);
|
|
568
575
|
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
if (!state.updateAvailable || state.updateProgress || state.updateCheckInProgress) return;
|
|
578
|
+
if (!canSelfUpdate(state.updateAvailable)) return;
|
|
579
|
+
startUpdate(state.updateAvailable);
|
|
580
|
+
}, [startUpdate, state.updateAvailable, state.updateCheckInProgress, state.updateProgress]);
|
|
581
|
+
|
|
569
582
|
// Load tickers on mount
|
|
570
583
|
useEffect(() => {
|
|
571
584
|
if (state.initialized || (globalThis as any).__gloomInitStarted) return;
|
|
@@ -1172,9 +1185,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
|
|
|
1172
1185
|
dispatch({ type: "SET_COMMAND_BAR", open: true, query: "" });
|
|
1173
1186
|
}
|
|
1174
1187
|
} else if (event.name === "u" && state.updateAvailable && !state.updateProgress && !state.updateCheckInProgress && canSelfUpdate(state.updateAvailable)) {
|
|
1175
|
-
|
|
1176
|
-
dispatch({ type: "SET_UPDATE_PROGRESS", progress });
|
|
1177
|
-
});
|
|
1188
|
+
startUpdate(state.updateAvailable);
|
|
1178
1189
|
} else {
|
|
1179
1190
|
// Plugin keyboard shortcuts (built-ins take priority)
|
|
1180
1191
|
const disabledPlugins = new Set(state.config.disabledPlugins || []);
|
|
@@ -1278,6 +1289,7 @@ export function App({
|
|
|
1278
1289
|
useEffect(() => {
|
|
1279
1290
|
return () => {
|
|
1280
1291
|
setSharedMarketDataCoordinator(null);
|
|
1292
|
+
services.pluginRegistry.destroy();
|
|
1281
1293
|
services.persistence.close();
|
|
1282
1294
|
};
|
|
1283
1295
|
}, [services]);
|
|
@@ -2488,6 +2488,10 @@ export function CommandBar({
|
|
|
2488
2488
|
return name ? `in "${name}"` : command.description;
|
|
2489
2489
|
}
|
|
2490
2490
|
case "check-for-updates":
|
|
2491
|
+
if (state.updateProgress?.phase === "downloading") return `Downloading v${state.updateAvailable?.version}: ${state.updateProgress.percent ?? 0}%`;
|
|
2492
|
+
if (state.updateProgress?.phase === "replacing") return "Installing update";
|
|
2493
|
+
if (state.updateProgress?.phase === "done") return "Update installed - restart to apply";
|
|
2494
|
+
if (state.updateProgress?.phase === "error") return `Update failed: ${state.updateProgress.error}`;
|
|
2491
2495
|
if (state.updateCheckInProgress) return "Checking GitHub releases now";
|
|
2492
2496
|
if (state.updateAvailable) return `Latest available: v${state.updateAvailable.version}`;
|
|
2493
2497
|
if (state.updateNotice) return state.updateNotice;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { colors, floatingPaneBg } from "../../theme/colors";
|
|
3
3
|
import { PaneHeader } from "./pane-header";
|
|
4
|
+
import { getPaneBodyHeight } from "./pane-sizing";
|
|
4
5
|
|
|
5
6
|
interface FloatingPaneWrapperProps {
|
|
6
7
|
title: string;
|
|
@@ -20,6 +21,7 @@ export function FloatingPaneWrapper({
|
|
|
20
21
|
title, x, y, width, height, zIndex, focused, showActions = false, onMouseMove, children,
|
|
21
22
|
}: FloatingPaneWrapperProps) {
|
|
22
23
|
const bg = floatingPaneBg(focused);
|
|
24
|
+
const bodyHeight = getPaneBodyHeight(height);
|
|
23
25
|
|
|
24
26
|
return (
|
|
25
27
|
<box
|
|
@@ -36,7 +38,7 @@ export function FloatingPaneWrapper({
|
|
|
36
38
|
<PaneHeader title={title} width={width} focused={focused} floating showActions={showActions} />
|
|
37
39
|
|
|
38
40
|
{/* Content */}
|
|
39
|
-
<box
|
|
41
|
+
<box height={bodyHeight} overflow="hidden">
|
|
40
42
|
{children}
|
|
41
43
|
</box>
|
|
42
44
|
|
|
@@ -31,7 +31,7 @@ function UpdateStatus() {
|
|
|
31
31
|
<box flexDirection="row" gap={1}>
|
|
32
32
|
<spinner name="dots" color={colors.headerText} />
|
|
33
33
|
<text fg={colors.headerText}>
|
|
34
|
-
Downloading v{updateAvailable?.version}
|
|
34
|
+
Downloading v{updateAvailable?.version}: {updateProgress.percent ?? 0}%
|
|
35
35
|
</text>
|
|
36
36
|
</box>
|
|
37
37
|
);
|
|
@@ -45,7 +45,7 @@ function UpdateStatus() {
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
if (updateProgress.phase === "done") {
|
|
48
|
-
return <text fg={colors.headerText}>Update installed
|
|
48
|
+
return <text fg={colors.headerText}>Update installed, restart to apply</text>;
|
|
49
49
|
}
|
|
50
50
|
if (updateProgress.phase === "error") {
|
|
51
51
|
return <text fg={colors.headerText}>Update failed: {updateProgress.error}</text>;
|
|
@@ -71,7 +71,7 @@ function UpdateStatus() {
|
|
|
71
71
|
}
|
|
72
72
|
return (
|
|
73
73
|
<text fg={colors.headerText}>
|
|
74
|
-
v{updateAvailable.version} available —
|
|
74
|
+
v{updateAvailable.version} available — starting download...
|
|
75
75
|
</text>
|
|
76
76
|
);
|
|
77
77
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { paneBg } from "../../theme/colors";
|
|
3
3
|
import { PaneHeader } from "./pane-header";
|
|
4
|
+
import { getPaneBodyHeight } from "./pane-sizing";
|
|
4
5
|
|
|
5
6
|
interface PaneWrapperProps {
|
|
6
7
|
title?: string;
|
|
@@ -26,6 +27,9 @@ export function PaneWrapper({
|
|
|
26
27
|
children,
|
|
27
28
|
}: PaneWrapperProps) {
|
|
28
29
|
const bg = paneBg(focused);
|
|
30
|
+
const bodyHeight = typeof height === "number"
|
|
31
|
+
? title ? getPaneBodyHeight(height) : height
|
|
32
|
+
: undefined;
|
|
29
33
|
|
|
30
34
|
return (
|
|
31
35
|
<box
|
|
@@ -41,7 +45,7 @@ export function PaneWrapper({
|
|
|
41
45
|
{title && (
|
|
42
46
|
<PaneHeader title={title} width={width} focused={focused} showActions={showActions} />
|
|
43
47
|
)}
|
|
44
|
-
<box flexGrow={1} overflow="hidden">
|
|
48
|
+
<box height={bodyHeight} flexGrow={bodyHeight == null ? 1 : 0} overflow="hidden">
|
|
45
49
|
{children}
|
|
46
50
|
</box>
|
|
47
51
|
</box>
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { testRender } from "@opentui/react/test-utils";
|
|
3
3
|
import { DialogProvider } from "@opentui-ui/dialog/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
4
5
|
import { act, useReducer } from "react";
|
|
5
6
|
import { AppContext, appReducer, createInitialState } from "../../state/app-context";
|
|
6
7
|
import { cloneLayout, createDefaultConfig } from "../../types/config";
|
|
7
8
|
import type { PluginRegistry } from "../../plugins/registry";
|
|
8
9
|
import { setSharedRegistryForTests } from "../../plugins/registry";
|
|
9
10
|
import { portfolioListPlugin } from "../../plugins/builtin/portfolio-list";
|
|
11
|
+
import type { PaneProps } from "../../types/plugin";
|
|
10
12
|
import { StatusBar } from "./status-bar";
|
|
11
13
|
import { Header } from "./header";
|
|
12
14
|
import { buildNativeWindowState, finalizePaneDragRelease, resolveNativeDockDividers, Shell } from "./shell";
|
|
@@ -26,7 +28,9 @@ afterEach(() => {
|
|
|
26
28
|
setSharedRegistryForTests(undefined);
|
|
27
29
|
});
|
|
28
30
|
|
|
29
|
-
function createShellPluginRegistry(
|
|
31
|
+
function createShellPluginRegistry(options?: {
|
|
32
|
+
tickerDetailComponent?: (props: PaneProps) => ReactNode;
|
|
33
|
+
}): PluginRegistry {
|
|
30
34
|
return {
|
|
31
35
|
panes: new Map([
|
|
32
36
|
["portfolio-list", {
|
|
@@ -38,7 +42,7 @@ function createShellPluginRegistry(): PluginRegistry {
|
|
|
38
42
|
["ticker-detail", {
|
|
39
43
|
id: "ticker-detail",
|
|
40
44
|
name: "Ticker Detail",
|
|
41
|
-
component: () => <text>Ticker Detail Body</text
|
|
45
|
+
component: options?.tickerDetailComponent ?? (() => <text>Ticker Detail Body</text>),
|
|
42
46
|
defaultPosition: "right",
|
|
43
47
|
defaultMode: "floating",
|
|
44
48
|
}],
|
|
@@ -160,15 +164,18 @@ function createHeaderDataProvider(): DataProvider {
|
|
|
160
164
|
|
|
161
165
|
function HeaderHarness({
|
|
162
166
|
updateAvailable = null,
|
|
167
|
+
updateProgress = null,
|
|
163
168
|
updateCheckInProgress = false,
|
|
164
169
|
updateNotice = null,
|
|
165
170
|
}: {
|
|
166
171
|
updateAvailable?: ReturnType<typeof createInitialState>["updateAvailable"];
|
|
172
|
+
updateProgress?: ReturnType<typeof createInitialState>["updateProgress"];
|
|
167
173
|
updateCheckInProgress?: boolean;
|
|
168
174
|
updateNotice?: string | null;
|
|
169
175
|
}) {
|
|
170
176
|
const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-header-test"));
|
|
171
177
|
initialState.updateAvailable = updateAvailable;
|
|
178
|
+
initialState.updateProgress = updateProgress;
|
|
172
179
|
initialState.updateCheckInProgress = updateCheckInProgress;
|
|
173
180
|
initialState.updateNotice = updateNotice;
|
|
174
181
|
const [state, dispatch] = useReducer(appReducer, initialState);
|
|
@@ -258,7 +265,7 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
|
|
|
258
265
|
}
|
|
259
266
|
|
|
260
267
|
describe("Header", () => {
|
|
261
|
-
test("shows
|
|
268
|
+
test("shows automatic self-update status for standalone binaries", async () => {
|
|
262
269
|
testSetup = await testRender(
|
|
263
270
|
<HeaderHarness updateAvailable={{
|
|
264
271
|
version: "0.3.0",
|
|
@@ -274,7 +281,8 @@ describe("Header", () => {
|
|
|
274
281
|
const frame = testSetup.captureCharFrame();
|
|
275
282
|
|
|
276
283
|
expect(frame).toContain("v0.3.0 available");
|
|
277
|
-
expect(frame).toContain("
|
|
284
|
+
expect(frame).toContain("starting download");
|
|
285
|
+
expect(frame).not.toContain("press u to update");
|
|
278
286
|
});
|
|
279
287
|
|
|
280
288
|
test("shows the manual npm command when self-update is disabled", async () => {
|
|
@@ -297,7 +305,26 @@ describe("Header", () => {
|
|
|
297
305
|
expect(frame).not.toContain("press u to update");
|
|
298
306
|
});
|
|
299
307
|
|
|
300
|
-
test("shows update
|
|
308
|
+
test("shows update download progress and notices", async () => {
|
|
309
|
+
testSetup = await testRender(
|
|
310
|
+
<HeaderHarness
|
|
311
|
+
updateAvailable={{
|
|
312
|
+
version: "0.3.0",
|
|
313
|
+
tagName: "v0.3.0",
|
|
314
|
+
downloadUrl: "https://example.com/gloomberb",
|
|
315
|
+
publishedAt: "2026-04-01T00:00:00.000Z",
|
|
316
|
+
updateAction: { kind: "self" },
|
|
317
|
+
}}
|
|
318
|
+
updateProgress={{ phase: "downloading", percent: 42 }}
|
|
319
|
+
/>,
|
|
320
|
+
{ width: 120, height: 2 },
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await testSetup.renderOnce();
|
|
324
|
+
expect(testSetup.captureCharFrame()).toContain("Downloading v0.3.0: 42%");
|
|
325
|
+
|
|
326
|
+
testSetup.renderer.destroy();
|
|
327
|
+
|
|
301
328
|
testSetup = await testRender(
|
|
302
329
|
<HeaderHarness updateCheckInProgress />,
|
|
303
330
|
{ width: 120, height: 2 },
|
|
@@ -648,4 +675,49 @@ describe("Shell", () => {
|
|
|
648
675
|
expect(updateLayout?.layout.floating).toEqual([]);
|
|
649
676
|
expect(updateLayout?.layout.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
|
|
650
677
|
});
|
|
678
|
+
|
|
679
|
+
test("keeps the last floating pane body row visible above the border", async () => {
|
|
680
|
+
const config = createDefaultConfig("/tmp/gloomberb-shell-footer-test");
|
|
681
|
+
const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
|
|
682
|
+
if (!detailPane) throw new Error("missing detail pane");
|
|
683
|
+
|
|
684
|
+
const floatingOnlyLayout = {
|
|
685
|
+
dockRoot: null,
|
|
686
|
+
instances: [{ ...detailPane }],
|
|
687
|
+
floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
|
|
688
|
+
};
|
|
689
|
+
const state = {
|
|
690
|
+
...createInitialState({
|
|
691
|
+
...config,
|
|
692
|
+
layout: cloneLayout(floatingOnlyLayout),
|
|
693
|
+
layouts: [{ name: "Default", layout: cloneLayout(floatingOnlyLayout) }],
|
|
694
|
+
}),
|
|
695
|
+
focusedPaneId: "ticker-detail:main",
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
testSetup = await testRender(
|
|
699
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
700
|
+
<DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
|
|
701
|
+
<Shell pluginRegistry={createShellPluginRegistry({
|
|
702
|
+
tickerDetailComponent: ({ width, height }) => (
|
|
703
|
+
<box flexDirection="column" width={width} height={height}>
|
|
704
|
+
<box flexGrow={1} />
|
|
705
|
+
<box paddingLeft={1}>
|
|
706
|
+
<text>Footer Probe</text>
|
|
707
|
+
</box>
|
|
708
|
+
</box>
|
|
709
|
+
),
|
|
710
|
+
})} />
|
|
711
|
+
</DialogProvider>
|
|
712
|
+
</AppContext>,
|
|
713
|
+
{ width: 40, height: 12 },
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
await act(async () => {
|
|
717
|
+
await testSetup!.renderOnce();
|
|
718
|
+
await testSetup!.renderOnce();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
expect(testSetup.captureCharFrame()).toContain("Footer Probe");
|
|
722
|
+
});
|
|
651
723
|
});
|
|
@@ -38,6 +38,7 @@ import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
|
|
|
38
38
|
import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
|
|
39
39
|
import { FloatingPaneWrapper } from "./floating-pane";
|
|
40
40
|
import { PaneWrapper } from "./pane";
|
|
41
|
+
import { getPaneBodyHeight } from "./pane-sizing";
|
|
41
42
|
|
|
42
43
|
interface ShellProps {
|
|
43
44
|
pluginRegistry: PluginRegistry;
|
|
@@ -984,6 +985,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
984
985
|
if (!pane) return null;
|
|
985
986
|
const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
|
|
986
987
|
const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
|
|
988
|
+
const bodyHeight = getPaneBodyHeight(leaf.rect.height);
|
|
987
989
|
return (
|
|
988
990
|
<box
|
|
989
991
|
key={`dock:${leaf.instanceId}`}
|
|
@@ -1007,7 +1009,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1007
1009
|
paneType={pane.instance.paneId}
|
|
1008
1010
|
focused={focused}
|
|
1009
1011
|
width={leaf.rect.width}
|
|
1010
|
-
height={
|
|
1012
|
+
height={bodyHeight}
|
|
1011
1013
|
/>
|
|
1012
1014
|
</PaneInstanceProvider>
|
|
1013
1015
|
</PaneWrapper>
|
|
@@ -1019,6 +1021,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1019
1021
|
const preview = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
|
|
1020
1022
|
const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
|
|
1021
1023
|
const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
|
|
1024
|
+
const bodyHeight = getPaneBodyHeight(preview.height);
|
|
1022
1025
|
return (
|
|
1023
1026
|
<FloatingPaneWrapper
|
|
1024
1027
|
key={`float:${pane.instance.instanceId}`}
|
|
@@ -1038,7 +1041,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1038
1041
|
paneType={pane.instance.paneId}
|
|
1039
1042
|
focused={focused}
|
|
1040
1043
|
width={preview.width}
|
|
1041
|
-
height={
|
|
1044
|
+
height={bodyHeight}
|
|
1042
1045
|
close={() => handleFloatingClose(pane.instance.instanceId)}
|
|
1043
1046
|
/>
|
|
1044
1047
|
</PaneInstanceProvider>
|
|
@@ -86,7 +86,13 @@ const TickerListHeader = memo(function TickerListHeader({
|
|
|
86
86
|
scrollX
|
|
87
87
|
focusable={false}
|
|
88
88
|
>
|
|
89
|
-
<box
|
|
89
|
+
<box
|
|
90
|
+
flexDirection="row"
|
|
91
|
+
height={1}
|
|
92
|
+
width="100%"
|
|
93
|
+
paddingX={1}
|
|
94
|
+
backgroundColor={colors.panel}
|
|
95
|
+
>
|
|
90
96
|
{columns.map((column) => {
|
|
91
97
|
const isSorted = sortColumnId === column.id;
|
|
92
98
|
const indicator = isSorted ? (sortDirection === "asc" ? " \u25B2" : " \u25BC") : "";
|
|
@@ -95,6 +101,7 @@ const TickerListHeader = memo(function TickerListHeader({
|
|
|
95
101
|
<box
|
|
96
102
|
key={column.id}
|
|
97
103
|
width={column.width + 1}
|
|
104
|
+
backgroundColor={colors.panel}
|
|
98
105
|
onMouseDown={onHeaderClick ? (event) => {
|
|
99
106
|
event.preventDefault();
|
|
100
107
|
onHeaderClick(column.id);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useMemo, useState, type RefObject } from "react";
|
|
1
|
+
import { useCallback, useMemo, useState, type RefObject } from "react";
|
|
2
2
|
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
3
3
|
import { colors, hoverBg } from "../../theme/colors";
|
|
4
4
|
import type { ColumnConfig } from "../../types/config";
|
|
5
|
+
import { useAppDispatch, usePaneInstance } from "../../state/app-context";
|
|
5
6
|
import { padTo } from "../../utils/format";
|
|
6
7
|
import { useDoubleClickActivation } from "../use-double-click-activation";
|
|
7
8
|
import { EmptyState } from "./status";
|
|
@@ -75,6 +76,8 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
75
76
|
virtualize = false,
|
|
76
77
|
overscan = 3,
|
|
77
78
|
}: DataTableProps<T, C>) {
|
|
79
|
+
const dispatch = useAppDispatch();
|
|
80
|
+
const paneInstanceId = usePaneInstance()?.instanceId ?? null;
|
|
78
81
|
const [scrollVersion, setScrollVersion] = useState(0);
|
|
79
82
|
const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
|
|
80
83
|
const viewportHeight = virtualize
|
|
@@ -106,11 +109,21 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
106
109
|
}
|
|
107
110
|
onBodyScrollActivity();
|
|
108
111
|
};
|
|
112
|
+
const focusPane = useCallback(() => {
|
|
113
|
+
if (!paneInstanceId) return;
|
|
114
|
+
dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
|
|
115
|
+
}, [dispatch, paneInstanceId]);
|
|
109
116
|
|
|
110
117
|
return (
|
|
111
118
|
<>
|
|
112
119
|
<scrollbox ref={headerScrollRef} height={1} scrollX focusable={false}>
|
|
113
|
-
<box
|
|
120
|
+
<box
|
|
121
|
+
flexDirection="row"
|
|
122
|
+
height={1}
|
|
123
|
+
width="100%"
|
|
124
|
+
paddingX={1}
|
|
125
|
+
backgroundColor={colors.panel}
|
|
126
|
+
>
|
|
114
127
|
{columns.map((column) => {
|
|
115
128
|
const isSorted = sortColumnId === column.id;
|
|
116
129
|
const indicator = isSorted
|
|
@@ -127,7 +140,9 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
127
140
|
<box
|
|
128
141
|
key={column.id}
|
|
129
142
|
width={column.width + 1}
|
|
143
|
+
backgroundColor={colors.panel}
|
|
130
144
|
onMouseDown={(event) => {
|
|
145
|
+
focusPane();
|
|
131
146
|
event.preventDefault();
|
|
132
147
|
onHeaderClick(column.id);
|
|
133
148
|
}}
|
|
@@ -150,7 +165,10 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
150
165
|
scrollX
|
|
151
166
|
scrollY
|
|
152
167
|
focusable={false}
|
|
153
|
-
onMouseDown={() =>
|
|
168
|
+
onMouseDown={() => {
|
|
169
|
+
focusPane();
|
|
170
|
+
queueMicrotask(syncHeaderScroll);
|
|
171
|
+
}}
|
|
154
172
|
onMouseUp={() => queueMicrotask(handleBodyScrollActivity)}
|
|
155
173
|
onMouseDrag={() => queueMicrotask(handleBodyScrollActivity)}
|
|
156
174
|
onMouseScroll={() => queueMicrotask(handleBodyScrollActivity)}
|
|
@@ -181,6 +199,7 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
181
199
|
backgroundColor={rowBg}
|
|
182
200
|
onMouseMove={() => setHoveredIdx(index)}
|
|
183
201
|
onMouseDown={(event) => {
|
|
202
|
+
focusPane();
|
|
184
203
|
event.preventDefault();
|
|
185
204
|
handleRowMouseDown(getItemKey(item, index), {
|
|
186
205
|
item,
|
|
@@ -198,6 +217,7 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
198
217
|
key={column.id}
|
|
199
218
|
width={column.width + 1}
|
|
200
219
|
onMouseDown={(event) => {
|
|
220
|
+
focusPane();
|
|
201
221
|
if (cell.onMouseDown) {
|
|
202
222
|
cell.onMouseDown(event);
|
|
203
223
|
return;
|
|
@@ -84,6 +84,7 @@ class MemoryPersistence implements PluginPersistence {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
afterEach(() => {
|
|
87
|
+
apiClient.dispose();
|
|
87
88
|
apiClient.setSessionToken(null);
|
|
88
89
|
apiClient.connectChannel = originalConnectChannel;
|
|
89
90
|
apiClient.getSession = originalGetSession;
|
|
@@ -203,6 +204,35 @@ describe("ChatController", () => {
|
|
|
203
204
|
controller.reset(true);
|
|
204
205
|
});
|
|
205
206
|
|
|
207
|
+
test("dispose stops verification polling and closes the live connection", () => {
|
|
208
|
+
const controller = new ChatController();
|
|
209
|
+
let closed = false;
|
|
210
|
+
|
|
211
|
+
apiClient.setSessionToken("token-123");
|
|
212
|
+
(controller as any).user = { id: "u1", username: "vince", emailVerified: false };
|
|
213
|
+
(controller as any).syncVerificationPolling();
|
|
214
|
+
expect((controller as any).verificationPollTimer).not.toBeNull();
|
|
215
|
+
|
|
216
|
+
(controller as any).user = { id: "u1", username: "vince", emailVerified: true };
|
|
217
|
+
apiClient.connectChannel = () => ({
|
|
218
|
+
send: async () => {
|
|
219
|
+
throw new Error("not implemented");
|
|
220
|
+
},
|
|
221
|
+
close: () => {
|
|
222
|
+
closed = true;
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
controller.ensureConnection();
|
|
227
|
+
expect((controller as any).wsConnection).not.toBeNull();
|
|
228
|
+
|
|
229
|
+
controller.dispose();
|
|
230
|
+
|
|
231
|
+
expect(closed).toBe(true);
|
|
232
|
+
expect((controller as any).verificationPollTimer).toBeNull();
|
|
233
|
+
expect((controller as any).wsConnection).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
206
236
|
test("keeps the cached session when session refresh fails transiently", async () => {
|
|
207
237
|
const persistence = new MemoryPersistence();
|
|
208
238
|
const controller = new ChatController();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PluginPersistence, PluginResumeState } from "../../types/plugin";
|
|
2
2
|
import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
|
|
3
|
+
import { debugLog } from "../../utils/debug-log";
|
|
3
4
|
import { toTimestampMillis } from "../../utils/timestamp";
|
|
4
5
|
|
|
5
6
|
const SESSION_STATE_KEY = "session";
|
|
@@ -18,6 +19,7 @@ const VERIFICATION_POLL_MS = 5_000;
|
|
|
18
19
|
const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
19
20
|
const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
|
|
20
21
|
const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
|
|
22
|
+
const chatLog = debugLog.createLogger("chat-controller");
|
|
21
23
|
|
|
22
24
|
interface PersistedSessionState {
|
|
23
25
|
sessionToken: string | null;
|
|
@@ -326,6 +328,22 @@ export class ChatController {
|
|
|
326
328
|
this.emit();
|
|
327
329
|
}
|
|
328
330
|
|
|
331
|
+
dispose(): void {
|
|
332
|
+
chatLog.info("dispose controller", {
|
|
333
|
+
listeners: this.listeners.size,
|
|
334
|
+
hasConnection: !!this.wsConnection,
|
|
335
|
+
});
|
|
336
|
+
this.stopVerificationPolling();
|
|
337
|
+
this.wsConnection?.close();
|
|
338
|
+
this.wsConnection = null;
|
|
339
|
+
this.wsConnected = false;
|
|
340
|
+
this.messagesLoading = false;
|
|
341
|
+
this.refreshMessagesPromise = null;
|
|
342
|
+
this.openViewCount = 0;
|
|
343
|
+
this.toastFn = () => {};
|
|
344
|
+
this.listeners.clear();
|
|
345
|
+
}
|
|
346
|
+
|
|
329
347
|
send(content: string, replyToId?: string): void {
|
|
330
348
|
if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
|
|
331
349
|
if (!this.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
|
|
@@ -674,6 +674,11 @@ export const gloomberbCloudPlugin: GloomPlugin = {
|
|
|
674
674
|
hidden: () => !apiClient.getSessionToken(),
|
|
675
675
|
});
|
|
676
676
|
},
|
|
677
|
+
|
|
678
|
+
dispose() {
|
|
679
|
+
chatController.dispose();
|
|
680
|
+
apiClient.dispose();
|
|
681
|
+
},
|
|
677
682
|
};
|
|
678
683
|
|
|
679
684
|
export const chatPlugin = gloomberbCloudPlugin;
|
|
@@ -25,6 +25,11 @@ export function setLayoutManagerDispatch(
|
|
|
25
25
|
getStateRef = getState;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export function clearLayoutManagerDispatch() {
|
|
29
|
+
dispatchRef = null;
|
|
30
|
+
getStateRef = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
function persistLayout(layout: LayoutConfig) {
|
|
29
34
|
if (!dispatchRef) return;
|
|
30
35
|
dispatchRef({ type: "PUSH_LAYOUT_HISTORY" });
|
|
@@ -261,4 +266,8 @@ export const layoutManagerPlugin: GloomPlugin = {
|
|
|
261
266
|
},
|
|
262
267
|
});
|
|
263
268
|
},
|
|
269
|
+
|
|
270
|
+
dispose() {
|
|
271
|
+
clearLayoutManagerDispatch();
|
|
272
|
+
},
|
|
264
273
|
};
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
applyTickByTickAllLastToQuote,
|
|
6
6
|
applyTickByTickBidAskToQuote,
|
|
7
7
|
diagnoseLocalIbkrPortIssue,
|
|
8
|
+
IbkrGatewayServiceManager,
|
|
8
9
|
IbkrGatewayService,
|
|
9
10
|
parseIbkrHistoricalBarTime,
|
|
10
11
|
resolveGatewayConnection,
|
|
@@ -521,4 +522,25 @@ describe("IbkrGatewayService", () => {
|
|
|
521
522
|
expect(service.getSnapshot().openOrders).toEqual([]);
|
|
522
523
|
expect(service.getSnapshot().executions).toEqual([]);
|
|
523
524
|
});
|
|
525
|
+
|
|
526
|
+
test("destroyAll disconnects every cached gateway service", async () => {
|
|
527
|
+
const manager = new IbkrGatewayServiceManager();
|
|
528
|
+
let disconnectCount = 0;
|
|
529
|
+
|
|
530
|
+
(manager as any).services.set("one", {
|
|
531
|
+
disconnect: async () => {
|
|
532
|
+
disconnectCount += 1;
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
(manager as any).services.set("two", {
|
|
536
|
+
disconnect: async () => {
|
|
537
|
+
disconnectCount += 1;
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
await manager.destroyAll();
|
|
542
|
+
|
|
543
|
+
expect(disconnectCount).toBe(2);
|
|
544
|
+
expect((manager as any).services.size).toBe(0);
|
|
545
|
+
});
|
|
524
546
|
});
|
|
@@ -2393,6 +2393,12 @@ export class IbkrGatewayServiceManager {
|
|
|
2393
2393
|
await service.disconnect();
|
|
2394
2394
|
this.services.delete(instanceId);
|
|
2395
2395
|
}
|
|
2396
|
+
|
|
2397
|
+
async destroyAll(): Promise<void> {
|
|
2398
|
+
const services = [...this.services.values()];
|
|
2399
|
+
this.services.clear();
|
|
2400
|
+
await Promise.allSettled(services.map((service) => service.disconnect()));
|
|
2401
|
+
}
|
|
2396
2402
|
}
|
|
2397
2403
|
|
|
2398
2404
|
export const ibkrGatewayManager = new IbkrGatewayServiceManager();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GloomPlugin, GloomPluginContext } from "../../types/plugin";
|
|
2
2
|
import { ibkrBroker } from "./broker-adapter";
|
|
3
3
|
import { buildPersistedIbkrGatewayConfig } from "./config";
|
|
4
|
-
import { setResolvedIbkrGatewayListener } from "./gateway-service";
|
|
4
|
+
import { ibkrGatewayManager, setResolvedIbkrGatewayListener } from "./gateway-service";
|
|
5
5
|
import {
|
|
6
6
|
getTradeTicketState,
|
|
7
7
|
getTradingPaneState,
|
|
@@ -150,4 +150,10 @@ export const ibkrPlugin: GloomPlugin = {
|
|
|
150
150
|
},
|
|
151
151
|
});
|
|
152
152
|
},
|
|
153
|
+
|
|
154
|
+
dispose() {
|
|
155
|
+
lastSelectedTickerSymbol = null;
|
|
156
|
+
setResolvedIbkrGatewayListener(null);
|
|
157
|
+
void ibkrGatewayManager.destroyAll().catch(() => {});
|
|
158
|
+
},
|
|
153
159
|
};
|
|
@@ -26,6 +26,7 @@ import { subscribePolymarketMarket } from "./services/polymarket-ws";
|
|
|
26
26
|
import type {
|
|
27
27
|
PredictionBrowseTab,
|
|
28
28
|
PredictionCategoryId,
|
|
29
|
+
PredictionVenue,
|
|
29
30
|
PredictionListRow,
|
|
30
31
|
PredictionMarketDetail,
|
|
31
32
|
PredictionMarketSummary,
|
|
@@ -36,6 +37,48 @@ import type {
|
|
|
36
37
|
|
|
37
38
|
const KEYBOARD_DETAIL_LOAD_DELAY_MS = 140;
|
|
38
39
|
|
|
40
|
+
function formatPredictionVenueLabel(venue: PredictionVenue): string {
|
|
41
|
+
return venue === "polymarket" ? "Polymarket" : "Kalshi";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function joinPredictionVenueLabels(venues: PredictionVenue[]): string {
|
|
45
|
+
const labels = [...new Set(venues.map(formatPredictionVenueLabel))];
|
|
46
|
+
if (labels.length <= 1) return labels[0] ?? "";
|
|
47
|
+
return `${labels.slice(0, -1).join(" and ")} and ${labels.at(-1)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatPredictionLoadError(
|
|
51
|
+
venue: PredictionVenue,
|
|
52
|
+
subject: "markets" | "market detail",
|
|
53
|
+
error: unknown,
|
|
54
|
+
): string {
|
|
55
|
+
const venueLabel = formatPredictionVenueLabel(venue);
|
|
56
|
+
const fallback = `Could not load ${venueLabel} ${subject}.`;
|
|
57
|
+
if (!(error instanceof Error)) {
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const message = error.message.trim();
|
|
62
|
+
if (message.length === 0) {
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const requestFailureMatch = message.match(/Request failed \((\d+)\)/i);
|
|
67
|
+
if (requestFailureMatch) {
|
|
68
|
+
return `${venueLabel} ${subject} request failed (${requestFailureMatch[1]}).`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (
|
|
72
|
+
/typo in the url or port|access the url|unable to connect|could not resolve host|connection refused/i.test(
|
|
73
|
+
message,
|
|
74
|
+
)
|
|
75
|
+
) {
|
|
76
|
+
return `${venueLabel} is unavailable right now.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return `${fallback} ${message}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
39
82
|
export function usePredictionMarketsDataState({
|
|
40
83
|
browseTab,
|
|
41
84
|
categoryId,
|
|
@@ -152,14 +195,84 @@ export function usePredictionMarketsDataState({
|
|
|
152
195
|
].filter((value): value is string => value != null),
|
|
153
196
|
[includeKalshi, includePolymarket, kalshiCatalogKey, polymarketCatalogKey],
|
|
154
197
|
);
|
|
198
|
+
const activeCatalogSources = useMemo(
|
|
199
|
+
() =>
|
|
200
|
+
[
|
|
201
|
+
includePolymarket
|
|
202
|
+
? {
|
|
203
|
+
venue: "polymarket" as const,
|
|
204
|
+
cacheKey: polymarketCatalogKey,
|
|
205
|
+
error: catalogErrors[polymarketCatalogKey] ?? null,
|
|
206
|
+
markets: polymarketCatalog,
|
|
207
|
+
}
|
|
208
|
+
: null,
|
|
209
|
+
includeKalshi
|
|
210
|
+
? {
|
|
211
|
+
venue: "kalshi" as const,
|
|
212
|
+
cacheKey: kalshiCatalogKey,
|
|
213
|
+
error: catalogErrors[kalshiCatalogKey] ?? null,
|
|
214
|
+
markets: kalshiCatalog,
|
|
215
|
+
}
|
|
216
|
+
: null,
|
|
217
|
+
].filter(
|
|
218
|
+
(
|
|
219
|
+
value,
|
|
220
|
+
): value is {
|
|
221
|
+
venue: PredictionVenue;
|
|
222
|
+
cacheKey: string;
|
|
223
|
+
error: string | null;
|
|
224
|
+
markets: PredictionMarketSummary[];
|
|
225
|
+
} => value != null,
|
|
226
|
+
),
|
|
227
|
+
[
|
|
228
|
+
catalogErrors,
|
|
229
|
+
includeKalshi,
|
|
230
|
+
includePolymarket,
|
|
231
|
+
kalshiCatalog,
|
|
232
|
+
kalshiCatalogKey,
|
|
233
|
+
polymarketCatalog,
|
|
234
|
+
polymarketCatalogKey,
|
|
235
|
+
],
|
|
236
|
+
);
|
|
155
237
|
const catalogLoadCount = activeCatalogKeys.reduce(
|
|
156
238
|
(count, cacheKey) => count + (catalogPending[cacheKey] ?? 0),
|
|
157
239
|
0,
|
|
158
240
|
);
|
|
159
|
-
const
|
|
160
|
-
.
|
|
161
|
-
.
|
|
162
|
-
|
|
241
|
+
const catalogStatus = useMemo(() => {
|
|
242
|
+
const failingSources = activeCatalogSources.filter((source) => !!source.error);
|
|
243
|
+
if (failingSources.length === 0) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const loadedSources = activeCatalogSources.filter(
|
|
248
|
+
(source) => !source.error && source.markets.length > 0,
|
|
249
|
+
);
|
|
250
|
+
if (failingSources.length < activeCatalogSources.length) {
|
|
251
|
+
const unavailableVenues = joinPredictionVenueLabels(
|
|
252
|
+
failingSources.map((source) => source.venue),
|
|
253
|
+
);
|
|
254
|
+
if (loadedSources.length > 0) {
|
|
255
|
+
return {
|
|
256
|
+
tone: "warning" as const,
|
|
257
|
+
message: `${unavailableVenues} unavailable right now; showing ${joinPredictionVenueLabels(
|
|
258
|
+
loadedSources.map((source) => source.venue),
|
|
259
|
+
)} markets.`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
tone: "warning" as const,
|
|
264
|
+
message: `${unavailableVenues} unavailable right now.`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
tone: "danger" as const,
|
|
270
|
+
message: failingSources
|
|
271
|
+
.map((source) => source.error)
|
|
272
|
+
.filter((value): value is string => !!value)
|
|
273
|
+
.join(" "),
|
|
274
|
+
};
|
|
275
|
+
}, [activeCatalogSources]);
|
|
163
276
|
|
|
164
277
|
const allMarkets = useMemo(() => {
|
|
165
278
|
const merged: PredictionMarketSummary[] = [];
|
|
@@ -276,9 +389,7 @@ export function usePredictionMarketsDataState({
|
|
|
276
389
|
updatePredictionErrorState(
|
|
277
390
|
current,
|
|
278
391
|
cacheKey,
|
|
279
|
-
|
|
280
|
-
? error.message
|
|
281
|
-
: "Could not load Polymarket markets.",
|
|
392
|
+
formatPredictionLoadError("polymarket", "markets", error),
|
|
282
393
|
),
|
|
283
394
|
);
|
|
284
395
|
} finally {
|
|
@@ -310,9 +421,7 @@ export function usePredictionMarketsDataState({
|
|
|
310
421
|
updatePredictionErrorState(
|
|
311
422
|
current,
|
|
312
423
|
cacheKey,
|
|
313
|
-
|
|
314
|
-
? error.message
|
|
315
|
-
: "Could not load Kalshi markets.",
|
|
424
|
+
formatPredictionLoadError("kalshi", "markets", error),
|
|
316
425
|
),
|
|
317
426
|
);
|
|
318
427
|
} finally {
|
|
@@ -360,9 +469,7 @@ export function usePredictionMarketsDataState({
|
|
|
360
469
|
updatePredictionErrorState(
|
|
361
470
|
current,
|
|
362
471
|
cacheKey,
|
|
363
|
-
|
|
364
|
-
? error.message
|
|
365
|
-
: "Could not load market detail.",
|
|
472
|
+
formatPredictionLoadError(summary.venue, "market detail", error),
|
|
366
473
|
),
|
|
367
474
|
);
|
|
368
475
|
if (currentDetailCacheKeyRef.current === cacheKey) {
|
|
@@ -605,7 +712,7 @@ export function usePredictionMarketsDataState({
|
|
|
605
712
|
}, []);
|
|
606
713
|
|
|
607
714
|
return {
|
|
608
|
-
|
|
715
|
+
catalogStatus,
|
|
609
716
|
catalogLoadCount,
|
|
610
717
|
debouncedSearchQuery,
|
|
611
718
|
detail,
|
|
@@ -692,7 +692,7 @@ export function usePredictionMarketsController({
|
|
|
692
692
|
paneSettings,
|
|
693
693
|
browseTab,
|
|
694
694
|
categoryId,
|
|
695
|
-
|
|
695
|
+
catalogStatus: data.catalogStatus,
|
|
696
696
|
catalogLoadCount: data.catalogLoadCount,
|
|
697
697
|
detail: data.detail,
|
|
698
698
|
detailError: data.detailError,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GloomPlugin, GloomPluginContext } from "../../types/plugin";
|
|
2
2
|
import { parsePredictionSearchShortcut } from "./navigation";
|
|
3
3
|
import { PredictionMarketsPane } from "./pane";
|
|
4
|
-
import { attachPredictionMarketsPersistence } from "./services/fetch";
|
|
4
|
+
import { attachPredictionMarketsPersistence, resetPredictionMarketsPersistence } from "./services/fetch";
|
|
5
5
|
import { predictionMarketsCliCommand } from "./cli";
|
|
6
6
|
import {
|
|
7
7
|
buildPredictionMarketsPaneSettingsDef,
|
|
@@ -101,4 +101,8 @@ export const predictionMarketsPlugin: GloomPlugin = {
|
|
|
101
101
|
},
|
|
102
102
|
});
|
|
103
103
|
},
|
|
104
|
+
|
|
105
|
+
dispose() {
|
|
106
|
+
resetPredictionMarketsPersistence();
|
|
107
|
+
},
|
|
104
108
|
};
|
|
@@ -83,6 +83,57 @@ describe("prediction markets pane interactions", () => {
|
|
|
83
83
|
expect(frame).toContain("Will the Fed cut rates?");
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
test("shows a venue warning instead of a raw fetch error when one catalog fails", async () => {
|
|
87
|
+
attachPredictionMarketsPersistence(new MemoryPersistence());
|
|
88
|
+
|
|
89
|
+
globalThis.fetch = (async (input: Request | string | URL) => {
|
|
90
|
+
const url = String(input);
|
|
91
|
+
if (url.includes("gamma-api.polymarket.com")) {
|
|
92
|
+
throw new Error("Unable to connect. Was there a typo in the url or port?");
|
|
93
|
+
}
|
|
94
|
+
if (url.includes("/trade-api/v2/events?")) {
|
|
95
|
+
return new Response(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
events: [
|
|
98
|
+
{
|
|
99
|
+
title: "Fed series",
|
|
100
|
+
category: "Economics",
|
|
101
|
+
event_ticker: "FED-1",
|
|
102
|
+
series_ticker: "FED",
|
|
103
|
+
markets: [
|
|
104
|
+
{
|
|
105
|
+
ticker: "KAL-1",
|
|
106
|
+
title: "Will the Fed cut rates?",
|
|
107
|
+
yes_sub_title: "Yes",
|
|
108
|
+
event_ticker: "FED-1",
|
|
109
|
+
status: "open",
|
|
110
|
+
market_type: "binary",
|
|
111
|
+
last_price_dollars: "0.48",
|
|
112
|
+
volume_24h_fp: "15000",
|
|
113
|
+
volume_fp: "90000",
|
|
114
|
+
open_interest_fp: "45000",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
{ status: 200 },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return new Response(JSON.stringify({}), { status: 200 });
|
|
124
|
+
}) as unknown as typeof fetch;
|
|
125
|
+
|
|
126
|
+
testSetup = await testRender(<Harness />, { width: 120, height: 34 });
|
|
127
|
+
await flushFrames(testSetup);
|
|
128
|
+
|
|
129
|
+
const frame = testSetup.captureCharFrame();
|
|
130
|
+
expect(frame).toContain("Will the Fed cut rates?");
|
|
131
|
+
expect(frame).toContain(
|
|
132
|
+
"Polymarket unavailable right now; showing Kalshi markets.",
|
|
133
|
+
);
|
|
134
|
+
expect(frame).not.toContain("Was there a typo in the url or port?");
|
|
135
|
+
});
|
|
136
|
+
|
|
86
137
|
test("selects a market on single click and opens detail on double click", async () => {
|
|
87
138
|
installPredictionMarketMocks();
|
|
88
139
|
|
|
@@ -127,6 +178,33 @@ describe("prediction markets pane interactions", () => {
|
|
|
127
178
|
expect(frame).toContain("Kalshi primary rule");
|
|
128
179
|
});
|
|
129
180
|
|
|
181
|
+
test("focuses the pane when a market row is clicked", async () => {
|
|
182
|
+
installPredictionMarketMocks();
|
|
183
|
+
|
|
184
|
+
testSetup = await testRender(
|
|
185
|
+
<Harness initialFocusedPaneId="portfolio-list:main" />,
|
|
186
|
+
{ width: 120, height: 34 },
|
|
187
|
+
);
|
|
188
|
+
await flushFrames(testSetup);
|
|
189
|
+
|
|
190
|
+
expect(harnessStateRef.current?.focusedPaneId).toBe("portfolio-list:main");
|
|
191
|
+
|
|
192
|
+
const frame = testSetup.captureCharFrame();
|
|
193
|
+
const lines = frame.split("\n");
|
|
194
|
+
const kalshiRow = lines.findIndex((line) =>
|
|
195
|
+
line.includes("Will the Fed cut rates?"),
|
|
196
|
+
);
|
|
197
|
+
const kalshiCol = lines[kalshiRow]?.indexOf("Will the Fed cut rates?") ?? -1;
|
|
198
|
+
|
|
199
|
+
await act(async () => {
|
|
200
|
+
await testSetup!.mockMouse.click(kalshiCol + 1, kalshiRow);
|
|
201
|
+
await testSetup!.renderOnce();
|
|
202
|
+
});
|
|
203
|
+
await flushFrames(testSetup);
|
|
204
|
+
|
|
205
|
+
expect(harnessStateRef.current?.focusedPaneId).toBe(TEST_PANE_ID);
|
|
206
|
+
});
|
|
207
|
+
|
|
130
208
|
test("loads selected detail once instead of refetching in a render loop", async () => {
|
|
131
209
|
const { fetchUrls } = installPredictionMarketMocks();
|
|
132
210
|
|
|
@@ -11,6 +11,10 @@ import type { PredictionBrowseTab, PredictionCategoryId } from "./types";
|
|
|
11
11
|
|
|
12
12
|
export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
|
|
13
13
|
const controller = usePredictionMarketsController({ focused });
|
|
14
|
+
const catalogStatusColor =
|
|
15
|
+
controller.catalogStatus?.tone === "danger"
|
|
16
|
+
? colors.negative
|
|
17
|
+
: colors.borderFocused;
|
|
14
18
|
|
|
15
19
|
const browseContent = (
|
|
16
20
|
<box
|
|
@@ -113,9 +117,9 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
|
|
|
113
117
|
</scrollbox>
|
|
114
118
|
) : null}
|
|
115
119
|
|
|
116
|
-
{controller.
|
|
117
|
-
<box height={1} paddingX={1}>
|
|
118
|
-
<text fg={
|
|
120
|
+
{controller.catalogStatus ? (
|
|
121
|
+
<box height={1} paddingX={1} width={width} backgroundColor={colors.panel}>
|
|
122
|
+
<text fg={catalogStatusColor}>{controller.catalogStatus.message}</text>
|
|
119
123
|
</box>
|
|
120
124
|
) : null}
|
|
121
125
|
|
|
@@ -19,6 +19,10 @@ export function attachPredictionMarketsPersistence(
|
|
|
19
19
|
predictionMarketsPersistence = persistence;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function resetPredictionMarketsPersistence(): void {
|
|
23
|
+
predictionMarketsPersistence = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
22
26
|
export function getPredictionMarketsPersistence(): PluginPersistence | null {
|
|
23
27
|
return predictionMarketsPersistence;
|
|
24
28
|
}
|
|
@@ -99,11 +99,22 @@ export class MemoryPersistence implements PluginPersistence {
|
|
|
99
99
|
|
|
100
100
|
attachPredictionMarketsPersistence(new MemoryPersistence());
|
|
101
101
|
|
|
102
|
-
export function createConfig(
|
|
102
|
+
export function createConfig(options?: {
|
|
103
|
+
initialFocusedPaneId?: string;
|
|
104
|
+
}): AppConfig {
|
|
103
105
|
const config = createDefaultConfig("/tmp/gloomberb-prediction-markets");
|
|
106
|
+
const initialFocusedPaneId = options?.initialFocusedPaneId ?? TEST_PANE_ID;
|
|
107
|
+
const extraInstances = initialFocusedPaneId !== TEST_PANE_ID
|
|
108
|
+
? [{
|
|
109
|
+
instanceId: initialFocusedPaneId,
|
|
110
|
+
paneId: initialFocusedPaneId.split(":")[0] ?? "portfolio-list",
|
|
111
|
+
binding: { kind: "none" as const },
|
|
112
|
+
}]
|
|
113
|
+
: [];
|
|
104
114
|
const layout = {
|
|
105
115
|
dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
|
|
106
116
|
instances: [
|
|
117
|
+
...extraInstances,
|
|
107
118
|
{
|
|
108
119
|
instanceId: TEST_PANE_ID,
|
|
109
120
|
paneId: "prediction-markets",
|
|
@@ -424,13 +435,17 @@ export function installPredictionMarketMocks() {
|
|
|
424
435
|
return { fetchUrls };
|
|
425
436
|
}
|
|
426
437
|
|
|
427
|
-
export function Harness(
|
|
438
|
+
export function Harness({
|
|
439
|
+
initialFocusedPaneId = TEST_PANE_ID,
|
|
440
|
+
}: {
|
|
441
|
+
initialFocusedPaneId?: string;
|
|
442
|
+
} = {}) {
|
|
428
443
|
const runtime = useMemo(() => createRuntime(), []);
|
|
429
444
|
const [state, dispatch] = useReducer(
|
|
430
445
|
appReducer,
|
|
431
446
|
(() => {
|
|
432
|
-
const initial = createInitialState(createConfig());
|
|
433
|
-
initial.focusedPaneId =
|
|
447
|
+
const initial = createInitialState(createConfig({ initialFocusedPaneId }));
|
|
448
|
+
initial.focusedPaneId = initialFocusedPaneId;
|
|
434
449
|
return initial;
|
|
435
450
|
})(),
|
|
436
451
|
);
|
|
@@ -442,7 +457,7 @@ export function Harness() {
|
|
|
442
457
|
<PredictionMarketsPane
|
|
443
458
|
paneId={TEST_PANE_ID}
|
|
444
459
|
paneType="prediction-markets"
|
|
445
|
-
focused
|
|
460
|
+
focused={state.focusedPaneId === TEST_PANE_ID}
|
|
446
461
|
width={120}
|
|
447
462
|
height={34}
|
|
448
463
|
/>
|
package/src/plugins/registry.ts
CHANGED
|
@@ -630,4 +630,27 @@ export class PluginRegistry implements PluginRuntimeAccess {
|
|
|
630
630
|
this.plugins.delete(pluginId);
|
|
631
631
|
this.events.emit("plugin:unregistered", { pluginId });
|
|
632
632
|
}
|
|
633
|
+
|
|
634
|
+
destroy(): void {
|
|
635
|
+
for (const pluginId of [...this.plugins.keys()].reverse()) {
|
|
636
|
+
try {
|
|
637
|
+
this.unregister(pluginId);
|
|
638
|
+
} catch (error) {
|
|
639
|
+
this.registryLog.error("Failed to unregister plugin during registry destroy", {
|
|
640
|
+
pluginId,
|
|
641
|
+
error: error instanceof Error ? error.message : String(error),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
this.pluginResumeListeners.clear();
|
|
646
|
+
if (sharedRegistry === this) {
|
|
647
|
+
sharedRegistry = undefined;
|
|
648
|
+
}
|
|
649
|
+
if (sharedDataProvider === this.dataProvider) {
|
|
650
|
+
sharedDataProvider = undefined;
|
|
651
|
+
}
|
|
652
|
+
if ((globalThis as any).__gloomRegistry === this) {
|
|
653
|
+
delete (globalThis as any).__gloomRegistry;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
633
656
|
}
|
package/src/updater.test.ts
CHANGED
|
@@ -272,6 +272,8 @@ describe("performUpdate", () => {
|
|
|
272
272
|
});
|
|
273
273
|
|
|
274
274
|
expect(readFileSync(execPath)).toEqual(nextBinary);
|
|
275
|
+
expect(progress[0]).toEqual({ phase: "downloading", percent: 0 });
|
|
276
|
+
expect(progress).toContainEqual({ phase: "downloading", percent: 100 });
|
|
275
277
|
expect(progress.at(-1)).toEqual({ phase: "done" });
|
|
276
278
|
} finally {
|
|
277
279
|
Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
|
package/src/utils/api-client.ts
CHANGED
|
@@ -647,6 +647,18 @@ class GloomApiClient {
|
|
|
647
647
|
};
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
+
dispose(): void {
|
|
651
|
+
cloudApiLog.info("dispose api client", {
|
|
652
|
+
quoteTargets: this.quoteTargets.size,
|
|
653
|
+
channelTargets: this.channelListeners.size,
|
|
654
|
+
});
|
|
655
|
+
this.channelListeners.clear();
|
|
656
|
+
this.quoteListeners.clear();
|
|
657
|
+
this.quoteTargets.clear();
|
|
658
|
+
this.reconnectDelayMs = 1000;
|
|
659
|
+
this.teardownSocket();
|
|
660
|
+
}
|
|
661
|
+
|
|
650
662
|
async searchInstruments(query: string, limit = 10): Promise<InstrumentSearchResult[]> {
|
|
651
663
|
const params = new URLSearchParams({
|
|
652
664
|
q: query,
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.4.
|
|
1
|
+
export const VERSION = "0.4.1";
|