gloomberb 0.7.2 → 0.7.3
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 +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +49 -15
- package/src/components/chart/chart-types.ts +2 -2
- package/src/components/chart/chart.test.ts +27 -8
- package/src/components/chart/comparison-stock-chart.tsx +84 -121
- package/src/components/chart/native/chart-rasterizer.ts +43 -17
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
- package/src/components/chart/stock-chart.tsx +59 -41
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +528 -383
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +1 -0
- package/src/components/command-bar/view-model.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +11 -51
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator.ts +8 -1
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +258 -44
- package/src/plugins/builtin/chat.test.tsx +257 -6
- package/src/plugins/builtin/chat.tsx +225 -60
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/news-wire/index.tsx +1 -1
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +252 -41
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/state/app-bootstrap.ts +3 -3
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +128 -0
- package/src/utils/api-client.ts +112 -7
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
|
@@ -329,6 +329,64 @@ describe("pane state updates", () => {
|
|
|
329
329
|
|
|
330
330
|
expect(next).toBe(initial);
|
|
331
331
|
});
|
|
332
|
+
|
|
333
|
+
test("keeps pane runtime state scoped to each saved layout", () => {
|
|
334
|
+
let state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
|
|
335
|
+
const originalLayoutIndex = state.config.activeLayoutIndex;
|
|
336
|
+
|
|
337
|
+
state = appReducer(state, {
|
|
338
|
+
type: "UPDATE_PANE_STATE",
|
|
339
|
+
paneId: "ticker-detail:main",
|
|
340
|
+
patch: { activeTabId: "chart" },
|
|
341
|
+
});
|
|
342
|
+
state = appReducer(state, { type: "DUPLICATE_LAYOUT", index: originalLayoutIndex });
|
|
343
|
+
const duplicateLayoutIndex = state.config.activeLayoutIndex;
|
|
344
|
+
|
|
345
|
+
state = appReducer(state, {
|
|
346
|
+
type: "UPDATE_PANE_STATE",
|
|
347
|
+
paneId: "ticker-detail:main",
|
|
348
|
+
patch: { activeTabId: "financials" },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(state.config.layouts[duplicateLayoutIndex]?.paneState?.["ticker-detail:main"]?.activeTabId).toBe("financials");
|
|
352
|
+
|
|
353
|
+
state = appReducer(state, { type: "SWITCH_LAYOUT", index: originalLayoutIndex });
|
|
354
|
+
expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("chart");
|
|
355
|
+
expect(state.config.layouts[originalLayoutIndex]?.paneState?.["ticker-detail:main"]?.activeTabId).toBe("chart");
|
|
356
|
+
|
|
357
|
+
state = appReducer(state, { type: "SWITCH_LAYOUT", index: duplicateLayoutIndex });
|
|
358
|
+
expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("financials");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("hydrates saved layout pane state before legacy session pane state", () => {
|
|
362
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
363
|
+
config.layouts[config.activeLayoutIndex] = {
|
|
364
|
+
...config.layouts[config.activeLayoutIndex]!,
|
|
365
|
+
paneState: {
|
|
366
|
+
"ticker-detail:main": { activeTabId: "chart" },
|
|
367
|
+
},
|
|
368
|
+
focusedPaneId: "ticker-detail:main",
|
|
369
|
+
activePanel: "right",
|
|
370
|
+
};
|
|
371
|
+
const sessionSnapshot: AppSessionSnapshot = {
|
|
372
|
+
paneState: {
|
|
373
|
+
"ticker-detail:main": { activeTabId: "overview" },
|
|
374
|
+
},
|
|
375
|
+
focusedPaneId: "portfolio-list:main",
|
|
376
|
+
activePanel: "left",
|
|
377
|
+
statusBarVisible: true,
|
|
378
|
+
openPaneIds: ["portfolio-list:main", "ticker-detail:main"],
|
|
379
|
+
hydrationTargets: [],
|
|
380
|
+
exchangeCurrencies: ["USD"],
|
|
381
|
+
savedAt: Date.now(),
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const state = createInitialState(config, sessionSnapshot);
|
|
385
|
+
|
|
386
|
+
expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("chart");
|
|
387
|
+
expect(state.focusedPaneId).toBe("ticker-detail:main");
|
|
388
|
+
expect(state.activePanel).toBe("right");
|
|
389
|
+
});
|
|
332
390
|
});
|
|
333
391
|
|
|
334
392
|
describe("quote merging", () => {
|
|
@@ -487,6 +545,61 @@ describe("quote merging", () => {
|
|
|
487
545
|
});
|
|
488
546
|
|
|
489
547
|
describe("layout focus fallback", () => {
|
|
548
|
+
test("switching to an old layout without focused metadata uses that layout's top floating pane", () => {
|
|
549
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
550
|
+
const targetLayout = {
|
|
551
|
+
...cloneLayout(config.layout),
|
|
552
|
+
dockRoot: null,
|
|
553
|
+
floating: [
|
|
554
|
+
{ instanceId: "chat:main", x: 4, y: 2, width: 36, height: 10, zIndex: 70 },
|
|
555
|
+
{ instanceId: "ticker-detail:main", x: 12, y: 4, width: 36, height: 10, zIndex: 95 },
|
|
556
|
+
],
|
|
557
|
+
};
|
|
558
|
+
config.layouts = [
|
|
559
|
+
{ name: "Current", layout: cloneLayout(config.layout), paneState: {} },
|
|
560
|
+
{ name: "Floating", layout: cloneLayout(targetLayout), paneState: {} },
|
|
561
|
+
];
|
|
562
|
+
config.activeLayoutIndex = 0;
|
|
563
|
+
|
|
564
|
+
let state = createInitialState(config);
|
|
565
|
+
state = appReducer(state, { type: "FOCUS_PANE", paneId: "chat:main" });
|
|
566
|
+
state = appReducer(state, { type: "SWITCH_LAYOUT", index: 1 });
|
|
567
|
+
|
|
568
|
+
const chatEntry = state.config.layout.floating.find((entry) => entry.instanceId === "chat:main");
|
|
569
|
+
const tickerEntry = state.config.layout.floating.find((entry) => entry.instanceId === "ticker-detail:main");
|
|
570
|
+
expect(state.focusedPaneId).toBe("ticker-detail:main");
|
|
571
|
+
expect((tickerEntry?.zIndex ?? 0) > (chatEntry?.zIndex ?? 0)).toBe(true);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("switching layouts raises the saved focused floating pane above stale z-order", () => {
|
|
575
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
576
|
+
const targetLayout = {
|
|
577
|
+
...cloneLayout(config.layout),
|
|
578
|
+
dockRoot: null,
|
|
579
|
+
floating: [
|
|
580
|
+
{ instanceId: "chat:main", x: 4, y: 2, width: 36, height: 10, zIndex: 70 },
|
|
581
|
+
{ instanceId: "ticker-detail:main", x: 12, y: 4, width: 36, height: 10, zIndex: 95 },
|
|
582
|
+
],
|
|
583
|
+
};
|
|
584
|
+
config.layouts = [
|
|
585
|
+
{ name: "Current", layout: cloneLayout(config.layout), paneState: {} },
|
|
586
|
+
{
|
|
587
|
+
name: "Floating",
|
|
588
|
+
layout: cloneLayout(targetLayout),
|
|
589
|
+
paneState: {},
|
|
590
|
+
focusedPaneId: "chat:main",
|
|
591
|
+
},
|
|
592
|
+
];
|
|
593
|
+
config.activeLayoutIndex = 0;
|
|
594
|
+
|
|
595
|
+
const state = appReducer(createInitialState(config), { type: "SWITCH_LAYOUT", index: 1 });
|
|
596
|
+
|
|
597
|
+
const chatEntry = state.config.layout.floating.find((entry) => entry.instanceId === "chat:main");
|
|
598
|
+
const tickerEntry = state.config.layout.floating.find((entry) => entry.instanceId === "ticker-detail:main");
|
|
599
|
+
expect(state.focusedPaneId).toBe("chat:main");
|
|
600
|
+
expect((chatEntry?.zIndex ?? 0) > (tickerEntry?.zIndex ?? 0)).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
490
603
|
test("starts with the top floating pane focused when no session pane is active", () => {
|
|
491
604
|
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
492
605
|
const layout = {
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type PaneRuntimeState,
|
|
38
38
|
resolveCollectionForPane,
|
|
39
39
|
resolveTickerForPane,
|
|
40
|
+
syncConfigActiveLayoutState,
|
|
40
41
|
} from "../core/state/app-state";
|
|
41
42
|
import type { AppAction, AppState } from "../core/state/app-state";
|
|
42
43
|
import { scheduleConfigSave } from "./config-save-scheduler";
|
|
@@ -50,6 +51,7 @@ export {
|
|
|
50
51
|
getPaneState,
|
|
51
52
|
resolveCollectionForPane,
|
|
52
53
|
resolveFinancialsForPane,
|
|
54
|
+
syncConfigActiveLayoutState,
|
|
53
55
|
resolveTickerFileForPane,
|
|
54
56
|
resolveTickerForPane,
|
|
55
57
|
} from "../core/state/app-state";
|
|
@@ -283,10 +285,12 @@ export function usePaneSettingValue<T>(
|
|
|
283
285
|
? (nextValue as (previousValue: T) => T)(currentValue)
|
|
284
286
|
: nextValue;
|
|
285
287
|
const layout = setPaneSetting(currentState.config.layout, scopedPaneId, key, resolved);
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
288
|
+
const nextConfig = syncConfigActiveLayoutState(
|
|
289
|
+
{ ...currentState.config, layout },
|
|
290
|
+
currentState.paneState,
|
|
291
|
+
currentState.focusedPaneId,
|
|
292
|
+
currentState.activePanel,
|
|
293
|
+
);
|
|
290
294
|
dispatch({ type: "SET_CONFIG", config: nextConfig });
|
|
291
295
|
scheduleConfigSave(nextConfig);
|
|
292
296
|
}, [dispatch, fallback, key, scopedPaneId, stateRef]);
|
package/src/theme/colors.ts
CHANGED
|
@@ -73,21 +73,20 @@ export function syncTheme(id: string): void {
|
|
|
73
73
|
|
|
74
74
|
export { blendHex } from "./color-utils";
|
|
75
75
|
|
|
76
|
-
const COMPARISON_SERIES_COLORS = [
|
|
77
|
-
"#5bc0eb",
|
|
78
|
-
"#f6ae2d",
|
|
79
|
-
"#f26419",
|
|
80
|
-
"#6f2dbd",
|
|
81
|
-
"#00a896",
|
|
82
|
-
"#ef476f",
|
|
83
|
-
"#118ab2",
|
|
84
|
-
"#8ac926",
|
|
85
|
-
"#ff7f51",
|
|
86
|
-
"#4361ee",
|
|
87
|
-
] as const;
|
|
88
|
-
|
|
89
76
|
export function getComparisonSeriesColor(index: number): string {
|
|
90
|
-
|
|
77
|
+
const seriesColors = [
|
|
78
|
+
colors.borderFocused,
|
|
79
|
+
colors.warning,
|
|
80
|
+
colors.positive,
|
|
81
|
+
colors.negative,
|
|
82
|
+
colors.textBright,
|
|
83
|
+
colors.neutral,
|
|
84
|
+
blendHex(colors.borderFocused, colors.positive, 0.45),
|
|
85
|
+
blendHex(colors.warning, colors.negative, 0.35),
|
|
86
|
+
blendHex(colors.textBright, colors.positive, 0.35),
|
|
87
|
+
blendHex(colors.borderFocused, colors.negative, 0.35),
|
|
88
|
+
];
|
|
89
|
+
return seriesColors[((index % seriesColors.length) + seriesColors.length) % seriesColors.length]!;
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
/** Returns a hover background color derived from bg and selected */
|
package/src/types/config.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Portfolio, Watchlist } from "./ticker";
|
|
2
2
|
|
|
3
|
-
export const CURRENT_CONFIG_VERSION =
|
|
3
|
+
export const CURRENT_CONFIG_VERSION = 16;
|
|
4
4
|
|
|
5
|
-
export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc";
|
|
5
|
+
export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc" | "hlc";
|
|
6
6
|
export type ChartRendererPreference = "auto" | "kitty" | "braille";
|
|
7
7
|
|
|
8
8
|
export interface ChartPreferences {
|
|
@@ -108,8 +108,12 @@ export interface LayoutConfig {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export interface SavedLayout {
|
|
111
|
+
id?: string;
|
|
111
112
|
name: string;
|
|
112
113
|
layout: LayoutConfig;
|
|
114
|
+
paneState?: Record<string, Record<string, unknown>>;
|
|
115
|
+
focusedPaneId?: string | null;
|
|
116
|
+
activePanel?: "left" | "right";
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
export interface AppConfig {
|
|
@@ -154,6 +158,7 @@ export const DEFAULT_COLUMNS: ColumnConfig[] = [
|
|
|
154
158
|
|
|
155
159
|
export const DEFAULT_PORTFOLIO_COLUMN_IDS = [
|
|
156
160
|
...DEFAULT_COLUMNS.map((column) => column.id),
|
|
161
|
+
"sparkline",
|
|
157
162
|
"shares",
|
|
158
163
|
"avg_cost",
|
|
159
164
|
"cost_basis",
|
|
@@ -585,8 +590,8 @@ export function createDefaultConfig(dataDir: string): AppConfig {
|
|
|
585
590
|
watchlists: [{ id: "watchlist", name: "Watchlist" }],
|
|
586
591
|
layout,
|
|
587
592
|
layouts: [
|
|
588
|
-
{ name: "Home", layout: cloneLayout(layout) },
|
|
589
|
-
{ name: "Monitor", layout: cloneLayout(DEFAULT_MONITOR_LAYOUT) },
|
|
593
|
+
{ name: "Home", layout: cloneLayout(layout), paneState: {} },
|
|
594
|
+
{ name: "Monitor", layout: cloneLayout(DEFAULT_MONITOR_LAYOUT), paneState: {} },
|
|
590
595
|
],
|
|
591
596
|
activeLayoutIndex: 0,
|
|
592
597
|
brokerInstances: [],
|
package/src/types/plugin.ts
CHANGED
|
@@ -110,6 +110,7 @@ export type PaneSettingField =
|
|
|
110
110
|
|
|
111
111
|
export interface PaneSettingsDef {
|
|
112
112
|
title?: string;
|
|
113
|
+
values?: Record<string, unknown>;
|
|
113
114
|
fields: PaneSettingField[];
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -406,6 +407,12 @@ export interface BrokerInstanceUpdateOptions {
|
|
|
406
407
|
replaceConfig?: boolean;
|
|
407
408
|
}
|
|
408
409
|
|
|
410
|
+
export interface PinTickerOptions {
|
|
411
|
+
floating?: boolean;
|
|
412
|
+
paneType?: string;
|
|
413
|
+
forceNewPane?: boolean;
|
|
414
|
+
}
|
|
415
|
+
|
|
409
416
|
export interface GloomPluginContext {
|
|
410
417
|
registerPane(pane: PaneDef): void;
|
|
411
418
|
registerPaneTemplate(template: PaneTemplateDef): void;
|
|
@@ -448,7 +455,7 @@ export interface GloomPluginContext {
|
|
|
448
455
|
createPaneFromTemplate(templateId: string, options?: PaneTemplateCreateOptions): void;
|
|
449
456
|
hidePane(paneId: string): void;
|
|
450
457
|
focusPane(paneId: string): void;
|
|
451
|
-
pinTicker(symbol: string, options?:
|
|
458
|
+
pinTicker(symbol: string, options?: PinTickerOptions): void;
|
|
452
459
|
navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }): void;
|
|
453
460
|
openPaneSettings(paneId?: string): void;
|
|
454
461
|
|
package/src/ui/context-menu.tsx
CHANGED
|
@@ -127,7 +127,7 @@ function selectedTextMenuItems(text: string, registry: PluginRegistry | null, co
|
|
|
127
127
|
contextMenuDivider("selected-text:ticker-divider"),
|
|
128
128
|
{
|
|
129
129
|
id: "selected-text:open-ticker",
|
|
130
|
-
label: "Open
|
|
130
|
+
label: "Open Ticker Detail",
|
|
131
131
|
onSelect: () => registry?.navigateTicker(symbol),
|
|
132
132
|
},
|
|
133
133
|
{
|
|
@@ -224,13 +224,13 @@ export function tickerContextMenuItems({
|
|
|
224
224
|
const items: ContextMenuItem[] = [
|
|
225
225
|
{
|
|
226
226
|
id: "ticker:open",
|
|
227
|
-
label: `Open ${symbol}`,
|
|
227
|
+
label: `Open ${symbol} Detail`,
|
|
228
228
|
onSelect: () => (openTicker ? openTicker(symbol) : registry?.navigateTicker(symbol)),
|
|
229
229
|
},
|
|
230
230
|
{
|
|
231
231
|
id: "ticker:pin-floating",
|
|
232
|
-
label: `
|
|
233
|
-
onSelect: () => registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail" }),
|
|
232
|
+
label: `Open ${symbol} in New Floating Detail`,
|
|
233
|
+
onSelect: () => registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail", forceNewPane: true }),
|
|
234
234
|
},
|
|
235
235
|
{
|
|
236
236
|
id: "ticker:copy-symbol",
|
|
@@ -282,6 +282,134 @@ describe("apiClient chat timestamps", () => {
|
|
|
282
282
|
expect(seenCreatedAts).toEqual(["2026-04-08T07:28:27.625Z"]);
|
|
283
283
|
channel.close();
|
|
284
284
|
});
|
|
285
|
+
|
|
286
|
+
test("fetches chat state and normalizes pending notification timestamps", async () => {
|
|
287
|
+
let requestedUrl = "";
|
|
288
|
+
globalThis.fetch = mockFetch(async (input: Request | string | URL) => {
|
|
289
|
+
requestedUrl = String(input);
|
|
290
|
+
return createResponse({
|
|
291
|
+
channels: [{ id: "everyone", name: "everyone", created_at: "2026-04-08T07:00:00.000Z" }],
|
|
292
|
+
onlineCount: 2,
|
|
293
|
+
channelStates: [{
|
|
294
|
+
channelId: "everyone",
|
|
295
|
+
notificationsEnabled: true,
|
|
296
|
+
lastReadMessageId: "m1",
|
|
297
|
+
unreadCount: 1,
|
|
298
|
+
}],
|
|
299
|
+
notifications: [{
|
|
300
|
+
id: "n1",
|
|
301
|
+
type: "reply",
|
|
302
|
+
channelId: "everyone",
|
|
303
|
+
messageId: "m2",
|
|
304
|
+
createdAt: "2026-04-08 07:30:00.000",
|
|
305
|
+
message: {
|
|
306
|
+
id: "m2",
|
|
307
|
+
channelId: "everyone",
|
|
308
|
+
content: "reply",
|
|
309
|
+
replyToId: "m1",
|
|
310
|
+
createdAt: "2026-04-08 07:29:00.000",
|
|
311
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
312
|
+
replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
|
|
313
|
+
},
|
|
314
|
+
}],
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const state = await apiClient.getChatState();
|
|
319
|
+
|
|
320
|
+
expect(new URL(requestedUrl).pathname).toBe("/chat/state");
|
|
321
|
+
expect(state.onlineCount).toBe(2);
|
|
322
|
+
expect(state.notifications[0]?.createdAt).toBe("2026-04-08T07:30:00.000Z");
|
|
323
|
+
expect(state.notifications[0]?.message.createdAt).toBe("2026-04-08T07:29:00.000Z");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("updates chat channel state and marks notifications delivered", async () => {
|
|
327
|
+
const requests: Array<{ path: string; method: string; body: unknown }> = [];
|
|
328
|
+
const responses = [
|
|
329
|
+
createResponse({
|
|
330
|
+
channelId: "everyone",
|
|
331
|
+
notificationsEnabled: true,
|
|
332
|
+
lastReadMessageId: "m2",
|
|
333
|
+
unreadCount: 0,
|
|
334
|
+
}),
|
|
335
|
+
createResponse({ delivered: 2 }),
|
|
336
|
+
createResponse({ onlineCount: 4 }),
|
|
337
|
+
];
|
|
338
|
+
globalThis.fetch = mockFetch(async (input: Request | string | URL, init?: RequestInit) => {
|
|
339
|
+
requests.push({
|
|
340
|
+
path: new URL(String(input)).pathname,
|
|
341
|
+
method: init?.method ?? "GET",
|
|
342
|
+
body: init?.body ? JSON.parse(String(init.body)) : null,
|
|
343
|
+
});
|
|
344
|
+
return responses.shift() as Response;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await apiClient.updateChatChannelState("everyone", {
|
|
348
|
+
notificationsEnabled: true,
|
|
349
|
+
readThroughMessageId: "m2",
|
|
350
|
+
});
|
|
351
|
+
await apiClient.markChatNotificationsDelivered(["n1", "n2"]);
|
|
352
|
+
const presence = await apiClient.getChatPresence();
|
|
353
|
+
|
|
354
|
+
expect(requests).toEqual([
|
|
355
|
+
{
|
|
356
|
+
path: "/chat/channels/everyone/state",
|
|
357
|
+
method: "PATCH",
|
|
358
|
+
body: { notificationsEnabled: true, readThroughMessageId: "m2" },
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
path: "/chat/notifications/delivered",
|
|
362
|
+
method: "POST",
|
|
363
|
+
body: { notificationIds: ["n1", "n2"] },
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
path: "/chat/presence",
|
|
367
|
+
method: "GET",
|
|
368
|
+
body: null,
|
|
369
|
+
},
|
|
370
|
+
]);
|
|
371
|
+
expect(presence).toEqual({ onlineCount: 4 });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("emits websocket chat presence and notification events", async () => {
|
|
375
|
+
const seenPresence: number[] = [];
|
|
376
|
+
const seenNotifications: string[] = [];
|
|
377
|
+
const unsubscribePresence = apiClient.subscribeChatPresence((onlineCount) => {
|
|
378
|
+
seenPresence.push(onlineCount);
|
|
379
|
+
});
|
|
380
|
+
const unsubscribeNotifications = apiClient.subscribeChatNotifications((notification) => {
|
|
381
|
+
seenNotifications.push(`${notification.id}:${notification.message.createdAt}`);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
385
|
+
type: "chat.presence",
|
|
386
|
+
onlineCount: 5,
|
|
387
|
+
}));
|
|
388
|
+
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
389
|
+
type: "chat.notification",
|
|
390
|
+
data: {
|
|
391
|
+
id: "n1",
|
|
392
|
+
type: "reply",
|
|
393
|
+
channelId: "everyone",
|
|
394
|
+
messageId: "m2",
|
|
395
|
+
createdAt: "2026-04-08 07:30:00.000",
|
|
396
|
+
message: {
|
|
397
|
+
id: "m2",
|
|
398
|
+
channelId: "everyone",
|
|
399
|
+
content: "reply",
|
|
400
|
+
replyToId: "m1",
|
|
401
|
+
createdAt: "2026-04-08 07:29:00.000",
|
|
402
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
403
|
+
replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
}));
|
|
407
|
+
|
|
408
|
+
expect(seenPresence).toEqual([5]);
|
|
409
|
+
expect(seenNotifications).toEqual(["n1:2026-04-08T07:29:00.000Z"]);
|
|
410
|
+
unsubscribePresence();
|
|
411
|
+
unsubscribeNotifications();
|
|
412
|
+
});
|
|
285
413
|
});
|
|
286
414
|
|
|
287
415
|
describe("apiClient cloud news", () => {
|
package/src/utils/api-client.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface ChatMessage {
|
|
|
48
48
|
replyToId: string | null;
|
|
49
49
|
createdAt: string;
|
|
50
50
|
user: { id: string; username: string; displayName: string };
|
|
51
|
-
replyTo?: { content: string; user: { username: string } } | null;
|
|
51
|
+
replyTo?: { content: string; user: { id?: string; username: string } } | null;
|
|
52
52
|
clientStatus?: "sending" | "failed";
|
|
53
53
|
clientError?: string | null;
|
|
54
54
|
}
|
|
@@ -59,6 +59,29 @@ export interface ChatChannel {
|
|
|
59
59
|
created_at: string;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export interface ChatChannelState {
|
|
63
|
+
channelId: string;
|
|
64
|
+
notificationsEnabled: boolean;
|
|
65
|
+
lastReadMessageId: string | null;
|
|
66
|
+
unreadCount: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ChatNotification {
|
|
70
|
+
id: string;
|
|
71
|
+
type: "reply" | "mention" | "channel";
|
|
72
|
+
channelId: string;
|
|
73
|
+
messageId: string;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
message: ChatMessage;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ChatStateResponse {
|
|
79
|
+
channels: ChatChannel[];
|
|
80
|
+
onlineCount: number;
|
|
81
|
+
channelStates: ChatChannelState[];
|
|
82
|
+
notifications: ChatNotification[];
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
export interface AuthUser {
|
|
63
86
|
id: string;
|
|
64
87
|
name: string;
|
|
@@ -352,6 +375,21 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
|
352
375
|
return messages.map((message) => normalizeChatMessage(message));
|
|
353
376
|
}
|
|
354
377
|
|
|
378
|
+
function normalizeChatNotification(notification: ChatNotification): ChatNotification {
|
|
379
|
+
return {
|
|
380
|
+
...notification,
|
|
381
|
+
createdAt: normalizeTimestamp(notification.createdAt),
|
|
382
|
+
message: normalizeChatMessage(notification.message),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function normalizeChatState(response: ChatStateResponse): ChatStateResponse {
|
|
387
|
+
return {
|
|
388
|
+
...response,
|
|
389
|
+
notifications: response.notifications.map(normalizeChatNotification),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
355
393
|
function normalizeTweet(tweet: CloudTweetPayload): CloudTweetPayload {
|
|
356
394
|
return {
|
|
357
395
|
...tweet,
|
|
@@ -370,6 +408,8 @@ function normalizeTweetSearchResponse(response: CloudTweetSearchResponse): Cloud
|
|
|
370
408
|
}
|
|
371
409
|
|
|
372
410
|
type ChannelListener = (message: ChatMessage) => void;
|
|
411
|
+
type ChatNotificationListener = (notification: ChatNotification) => void;
|
|
412
|
+
type ChatPresenceListener = (onlineCount: number) => void;
|
|
373
413
|
type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
|
|
374
414
|
type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
|
|
375
415
|
|
|
@@ -408,6 +448,8 @@ class GloomApiClient {
|
|
|
408
448
|
private reconnectDelayMs = 1000;
|
|
409
449
|
|
|
410
450
|
private readonly channelListeners = new Map<string, Set<ChannelListener>>();
|
|
451
|
+
private readonly chatNotificationListeners = new Set<ChatNotificationListener>();
|
|
452
|
+
private readonly chatPresenceListeners = new Set<ChatPresenceListener>();
|
|
411
453
|
private readonly quoteListeners = new Map<string, Set<QuoteListener>>();
|
|
412
454
|
private readonly quoteTargets = new Map<string, QuoteStreamTarget>();
|
|
413
455
|
private readonly pendingQuoteSubscribes = new Map<string, QuoteStreamTarget>();
|
|
@@ -650,7 +692,12 @@ class GloomApiClient {
|
|
|
650
692
|
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
651
693
|
if (payload && typeof payload === "object" && "type" in (payload as Record<string, unknown>)) {
|
|
652
694
|
const type = (payload as Record<string, unknown>).type;
|
|
653
|
-
if (
|
|
695
|
+
if (
|
|
696
|
+
type === "market.subscribe"
|
|
697
|
+
|| type === "market.unsubscribe"
|
|
698
|
+
|| type === "chat.subscribe"
|
|
699
|
+
|| type === "chat.unsubscribe"
|
|
700
|
+
) {
|
|
654
701
|
cloudApiLog.info("send websocket message", payload);
|
|
655
702
|
}
|
|
656
703
|
}
|
|
@@ -773,6 +820,21 @@ class GloomApiClient {
|
|
|
773
820
|
return;
|
|
774
821
|
}
|
|
775
822
|
|
|
823
|
+
if (parsed?.type === "chat.notification" && parsed.data) {
|
|
824
|
+
const notification = normalizeChatNotification(parsed.data as ChatNotification);
|
|
825
|
+
for (const listener of this.chatNotificationListeners) {
|
|
826
|
+
listener(notification);
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (parsed?.type === "chat.presence" && typeof parsed.onlineCount === "number") {
|
|
832
|
+
for (const listener of this.chatPresenceListeners) {
|
|
833
|
+
listener(parsed.onlineCount);
|
|
834
|
+
}
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
776
838
|
if (parsed?.type === "market.quote" && parsed.quote && typeof parsed.symbol === "string") {
|
|
777
839
|
const key = marketKey(parsed.symbol, parsed.exchange);
|
|
778
840
|
const target = this.quoteTargets.get(key) ?? {
|
|
@@ -853,6 +915,32 @@ class GloomApiClient {
|
|
|
853
915
|
return this.request<ChatChannel[]>("/chat/channels");
|
|
854
916
|
}
|
|
855
917
|
|
|
918
|
+
async getChatPresence(): Promise<{ onlineCount: number }> {
|
|
919
|
+
return this.request<{ onlineCount: number }>("/chat/presence");
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async getChatState(): Promise<ChatStateResponse> {
|
|
923
|
+
const state = await this.request<ChatStateResponse>("/chat/state");
|
|
924
|
+
return normalizeChatState(state);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async updateChatChannelState(
|
|
928
|
+
channelId: string,
|
|
929
|
+
body: { notificationsEnabled?: boolean; readThroughMessageId?: string },
|
|
930
|
+
): Promise<ChatChannelState> {
|
|
931
|
+
return this.request<ChatChannelState>(`/chat/channels/${channelId}/state`, {
|
|
932
|
+
method: "PATCH",
|
|
933
|
+
body: JSON.stringify(body),
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async markChatNotificationsDelivered(notificationIds: string[]): Promise<{ delivered: number }> {
|
|
938
|
+
return this.request<{ delivered: number }>("/chat/notifications/delivered", {
|
|
939
|
+
method: "POST",
|
|
940
|
+
body: JSON.stringify({ notificationIds }),
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
856
944
|
async getMessages(
|
|
857
945
|
channelId: string,
|
|
858
946
|
opts?: { after?: string; before?: string; limit?: number },
|
|
@@ -866,10 +954,10 @@ class GloomApiClient {
|
|
|
866
954
|
return normalizeChatMessages(messages);
|
|
867
955
|
}
|
|
868
956
|
|
|
869
|
-
async sendMessage(channelId: string, content: string, replyToId?: string): Promise<ChatMessage> {
|
|
957
|
+
async sendMessage(channelId: string, content: string, replyToId?: string, clientMessageId?: string): Promise<ChatMessage> {
|
|
870
958
|
const message = await this.request<ChatMessage>(`/chat/channels/${channelId}/messages`, {
|
|
871
959
|
method: "POST",
|
|
872
|
-
body: JSON.stringify({ content, replyToId }),
|
|
960
|
+
body: JSON.stringify({ content, replyToId, clientMessageId }),
|
|
873
961
|
});
|
|
874
962
|
return normalizeChatMessage(message);
|
|
875
963
|
}
|
|
@@ -878,7 +966,7 @@ class GloomApiClient {
|
|
|
878
966
|
channelId: string,
|
|
879
967
|
onMessage: (msg: ChatMessage) => void,
|
|
880
968
|
onError?: (err: string) => void,
|
|
881
|
-
): { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void } {
|
|
969
|
+
): { send: (content: string, replyToId?: string, clientMessageId?: string) => Promise<ChatMessage>; close: () => void } {
|
|
882
970
|
if (!channelId) {
|
|
883
971
|
return {
|
|
884
972
|
send: async () => {
|
|
@@ -898,9 +986,9 @@ class GloomApiClient {
|
|
|
898
986
|
}
|
|
899
987
|
|
|
900
988
|
return {
|
|
901
|
-
send: async (content: string, replyToId?: string) => {
|
|
989
|
+
send: async (content: string, replyToId?: string, clientMessageId?: string) => {
|
|
902
990
|
try {
|
|
903
|
-
const message = await this.sendMessage(channelId, content, replyToId);
|
|
991
|
+
const message = await this.sendMessage(channelId, content, replyToId, clientMessageId);
|
|
904
992
|
onMessage(message);
|
|
905
993
|
return message;
|
|
906
994
|
} catch (error) {
|
|
@@ -914,6 +1002,7 @@ class GloomApiClient {
|
|
|
914
1002
|
current.delete(onMessage);
|
|
915
1003
|
if (current.size === 0) {
|
|
916
1004
|
this.channelListeners.delete(channelId);
|
|
1005
|
+
this.sendSocketMessage({ type: "chat.unsubscribe", channelId });
|
|
917
1006
|
}
|
|
918
1007
|
if (!this.shouldKeepSocketOpen()) {
|
|
919
1008
|
this.teardownSocket();
|
|
@@ -922,6 +1011,20 @@ class GloomApiClient {
|
|
|
922
1011
|
};
|
|
923
1012
|
}
|
|
924
1013
|
|
|
1014
|
+
subscribeChatNotifications(listener: ChatNotificationListener): () => void {
|
|
1015
|
+
this.chatNotificationListeners.add(listener);
|
|
1016
|
+
return () => {
|
|
1017
|
+
this.chatNotificationListeners.delete(listener);
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
subscribeChatPresence(listener: ChatPresenceListener): () => void {
|
|
1022
|
+
this.chatPresenceListeners.add(listener);
|
|
1023
|
+
return () => {
|
|
1024
|
+
this.chatPresenceListeners.delete(listener);
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
925
1028
|
subscribeQuotes(
|
|
926
1029
|
targets: QuoteStreamTarget[],
|
|
927
1030
|
onQuote: (target: QuoteStreamTarget, quote: CloudQuotePayload) => void,
|
|
@@ -997,6 +1100,8 @@ class GloomApiClient {
|
|
|
997
1100
|
channelTargets: this.channelListeners.size,
|
|
998
1101
|
});
|
|
999
1102
|
this.channelListeners.clear();
|
|
1103
|
+
this.chatNotificationListeners.clear();
|
|
1104
|
+
this.chatPresenceListeners.clear();
|
|
1000
1105
|
this.quoteListeners.clear();
|
|
1001
1106
|
this.quoteTargets.clear();
|
|
1002
1107
|
this.pendingQuoteSubscribes.clear();
|
|
@@ -5,7 +5,18 @@ export function fuzzyMatch(query: string, target: string): { match: boolean; sco
|
|
|
5
5
|
const t = target.toLowerCase();
|
|
6
6
|
|
|
7
7
|
if (q.length === 0) return { match: true, score: 0 };
|
|
8
|
-
if (t
|
|
8
|
+
if (t === q) return { match: true, score: 2500 - q.length };
|
|
9
|
+
|
|
10
|
+
const tokens = t.split(/\s+/).filter(Boolean);
|
|
11
|
+
const exactTokenIndex = tokens.findIndex((token) => token === q);
|
|
12
|
+
if (exactTokenIndex >= 0) return { match: true, score: 2200 - q.length - exactTokenIndex };
|
|
13
|
+
|
|
14
|
+
const prefixTokenIndex = tokens.findIndex((token) => token.startsWith(q));
|
|
15
|
+
if (prefixTokenIndex >= 0) {
|
|
16
|
+
const token = tokens[prefixTokenIndex]!;
|
|
17
|
+
return { match: true, score: 1000 - q.length - prefixTokenIndex - (token.length - q.length) };
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
if (t.includes(q)) return { match: true, score: 500 - q.length }; // substring match
|
|
10
21
|
|
|
11
22
|
let qi = 0;
|