gloomberb 0.7.1 → 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.
Files changed (131) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +47 -7
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +67 -11
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/ui/host.tsx +8 -2
  124. package/src/ui/index.tsx +1 -0
  125. package/src/utils/api-client.test.ts +128 -0
  126. package/src/utils/api-client.ts +112 -7
  127. package/src/utils/fuzzy-search.ts +12 -1
  128. package/src/utils/keyboard.ts +31 -0
  129. package/src/utils/ticker-search.test.ts +2 -2
  130. package/src/utils/ticker-search.ts +48 -1
  131. package/src/version.ts +1 -1
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
4
4
  import { resetTerminalInputState } from "../../utils/terminal-input-reset";
5
5
  import type { KeyEventLike } from "../../react/input";
6
6
  import type { NativeRendererHost, RendererHost } from "../../ui/host";
7
+ import { colors } from "../../theme/colors";
7
8
 
8
9
  export { useKeyboard, useRenderer, useTerminalDimensions };
9
10
  export type { CliRenderer };
@@ -54,7 +55,7 @@ export async function createOpenTuiHost(): Promise<OpenTuiHost> {
54
55
 
55
56
  const renderer = await createCliRenderer({
56
57
  exitOnCtrlC: true,
57
- backgroundColor: "#000000",
58
+ backgroundColor: colors.bg,
58
59
  enableMouseMovement: true,
59
60
  });
60
61
  const root = createRoot(renderer);
@@ -66,7 +66,7 @@ export async function startOpenTuiApp(): Promise<void> {
66
66
  <OpenTuiDialogHostProvider
67
67
  size="medium"
68
68
  dialogOptions={{ style: { backgroundColor: colors.bg, borderColor: colors.borderFocused, borderStyle: "single", paddingX: 2, paddingY: 1 } }}
69
- backdropColor="#000000"
69
+ backdropColor={colors.bg}
70
70
  backdropOpacity={0.8}
71
71
  >
72
72
  <App
@@ -4,6 +4,7 @@ import { testRender as openTuiTestRender } from "@opentui/react/test-utils";
4
4
  import { createRoot as openTuiCreateRoot, useRenderer } from "@opentui/react";
5
5
  import { UiHostProvider, type NativeRendererHost, type RendererHost } from "../../ui";
6
6
  import { ToastHostProvider } from "../../ui/toast";
7
+ import { colors } from "../../theme/colors";
7
8
  import { OpenTuiInputHostProvider } from "./input-host";
8
9
  import { openTuiUiHost } from "./ui-host";
9
10
  import { OpenTuiDialogHostProvider } from "./dialog-host";
@@ -14,8 +15,8 @@ export function TestDialogProvider({ children }: { children: ReactNode }) {
14
15
  <DialogProvider
15
16
  dialogOptions={{
16
17
  style: {
17
- backgroundColor: "#000000",
18
- borderColor: "#ffffff",
18
+ backgroundColor: colors.bg,
19
+ borderColor: colors.borderFocused,
19
20
  borderStyle: "single",
20
21
  },
21
22
  }}
@@ -54,9 +55,9 @@ export function OpenTuiTestProviders({ children }: { children: ReactNode }) {
54
55
  <OpenTuiInputHostProvider>
55
56
  <ToastHostProvider host={openTuiToastHost}>
56
57
  <OpenTuiDialogHostProvider
57
- backgroundColor="#000000"
58
- containerBorderColor="#555555"
59
- focusedBorderColor="#9cdcfe"
58
+ backgroundColor={colors.bg}
59
+ containerBorderColor={colors.border}
60
+ focusedBorderColor={colors.borderFocused}
60
61
  >
61
62
  {children}
62
63
  </OpenTuiDialogHostProvider>
@@ -37,11 +37,11 @@ interface RefreshPlanEntry {
37
37
  }
38
38
 
39
39
  const MAX_BACKGROUND_WARMUP_TICKERS = 12;
40
- const PORTFOLIO_FINANCIAL_COLUMN_IDS = new Set([
41
- "market_cap",
40
+ const PORTFOLIO_FULL_SNAPSHOT_COLUMN_IDS = new Set([
42
41
  "pe",
43
42
  "forward_pe",
44
43
  "dividend_yield",
44
+ "sparkline",
45
45
  ]);
46
46
  const startupLog = debugLog.createLogger("startup");
47
47
 
@@ -157,7 +157,7 @@ function buildRefreshPlan(
157
157
  const columnIds = Array.isArray(instance.settings?.columnIds)
158
158
  ? instance.settings.columnIds.filter((value): value is string => typeof value === "string")
159
159
  : [];
160
- return columnIds.some((columnId) => PORTFOLIO_FINANCIAL_COLUMN_IDS.has(columnId))
160
+ return columnIds.some((columnId) => PORTFOLIO_FULL_SNAPSHOT_COLUMN_IDS.has(columnId))
161
161
  ? "financials"
162
162
  : "quote";
163
163
  };
@@ -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 layouts = currentState.config.layouts.map((savedLayout, index) => (
287
- index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
288
- ));
289
- const nextConfig = { ...currentState.config, layout, layouts };
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]);
@@ -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
- return COMPARISON_SERIES_COLORS[((index % COMPARISON_SERIES_COLORS.length) + COMPARISON_SERIES_COLORS.length) % COMPARISON_SERIES_COLORS.length]!;
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 */
@@ -1,8 +1,8 @@
1
1
  import type { Portfolio, Watchlist } from "./ticker";
2
2
 
3
- export const CURRENT_CONFIG_VERSION = 15;
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: [],
@@ -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?: { floating?: boolean; paneType?: string }): void;
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
 
@@ -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 as Ticker",
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: `Pin ${symbol} in Floating Detail`,
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",
package/src/ui/host.tsx CHANGED
@@ -94,6 +94,12 @@ export interface BoxRenderable {
94
94
  [key: string]: unknown;
95
95
  }
96
96
 
97
+ export interface ScrollBarRenderable {
98
+ visible: boolean;
99
+ on?(event: "change", handler: () => void): void;
100
+ off?(event: "change", handler: () => void): void;
101
+ }
102
+
97
103
  export interface ScrollBoxRenderable {
98
104
  scrollTop: number;
99
105
  scrollLeft?: number;
@@ -107,8 +113,8 @@ export interface ScrollBoxRenderable {
107
113
  visible?: boolean;
108
114
  parent?: unknown;
109
115
  getBoundingClientRect?: () => { x: number; y: number; width: number; height: number };
110
- horizontalScrollBar?: { visible: boolean };
111
- verticalScrollBar?: { visible: boolean };
116
+ horizontalScrollBar?: ScrollBarRenderable;
117
+ verticalScrollBar?: ScrollBarRenderable;
112
118
  scrollTo(target: number | { x?: number; y?: number }, y?: number): void;
113
119
  scrollToPixels?(target: number | { x?: number; y?: number }, y?: number): void;
114
120
  }
package/src/ui/index.tsx CHANGED
@@ -39,6 +39,7 @@ export type {
39
39
  PixelResolution,
40
40
  RGBA as RGBAColor,
41
41
  RendererHost,
42
+ ScrollBarRenderable,
42
43
  ScrollBoxRenderable,
43
44
  ScrollBoxProps,
44
45
  SyntaxStyleLike,
@@ -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", () => {