gloomberb 0.7.3 → 0.7.5

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +59 -30
  3. package/src/components/chart/chart-renderer.ts +109 -32
  4. package/src/components/chart/chart-types.ts +18 -0
  5. package/src/components/chart/chart.test.ts +152 -5
  6. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  7. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  8. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  9. package/src/components/chart/market-session.ts +187 -0
  10. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  11. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  12. package/src/components/chart/stock-chart.tsx +36 -8
  13. package/src/components/chart/time-axis-label.tsx +56 -2
  14. package/src/components/command-bar/command-bar.test.tsx +1 -1
  15. package/src/components/command-bar/command-bar.tsx +26 -2
  16. package/src/components/command-bar/list-model.ts +8 -4
  17. package/src/components/command-bar/view-model.test.ts +20 -0
  18. package/src/components/command-bar/view-model.ts +15 -3
  19. package/src/components/layout/shell.test.tsx +26 -0
  20. package/src/components/layout/shell.tsx +69 -21
  21. package/src/components/ticker-list-table.tsx +4 -15
  22. package/src/core/state/session-persistence.ts +25 -0
  23. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  24. package/src/market-data/coordinator.test.ts +122 -2
  25. package/src/market-data/coordinator.ts +192 -55
  26. package/src/market-data/hooks.test.tsx +31 -0
  27. package/src/market-data/hooks.tsx +1 -0
  28. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  29. package/src/plugins/builtin/chat-controller.ts +8 -7
  30. package/src/plugins/builtin/chat.test.tsx +55 -0
  31. package/src/plugins/builtin/chat.tsx +20 -6
  32. package/src/plugins/builtin/holders/index.test.ts +30 -1
  33. package/src/plugins/builtin/holders/index.tsx +7 -3
  34. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  35. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  36. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  37. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  38. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  39. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  40. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  41. package/src/plugins/builtin/sectors/index.tsx +30 -9
  42. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  43. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  44. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  45. package/src/plugins/pane-manager.test.ts +31 -0
  46. package/src/plugins/pane-manager.ts +32 -6
  47. package/src/renderers/electrobun/bun/index.ts +83 -3
  48. package/src/renderers/electrobun/shared/protocol.ts +8 -1
  49. package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
  50. package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
  51. package/src/renderers/electrobun/view/main.tsx +38 -24
  52. package/src/renderers/electrobun/view/styles.css +48 -4
  53. package/src/sources/gloomberb-cloud.test.ts +55 -2
  54. package/src/sources/gloomberb-cloud.ts +180 -29
  55. package/src/sources/provider-router.test.ts +567 -10
  56. package/src/sources/provider-router.ts +323 -93
  57. package/src/sources/yahoo-finance.test.ts +37 -0
  58. package/src/sources/yahoo-finance.ts +48 -2
  59. package/src/state/app-bootstrap.test.ts +50 -23
  60. package/src/state/app-bootstrap.ts +26 -10
  61. package/src/state/session-persistence.test.ts +5 -3
  62. package/src/state/use-inline-tickers.ts +3 -0
  63. package/src/state/use-quote-streaming.ts +11 -0
  64. package/src/types/data-provider.ts +5 -1
  65. package/src/types/financials.ts +1 -0
  66. package/src/utils/api-client.test.ts +120 -0
  67. package/src/utils/api-client.ts +66 -19
  68. package/src/utils/exchanges.test.ts +16 -0
  69. package/src/utils/exchanges.ts +33 -0
  70. package/src/utils/options.ts +1 -1
  71. package/src/utils/price-history.test.ts +28 -1
  72. package/src/utils/price-history.ts +16 -0
  73. package/src/utils/quote-freshness.test.ts +150 -0
  74. package/src/utils/quote-freshness.ts +42 -1
  75. package/src/utils/quote-resolution.ts +2 -0
  76. package/src/utils/quote-time.test.ts +34 -0
  77. package/src/utils/quote-time.ts +18 -5
  78. package/src/utils/ticker-search.ts +72 -1
  79. package/src/version.ts +1 -1
  80. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -43,6 +43,7 @@ import {
43
43
  } from "./settings";
44
44
  import { formatPercentRaw } from "../../../utils/format";
45
45
  import { getMostRecentQuoteUpdate } from "../../../utils/quote-time";
46
+ import { isQuoteStaleForCurrentSession } from "../../../utils/quote-freshness";
46
47
  import { priceColor } from "../../../theme/colors";
47
48
  import { PortfolioTickerTable, type QuoteFlashDirection } from "./table";
48
49
  import { useThrottledCursorSymbol } from "./use-throttled-cursor-symbol";
@@ -73,9 +74,8 @@ function visibleWarmupKey(kind: "quote" | "snapshot", ticker: TickerRecord): str
73
74
  return `${kind}:${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
74
75
  }
75
76
 
76
- function needsVisibleQuoteWarmup(ticker: TickerRecord, financials: TickerFinancials | undefined): boolean {
77
- if (ticker.metadata.assetCategory === "OPT") return false;
78
- return !financials?.quote;
77
+ function needsVisibleQuoteWarmup(ticker: TickerRecord, financials: TickerFinancials | undefined, now = Date.now()): boolean {
78
+ return !financials?.quote || isQuoteStaleForCurrentSession(financials.quote, now);
79
79
  }
80
80
 
81
81
  function needsVisibleSnapshotWarmup(
@@ -280,12 +280,12 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
280
280
  const marketFinancialsMap = useTickerFinancialsMap(tickers);
281
281
  const sharedCoordinator = getSharedMarketDataCoordinator();
282
282
  const financialsMap = useMemo(() => {
283
- const merged = sharedCoordinator ? new Map<string, TickerFinancials>() : new Map(cachedFinancials);
283
+ const merged = new Map(cachedFinancials);
284
284
  for (const [symbol, financials] of marketFinancialsMap) {
285
285
  merged.set(symbol, financials);
286
286
  }
287
287
  return merged;
288
- }, [cachedFinancials, marketFinancialsMap, sharedCoordinator]);
288
+ }, [cachedFinancials, marketFinancialsMap]);
289
289
 
290
290
  const accountStateInput = useMemo(() => ({ brokerAccounts, config }), [brokerAccounts, config]);
291
291
  const accountState = usePortfolioAccountState(currentPortfolio, accountStateInput);
@@ -464,11 +464,28 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
464
464
  () => selectStreamTickers(sortedTickers, streamWindow, cursorSymbol),
465
465
  [cursorSymbol, sortedTickers, streamWindow],
466
466
  );
467
+ const priorityStreamSymbols = useMemo(
468
+ () => new Set(streamTickers.map((ticker) => ticker.metadata.ticker)),
469
+ [streamTickers],
470
+ );
471
+ const streamSurface = isPortfolioTab ? "portfolio" : "watchlist";
467
472
  const streamTargets = useMemo(() => (
468
- streamTickers
469
- .map((ticker) => quoteSubscriptionTargetFromTicker(ticker, ticker.metadata.ticker, "provider"))
473
+ sortedTickers
474
+ .map((ticker) => {
475
+ const target = quoteSubscriptionTargetFromTicker(ticker, ticker.metadata.ticker, "provider");
476
+ if (!target) return null;
477
+ const selected = ticker.metadata.ticker === cursorSymbol;
478
+ const visible = priorityStreamSymbols.has(ticker.metadata.ticker);
479
+ return {
480
+ ...target,
481
+ surface: streamSurface,
482
+ visible,
483
+ selected,
484
+ weight: selected ? 100 : visible ? 80 : 10,
485
+ };
486
+ })
470
487
  .filter((target): target is NonNullable<typeof target> => target != null)
471
- ), [streamTickers]);
488
+ ), [cursorSymbol, priorityStreamSymbols, sortedTickers, streamSurface]);
472
489
  const visibleFinancialTickers = useMemo(
473
490
  () => sortedTickers.slice(streamWindow.start, streamWindow.end),
474
491
  [sortedTickers, streamWindow.end, streamWindow.start],
@@ -486,7 +503,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
486
503
  const financials = financialsMap.get(ticker.metadata.ticker);
487
504
  const quoteKey = visibleWarmupKey("quote", ticker);
488
505
  if (
489
- needsVisibleQuoteWarmup(ticker, financials)
506
+ needsVisibleQuoteWarmup(ticker, financials, nowTimestamp)
490
507
  && !warmupInFlightRef.current.has(quoteKey)
491
508
  && nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
492
509
  ) {
@@ -3,6 +3,7 @@ import { existsSync, rmSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { act, useReducer } from "react";
6
+ import { RGBA, TextAttributes } from "@opentui/core";
6
7
  import { testRender } from "../../renderers/opentui/test-utils";
7
8
  import { AppPersistence } from "../../data/app-persistence";
8
9
  import { TickerRepository } from "../../data/ticker-repository";
@@ -18,6 +19,7 @@ import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
18
19
  import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
19
20
  import { PluginRegistry, setSharedMarketDataForTests, setSharedRegistryForTests } from "../registry";
20
21
  import type { BrokerAdapter } from "../../types/broker";
22
+ import { colors } from "../../theme/colors";
21
23
  import { portfolioListPlugin } from "./portfolio-list";
22
24
 
23
25
  const TEST_PANE_ID = "portfolio-list:test";
@@ -316,6 +318,10 @@ async function flushFrame() {
316
318
  });
317
319
  }
318
320
 
321
+ function lineText(line: { spans: Array<{ text: string }> }) {
322
+ return line.spans.map((span) => span.text).join("");
323
+ }
324
+
319
325
  afterEach(async () => {
320
326
  if (testSetup) {
321
327
  testSetup.renderer.destroy();
@@ -1122,10 +1128,11 @@ describe("PortfolioListPane cash and margin UI", () => {
1122
1128
  quote: makeQuote({
1123
1129
  symbol: "AAPL",
1124
1130
  price: 125,
1125
- marketCap: undefined,
1131
+ marketCap: 2_000_000_000,
1126
1132
  }),
1127
1133
  fundamentals: {
1128
1134
  trailingPE: 25,
1135
+ forwardPE: 22,
1129
1136
  },
1130
1137
  profile: {
1131
1138
  sector: "Technology",
@@ -1437,7 +1444,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1437
1444
  expect(frame).toContain("+5.41%");
1438
1445
  });
1439
1446
 
1440
- test("quote flash keeps the existing row background intact", async () => {
1447
+ test("quote flash dims cells without changing quote sign color or row background", async () => {
1441
1448
  const config = createPortfolioConfigWithColumns(
1442
1449
  "broker:ibkr-flex:DU12345",
1443
1450
  ["ticker", "price", "change", "latency"],
@@ -1454,7 +1461,18 @@ describe("PortfolioListPane cash and margin UI", () => {
1454
1461
  ["MSFT", makeTicker({ ticker: "MSFT", name: "Microsoft" })],
1455
1462
  ]);
1456
1463
  state.financials = new Map([
1457
- ["AAPL", { annualStatements: [], quarterlyStatements: [], priceHistory: [], quote: makeQuote() }],
1464
+ ["AAPL", {
1465
+ annualStatements: [],
1466
+ quarterlyStatements: [],
1467
+ priceHistory: [],
1468
+ quote: makeQuote({
1469
+ price: 125,
1470
+ bid: 124.95,
1471
+ ask: 125.05,
1472
+ change: -5,
1473
+ changePercent: -4.17,
1474
+ }),
1475
+ }],
1458
1476
  ["MSFT", {
1459
1477
  annualStatements: [],
1460
1478
  quarterlyStatements: [],
@@ -1483,7 +1501,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1483
1501
 
1484
1502
  await flushFrame();
1485
1503
  const beforeFrame = testSetup.captureSpans();
1486
- const beforeLine = beforeFrame.lines.find((line) => line.spans.map((span) => span.text).join("").includes("AAPL"));
1504
+ const beforeLine = beforeFrame.lines.find((line) => lineText(line).includes("AAPL"));
1487
1505
  expect(beforeLine).toBeDefined();
1488
1506
 
1489
1507
  await act(async () => {
@@ -1494,8 +1512,8 @@ describe("PortfolioListPane cash and margin UI", () => {
1494
1512
  price: 126,
1495
1513
  bid: 125.95,
1496
1514
  ask: 126.05,
1497
- change: 6,
1498
- changePercent: 5,
1515
+ change: -4,
1516
+ changePercent: -3.08,
1499
1517
  lastUpdated: Date.now() + 1_000,
1500
1518
  }),
1501
1519
  });
@@ -1503,11 +1521,15 @@ describe("PortfolioListPane cash and margin UI", () => {
1503
1521
  await flushFrame();
1504
1522
 
1505
1523
  const frame = testSetup.captureSpans();
1506
- const aaplLine = frame.lines.find((line) => line.spans.map((span) => span.text).join("").includes("AAPL"));
1524
+ const aaplLine = frame.lines.find((line) => lineText(line).includes("AAPL"));
1507
1525
  expect(aaplLine).toBeDefined();
1508
1526
 
1509
1527
  const beforeBackgrounds = beforeLine!.spans.map((span) => span.bg.toInts().join(","));
1510
1528
  const afterBackgrounds = aaplLine!.spans.map((span) => span.bg.toInts().join(","));
1511
1529
  expect(afterBackgrounds).toEqual(beforeBackgrounds);
1530
+ const priceSpan = aaplLine!.spans.find((span) => span.text.includes("126"));
1531
+ expect(priceSpan).toBeDefined();
1532
+ expect(priceSpan!.fg.toInts().join(",")).toBe(RGBA.fromHex(colors.negative).toInts().join(","));
1533
+ expect(priceSpan!.attributes & TextAttributes.DIM).not.toBe(0);
1512
1534
  });
1513
1535
  });
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { Box } from "../../../ui";
3
3
  import { DataTableView, Tabs, usePaneFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent } from "../../../components";
4
4
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
5
- import type { PricePoint } from "../../../types/financials";
5
+ import type { PricePoint, Quote } from "../../../types/financials";
6
6
  import { colors, priceColor } from "../../../theme/colors";
7
7
  import { formatCurrency, formatPercentRaw } from "../../../utils/format";
8
8
  import { useAssetData, useDebouncedPluginPaneState, usePluginPaneState, usePluginTickerActions } from "../../plugin-runtime";
@@ -300,14 +300,35 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
300
300
  rows.map((row) => ({ ...row, loading: true }))
301
301
  )));
302
302
 
303
- const fetches = sectorDefs.map(async (sector) => {
303
+ const loadQuotes = async (): Promise<Map<string, Quote | null>> => {
304
+ const quotes = new Map<string, Quote | null>();
305
+ if (dataProvider.getQuotesBatch) {
306
+ const results = await dataProvider.getQuotesBatch(
307
+ sectorDefs.map((sector) => ({ symbol: sector.etf, exchange: "" })),
308
+ ).catch(() => []);
309
+ for (const result of results) {
310
+ quotes.set(result.target.symbol, result.quote ?? null);
311
+ }
312
+ return quotes;
313
+ }
314
+
315
+ await Promise.all(sectorDefs.map(async (sector) => {
316
+ try {
317
+ quotes.set(sector.etf, await dataProvider.getQuote(sector.etf, ""));
318
+ } catch {
319
+ quotes.set(sector.etf, null);
320
+ }
321
+ }));
322
+ return quotes;
323
+ };
324
+
325
+ const fetches = loadQuotes().then((quotesByEtf) => Promise.allSettled(sectorDefs.map(async (sector) => {
304
326
  try {
305
- const [quoteResult, historyResult] = await Promise.allSettled([
306
- dataProvider.getQuote(sector.etf, ""),
307
- dataProvider.getPriceHistory(sector.etf, "", "1Y"),
308
- ]);
327
+ const historyResult = await dataProvider.getPriceHistory(sector.etf, "", "1Y")
328
+ .then((value) => ({ status: "fulfilled" as const, value }))
329
+ .catch(() => ({ status: "rejected" as const }));
309
330
  if (fetchGenRef.current !== gen) return;
310
- const quote = quoteResult.status === "fulfilled" ? quoteResult.value : null;
331
+ const quote = quotesByEtf.get(sector.etf) ?? null;
311
332
  const history = historyResult.status === "fulfilled" ? historyResult.value : [];
312
333
  const historyClose = latestHistoryClose(history);
313
334
  const price = quote?.price ?? historyClose;
@@ -336,9 +357,9 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
336
357
  )
337
358
  )));
338
359
  }
339
- });
360
+ })));
340
361
 
341
- Promise.allSettled(fetches).then(() => {
362
+ fetches.then(() => {
342
363
  if (fetchGenRef.current === gen) {
343
364
  setLastRefreshByCollection((prev) => ({
344
365
  ...prev,
@@ -62,7 +62,24 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
62
62
  const paneInstance = usePaneInstance();
63
63
  const { symbol, ticker, financials } = usePaneTicker();
64
64
  const streamingTarget = quoteSubscriptionTargetFromTicker(ticker, ticker?.metadata.ticker, "provider");
65
- const streamingTargets = useMemo(() => (streamingTarget ? [streamingTarget] : []), [streamingTarget]);
65
+ const streamingTargets = useMemo(() => (
66
+ streamingTarget
67
+ ? [{
68
+ ...streamingTarget,
69
+ surface: "detail" as const,
70
+ visible: true,
71
+ selected: true,
72
+ weight: 100,
73
+ }]
74
+ : []
75
+ ), [
76
+ streamingTarget?.symbol,
77
+ streamingTarget?.exchange,
78
+ streamingTarget?.route,
79
+ streamingTarget?.context?.brokerId,
80
+ streamingTarget?.context?.brokerInstanceId,
81
+ streamingTarget?.context?.instrument,
82
+ ]);
66
83
  useQuoteStreaming(streamingTargets);
67
84
 
68
85
  const { collectionId } = usePaneCollection();
@@ -371,7 +371,10 @@ export function QuoteMonitorPane({ paneId, focused, width, height }: PaneProps)
371
371
  symbols
372
372
  .map((symbol) => {
373
373
  const ticker = tickersBySymbol.get(symbol) ?? (fallbackTicker?.metadata.ticker === symbol ? fallbackTicker : null);
374
- return quoteSubscriptionTargetFromTicker(ticker, symbol, "provider");
374
+ const target = quoteSubscriptionTargetFromTicker(ticker, symbol, "provider");
375
+ return target
376
+ ? { ...target, surface: "monitor" as const, visible: true, selected: symbol === fallbackSymbol, weight: symbol === fallbackSymbol ? 100 : 90 }
377
+ : null;
375
378
  })
376
379
  .filter((target): target is QuoteSubscriptionTarget => target != null)
377
380
  ), [fallbackTicker, symbols, tickersBySymbol]);
@@ -183,31 +183,70 @@ function WorldIndicesPane({ focused, width, height }: PaneProps) {
183
183
  fetchGenRef.current += 1;
184
184
  const gen = fetchGenRef.current;
185
185
 
186
- for (const entry of WORLD_INDICES) {
187
- setQuotes((prev) => {
188
- const next = new Map(prev);
186
+ setQuotes((prev) => {
187
+ const next = new Map(prev);
188
+ for (const entry of WORLD_INDICES) {
189
189
  const existing = next.get(entry.symbol);
190
190
  next.set(entry.symbol, { quote: existing?.quote ?? null, loading: true, error: null });
191
+ }
192
+ return next;
193
+ });
194
+
195
+ const loadQuotes = async (): Promise<QuoteMap> => {
196
+ const next = new Map<string, IndexQuoteState>();
197
+ if (dataProvider.getQuotesBatch) {
198
+ const results = await dataProvider.getQuotesBatch(
199
+ WORLD_INDICES.map((entry) => ({ symbol: entry.symbol, exchange: "" })),
200
+ );
201
+ const bySymbol = new Map(results.map((result) => [result.target.symbol, result]));
202
+ for (const entry of WORLD_INDICES) {
203
+ const result = bySymbol.get(entry.symbol);
204
+ next.set(entry.symbol, {
205
+ quote: result?.quote ?? null,
206
+ loading: false,
207
+ error: result?.error
208
+ ? result.error instanceof Error ? result.error.message : String(result.error)
209
+ : null,
210
+ });
211
+ }
191
212
  return next;
192
- });
213
+ }
193
214
 
194
- dataProvider.getQuote(entry.symbol, "").then((quote) => {
195
- if (fetchGenRef.current !== gen) return;
196
- setQuotes((prev) => {
197
- const next = new Map(prev);
215
+ await Promise.all(WORLD_INDICES.map(async (entry) => {
216
+ try {
217
+ const quote = await dataProvider.getQuote(entry.symbol, "");
198
218
  next.set(entry.symbol, { quote, loading: false, error: null });
199
- return next;
200
- });
201
- }).catch((err: unknown) => {
202
- if (fetchGenRef.current !== gen) return;
203
- const msg = err instanceof Error ? err.message : String(err);
204
- setQuotes((prev) => {
205
- const next = new Map(prev);
206
- next.set(entry.symbol, { quote: null, loading: false, error: msg });
207
- return next;
208
- });
219
+ } catch (err: unknown) {
220
+ next.set(entry.symbol, {
221
+ quote: null,
222
+ loading: false,
223
+ error: err instanceof Error ? err.message : String(err),
224
+ });
225
+ }
226
+ }));
227
+ return next;
228
+ };
229
+
230
+ loadQuotes().then((nextQuotes) => {
231
+ if (fetchGenRef.current !== gen) return;
232
+ setQuotes((prev) => {
233
+ const next = new Map(prev);
234
+ for (const [symbol, state] of nextQuotes) {
235
+ next.set(symbol, state);
236
+ }
237
+ return next;
209
238
  });
210
- }
239
+ }).catch((err: unknown) => {
240
+ if (fetchGenRef.current !== gen) return;
241
+ const message = err instanceof Error ? err.message : String(err);
242
+ setQuotes((prev) => {
243
+ const next = new Map(prev);
244
+ for (const entry of WORLD_INDICES) {
245
+ next.set(entry.symbol, { quote: null, loading: false, error: message });
246
+ }
247
+ return next;
248
+ });
249
+ });
211
250
  }, [dataProvider]);
212
251
 
213
252
  useEffect(() => {
@@ -5,6 +5,8 @@ import {
5
5
  applyDrop,
6
6
  floatAtRect,
7
7
  gridlockAllPanes,
8
+ getDockDividerLayouts,
9
+ getDockLeafLayouts,
8
10
  getDockedPaneIds,
9
11
  getLeafRect,
10
12
  simulateDrop,
@@ -44,6 +46,35 @@ describe("pane-manager split-tree drops", () => {
44
46
  expect(getDockedPaneIds(next)).toContain(notesPane.instanceId);
45
47
  });
46
48
 
49
+ test("can reserve a gutter for dock dividers without overlapping pane rects", () => {
50
+ const layout = {
51
+ dockRoot: {
52
+ kind: "split" as const,
53
+ axis: "horizontal" as const,
54
+ ratio: 0.5,
55
+ first: { kind: "pane" as const, instanceId: "left:main" },
56
+ second: { kind: "pane" as const, instanceId: "right:main" },
57
+ },
58
+ instances: [
59
+ createPaneInstance("left", { instanceId: "left:main" }),
60
+ createPaneInstance("right", { instanceId: "right:main" }),
61
+ ],
62
+ floating: [],
63
+ detached: [],
64
+ };
65
+
66
+ const leaves = getDockLeafLayouts(layout, BOUNDS, { reserveDividerGutters: true });
67
+ const divider = getDockDividerLayouts(layout, BOUNDS, { reserveDividerGutters: true })[0];
68
+ const left = leaves.find((leaf) => leaf.instanceId === "left:main");
69
+ const right = leaves.find((leaf) => leaf.instanceId === "right:main");
70
+
71
+ expect(left).toBeDefined();
72
+ expect(right).toBeDefined();
73
+ expect(divider).toBeDefined();
74
+ expect(divider!.rect.x).toBe(left!.rect.x + left!.rect.width);
75
+ expect(right!.rect.x).toBe(divider!.rect.x + divider!.rect.width);
76
+ });
77
+
47
78
  test("splits the hovered pane on leaf drops and matches the preview", () => {
48
79
  const config = createDefaultConfig("/tmp/gloomberb-test");
49
80
  const notesPane = createPaneInstance("chat");
@@ -84,6 +84,7 @@ export interface DockDividerLayout {
84
84
  export interface DockGeometryOptions {
85
85
  precise?: boolean;
86
86
  dividerSize?: number;
87
+ reserveDividerGutters?: boolean;
87
88
  }
88
89
 
89
90
  export interface LayoutSimulation {
@@ -430,18 +431,31 @@ function collectDockGeometry(
430
431
 
431
432
  const precise = options.precise === true;
432
433
  const dividerSize = options.dividerSize ?? 1;
434
+ const reserveDividerGutters = options.reserveDividerGutters === true && !precise;
433
435
 
434
436
  if (node.axis === "horizontal") {
435
- const [firstWidth, secondWidth] = resolveSplitSizes(bounds.width, node.ratio, MIN_PANE_WIDTH, precise);
437
+ const reserveDividerGutter = reserveDividerGutters && bounds.width > dividerSize;
438
+ const splitWidth = reserveDividerGutter ? bounds.width - dividerSize : bounds.width;
439
+ const [firstWidth, secondWidth] = resolveSplitSizes(splitWidth, node.ratio, MIN_PANE_WIDTH, precise);
436
440
  const firstBounds = { x: bounds.x, y: bounds.y, width: firstWidth, height: bounds.height };
437
- const secondBounds = { x: bounds.x + firstWidth, y: bounds.y, width: secondWidth, height: bounds.height };
441
+ const dividerX = reserveDividerGutter
442
+ ? bounds.x + firstWidth
443
+ : precise
444
+ ? bounds.x + firstWidth - (dividerSize / 2)
445
+ : bounds.x + firstWidth - 1;
446
+ const secondBounds = {
447
+ x: reserveDividerGutter ? bounds.x + firstWidth + dividerSize : bounds.x + firstWidth,
448
+ y: bounds.y,
449
+ width: secondWidth,
450
+ height: bounds.height,
451
+ };
438
452
  dividers.push({
439
453
  path,
440
454
  axis: node.axis,
441
455
  bounds: { ...bounds },
442
456
  ratio: node.ratio,
443
457
  rect: {
444
- x: precise ? bounds.x + firstWidth - (dividerSize / 2) : bounds.x + firstWidth - 1,
458
+ x: dividerX,
445
459
  y: bounds.y,
446
460
  width: dividerSize,
447
461
  height: bounds.height,
@@ -452,9 +466,21 @@ function collectDockGeometry(
452
466
  return { leaves, dividers };
453
467
  }
454
468
 
455
- const [firstHeight, secondHeight] = resolveSplitSizes(bounds.height, node.ratio, MIN_DOCKED_HEIGHT, precise);
469
+ const reserveDividerGutter = reserveDividerGutters && bounds.height > dividerSize;
470
+ const splitHeight = reserveDividerGutter ? bounds.height - dividerSize : bounds.height;
471
+ const [firstHeight, secondHeight] = resolveSplitSizes(splitHeight, node.ratio, MIN_DOCKED_HEIGHT, precise);
456
472
  const firstBounds = { x: bounds.x, y: bounds.y, width: bounds.width, height: firstHeight };
457
- const secondBounds = { x: bounds.x, y: bounds.y + firstHeight, width: bounds.width, height: secondHeight };
473
+ const dividerY = reserveDividerGutter
474
+ ? bounds.y + firstHeight
475
+ : precise
476
+ ? bounds.y + firstHeight - (dividerSize / 2)
477
+ : bounds.y + firstHeight - 1;
478
+ const secondBounds = {
479
+ x: bounds.x,
480
+ y: reserveDividerGutter ? bounds.y + firstHeight + dividerSize : bounds.y + firstHeight,
481
+ width: bounds.width,
482
+ height: secondHeight,
483
+ };
458
484
  dividers.push({
459
485
  path,
460
486
  axis: node.axis,
@@ -462,7 +488,7 @@ function collectDockGeometry(
462
488
  ratio: node.ratio,
463
489
  rect: {
464
490
  x: bounds.x,
465
- y: precise ? bounds.y + firstHeight - (dividerSize / 2) : bounds.y + firstHeight - 1,
491
+ y: dividerY,
466
492
  width: bounds.width,
467
493
  height: dividerSize,
468
494
  },
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from "fs";
2
- import { join } from "path";
2
+ import { dirname, join } from "path";
3
3
  import { Buffer } from "node:buffer";
4
4
  import Electrobun, { ApplicationMenu, BrowserView, BrowserWindow, Utils, ContextMenu, type UpdateStatusEntry } from "electrobun/bun";
5
5
  import {
@@ -17,7 +17,7 @@ import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemeP
17
17
  import type { ReleaseInfo, UpdateCheckResult, UpdateProgress } from "../../../updater";
18
18
  import { buildSoundCommand } from "../../../notifications/app-notifier";
19
19
  import { isPaneDetached } from "../../../plugins/pane-manager";
20
- import { ELECTROBUN_CONTEXT_MENU_ACTION, type ElectrobunDesktopRpcSchema } from "../shared/protocol";
20
+ import { ELECTROBUN_CONTEXT_MENU_ACTION, type DesktopRestartMessage, type ElectrobunDesktopRpcSchema } from "../shared/protocol";
21
21
  import { decodeRpcValue, encodeRpcValue } from "../view/rpc-codec";
22
22
  import { contextMenuSelectionMessage } from "./context-menu-click";
23
23
  import { getContextMenuRequestId, normalizeContextMenuItems } from "./context-menu-normalize";
@@ -65,6 +65,7 @@ let desktopWorkspace: DesktopWorkspace | null = null;
65
65
  let currentDockPreview: DesktopDockPreviewState = { paneId: null, edge: null };
66
66
  let currentThemePreview: DesktopThemePreviewState = { theme: null };
67
67
  let desktopUpdateInProgress = false;
68
+ let desktopRestartInProgress = false;
68
69
 
69
70
  const detachedWindows = new Map<string, BrowserWindow>();
70
71
  const detachedFrameTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -82,6 +83,19 @@ function summarizeError(error: unknown): string {
82
83
  return error instanceof Error ? error.stack ?? error.message : String(error);
83
84
  }
84
85
 
86
+ function shellQuote(value: string): string {
87
+ return `'${value.replaceAll("'", "'\\''")}'`;
88
+ }
89
+
90
+ function findMacAppBundle(execPath: string): string | null {
91
+ let current = dirname(execPath);
92
+ while (current && current !== dirname(current)) {
93
+ if (current.endsWith(".app")) return current;
94
+ current = dirname(current);
95
+ }
96
+ return null;
97
+ }
98
+
85
99
  function requireServices(): AppServices {
86
100
  if (!services) throw new Error("Backend services have not been initialized.");
87
101
  return services;
@@ -633,6 +647,62 @@ function teardownServices(): void {
633
647
  services = null;
634
648
  }
635
649
 
650
+ function spawnDetached(command: string[]): void {
651
+ const child = Bun.spawn(command, {
652
+ detached: true,
653
+ stdio: ["ignore", "ignore", "ignore"],
654
+ } as any);
655
+ child.unref();
656
+ }
657
+
658
+ function scheduleDesktopRelaunch(): void {
659
+ const pid = process.pid;
660
+ const macAppBundle = process.platform === "darwin" ? findMacAppBundle(process.execPath) : null;
661
+
662
+ if (macAppBundle) {
663
+ spawnDetached([
664
+ "sh",
665
+ "-c",
666
+ `while kill -0 ${pid} 2>/dev/null; do sleep 0.25; done; sleep 0.5; open ${shellQuote(macAppBundle)}`,
667
+ ]);
668
+ return;
669
+ }
670
+
671
+ if (process.platform === "win32") {
672
+ spawnDetached([process.execPath, ...process.argv.slice(1)]);
673
+ return;
674
+ }
675
+
676
+ const args = process.argv.slice(1).map(shellQuote).join(" ");
677
+ spawnDetached([
678
+ "sh",
679
+ "-c",
680
+ `while kill -0 ${pid} 2>/dev/null; do sleep 0.25; done; cd ${shellQuote(process.cwd())}; ${shellQuote(process.execPath)}${args ? ` ${args}` : ""}`,
681
+ ]);
682
+ }
683
+
684
+ function restartDesktopApp(message: DesktopRestartMessage = {}): void {
685
+ if (desktopRestartInProgress) return;
686
+ desktopRestartInProgress = true;
687
+ console.error("[desktop-recovery] restart requested", {
688
+ reason: message.reason,
689
+ source: message.source,
690
+ pid: process.pid,
691
+ execPath: process.execPath,
692
+ argv: process.argv,
693
+ });
694
+ try {
695
+ scheduleDesktopRelaunch();
696
+ } catch (error) {
697
+ desktopRestartInProgress = false;
698
+ console.error("[desktop-recovery] failed to schedule restart", summarizeError(error));
699
+ throw error;
700
+ }
701
+ closeAllDetachedWindows();
702
+ teardownServices();
703
+ Utils.quit();
704
+ }
705
+
636
706
  function resolveDetachedWindowTitle(instanceId: string): string {
637
707
  const instance = currentConfig ? findPaneInstance(currentConfig.layout, instanceId) : null;
638
708
  if (!instance) return "Gloomberb";
@@ -1087,6 +1157,12 @@ async function handleBackendRequest(
1087
1157
  requireServices().persistence.pluginState.delete(payload.pluginId as string, payload.key as string);
1088
1158
  syncBackendCloudAuthState(payload.pluginId as string, payload.key as string, null);
1089
1159
  return null;
1160
+ case "host.restart":
1161
+ restartDesktopApp({
1162
+ reason: typeof payload.reason === "string" ? payload.reason : undefined,
1163
+ source: typeof payload.source === "string" ? payload.source : "backend-request",
1164
+ });
1165
+ return null;
1090
1166
  case "host.exit":
1091
1167
  closeAllDetachedWindows();
1092
1168
  teardownServices();
@@ -1153,7 +1229,11 @@ function createWindowRpc(key: string): DesktopRpc {
1153
1229
  requests: {
1154
1230
  "backend.request": async ({ method, payload }) => encodeRpcValue(await handleBackendRequest(rpc, method, payload)),
1155
1231
  },
1156
- messages: {},
1232
+ messages: {
1233
+ "host.restart": (message) => {
1234
+ restartDesktopApp(message);
1235
+ },
1236
+ },
1157
1237
  },
1158
1238
  });
1159
1239
  registerWindowRpc(key, rpc);
@@ -53,6 +53,11 @@ export interface CapabilityEventMessage {
53
53
  event: unknown;
54
54
  }
55
55
 
56
+ export interface DesktopRestartMessage {
57
+ reason?: string;
58
+ source?: string;
59
+ }
60
+
56
61
  export interface ElectrobunDesktopRpcSchema {
57
62
  bun: {
58
63
  requests: {
@@ -61,7 +66,9 @@ export interface ElectrobunDesktopRpcSchema {
61
66
  response: unknown;
62
67
  };
63
68
  };
64
- messages: {};
69
+ messages: {
70
+ "host.restart": DesktopRestartMessage;
71
+ };
65
72
  };
66
73
  webview: {
67
74
  requests: {};
@@ -5,6 +5,7 @@ import {
5
5
  type CapabilityEventMessage,
6
6
  type ContextMenuSelectMessage,
7
7
  type DesktopDockPreviewMessage,
8
+ type DesktopRestartMessage,
8
9
  type DesktopStateMessage,
9
10
  type DesktopThemePreviewMessage,
10
11
  type ElectrobunBackendInit,
@@ -131,6 +132,10 @@ export async function initElectrobunBackend(payload?: { kind?: "main" | "detache
131
132
  return initSnapshot;
132
133
  }
133
134
 
135
+ export function requestElectrobunRestart(message: DesktopRestartMessage = {}): void {
136
+ rpc.send["host.restart"](message);
137
+ }
138
+
134
139
  export function getElectrobunBackendInitSnapshot(): ElectrobunBackendInit | null {
135
140
  return initSnapshot;
136
141
  }