gloomberb 0.12.0 → 0.13.2
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 +2 -0
- package/package.json +1 -2
- package/src/api-client/auth.ts +2 -1
- package/src/api-client/index.test.ts +34 -0
- package/src/api-client/index.ts +10 -2
- package/src/api-client/request.ts +4 -1
- package/src/api-client/research-activity.ts +61 -0
- package/src/api-client/types.ts +2 -1
- package/src/cli/commands/plugins.ts +4 -3
- package/src/cli/desktop-pane-shot.ts +79 -10
- package/src/cli/pane-functions/capabilities.ts +76 -23
- package/src/cli/pane-functions/cloud-session.test.ts +54 -0
- package/src/cli/pane-functions/cloud-session.ts +38 -0
- package/src/cli/pane-functions/dom.test.ts +41 -0
- package/src/cli/pane-functions/dom.ts +199 -0
- package/src/cli/pane-functions/index.test.ts +13 -2
- package/src/cli/pane-functions/index.ts +9 -5
- package/src/cli/pane-functions/intraday-shot.test.ts +307 -0
- package/src/cli/pane-functions/intraday-shot.ts +334 -0
- package/src/cli/pane-functions/report.test.ts +29 -1
- package/src/cli/pane-functions/report.ts +68 -7
- package/src/cli/pane-functions/screenshot.ts +195 -42
- package/src/cli/restore-plugins.ts +10 -3
- package/src/components/chart/composite/composite-chart.test.tsx +68 -1
- package/src/components/chart/composite/composite-chart.tsx +87 -21
- package/src/components/chart/composite/rasterizer.ts +33 -0
- package/src/components/chart/composite/renderers.test.ts +51 -4
- package/src/components/chart/composite/scene.test.ts +128 -2
- package/src/components/chart/composite/scene.ts +92 -16
- package/src/components/chart/composite/text-renderer.ts +13 -0
- package/src/components/chart/composite/time-scale.ts +30 -10
- package/src/components/chart/composite/types.ts +26 -0
- package/src/components/command-bar/assist/runtime.ts +38 -30
- package/src/components/command-bar/surface/assist.test.tsx +63 -9
- package/src/components/command-bar/surface/test-harness.tsx +23 -4
- package/src/components/onboarding/account-step/chooser-panel.tsx +2 -2
- package/src/components/onboarding/account-step/qr-panel.tsx +2 -10
- package/src/components/onboarding/onboarding-wizard.test.tsx +38 -12
- package/src/components/onboarding/onboarding-wizard.tsx +49 -14
- package/src/data/config/store/index.test.ts +23 -0
- package/src/data/config/store/node.ts +7 -1
- package/src/data/config/store/normalize.ts +4 -0
- package/src/news/types.ts +1 -0
- package/src/plugins/builtin/cds/client.test.ts +20 -0
- package/src/plugins/builtin/cds/client.ts +13 -3
- package/src/plugins/builtin/cds/headless.test.ts +30 -0
- package/src/plugins/builtin/cds/headless.ts +113 -0
- package/src/plugins/builtin/cds/index.tsx +4 -0
- package/src/plugins/builtin/chart-composer/cli-options.ts +4 -1
- package/src/plugins/builtin/chart-composer/index.tsx +1 -1
- package/src/plugins/builtin/chat/controller/channels.ts +2 -1
- package/src/plugins/builtin/chat/controller/index.ts +13 -5
- package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
- package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
- package/src/plugins/builtin/chat/controller/state.ts +1 -0
- package/src/plugins/builtin/chat/controller.test.ts +260 -4
- package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
- package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
- package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
- package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
- package/src/plugins/builtin/cloud/askg/protocol.test.ts +190 -0
- package/src/plugins/builtin/cloud/askg/protocol.ts +286 -0
- package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
- package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
- package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
- package/src/plugins/builtin/cloud/auth-dialog.tsx +7 -1
- package/src/plugins/builtin/cloud/device-signin-dialog.tsx +21 -8
- package/src/plugins/builtin/cloud/plugin.tsx +2 -0
- package/src/plugins/builtin/cloud/verification-panel.tsx +56 -0
- package/src/plugins/builtin/congress-trades/client.ts +11 -0
- package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
- package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
- package/src/plugins/builtin/congress-trades/headless.ts +145 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
- package/src/plugins/builtin/credit-conditions/client.ts +1 -1
- package/src/plugins/builtin/credit-conditions/headless.test.ts +31 -0
- package/src/plugins/builtin/credit-conditions/headless.ts +55 -0
- package/src/plugins/builtin/credit-conditions/index.tsx +4 -0
- package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
- package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
- package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
- package/src/plugins/builtin/dividend-yield/model.ts +4 -22
- package/src/plugins/builtin/dividend-yield/view.ts +17 -0
- package/src/plugins/builtin/earnings/headless.test.ts +82 -0
- package/src/plugins/builtin/earnings/headless.ts +162 -0
- package/src/plugins/builtin/earnings/index.tsx +2 -0
- package/src/plugins/builtin/earnings-calls/data.ts +29 -8
- package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
- package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
- package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
- package/src/plugins/builtin/earnings-calls/model.ts +238 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +4 -0
- package/src/plugins/builtin/earnings-calls/transcript-view.tsx +13 -11
- package/src/plugins/builtin/fear-greed/headless.test.ts +53 -0
- package/src/plugins/builtin/fear-greed/headless.ts +84 -0
- package/src/plugins/builtin/fear-greed/index.tsx +4 -0
- package/src/plugins/builtin/holders/client.ts +29 -0
- package/src/plugins/builtin/holders/headless.test.ts +58 -0
- package/src/plugins/builtin/holders/headless.ts +162 -0
- package/src/plugins/builtin/holders/index.tsx +14 -8
- package/src/plugins/builtin/holders/pane.tsx +7 -1
- package/src/plugins/builtin/insider/client.ts +50 -0
- package/src/plugins/builtin/insider/headless.test.ts +122 -0
- package/src/plugins/builtin/insider/headless.ts +117 -0
- package/src/plugins/builtin/insider/index.tsx +21 -50
- package/src/plugins/builtin/insider/insider-data.ts +1 -1
- package/src/plugins/builtin/insider/model.ts +72 -0
- package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
- package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
- package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
- package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
- package/src/plugins/builtin/market-movers/client.ts +76 -0
- package/src/plugins/builtin/market-movers/headless.test.ts +34 -0
- package/src/plugins/builtin/market-movers/headless.ts +73 -0
- package/src/plugins/builtin/market-movers/index.tsx +11 -51
- package/src/plugins/builtin/news/headless.test.ts +138 -0
- package/src/plugins/builtin/news/headless.ts +190 -0
- package/src/plugins/builtin/news/index.tsx +13 -9
- package/src/plugins/builtin/news/wire/index.tsx +2 -1
- package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
- package/src/plugins/builtin/research/analyst-headless.ts +172 -0
- package/src/plugins/builtin/research/analyst-model.ts +219 -0
- package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
- package/src/plugins/builtin/research/client.ts +12 -0
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +3 -237
- package/src/plugins/builtin/research/event-model.ts +245 -0
- package/src/plugins/builtin/research/events-headless.test.ts +32 -0
- package/src/plugins/builtin/research/events-headless.ts +87 -0
- package/src/plugins/builtin/research/headless.test.ts +93 -0
- package/src/plugins/builtin/research/headless.ts +161 -0
- package/src/plugins/builtin/research/index.tsx +32 -18
- package/src/plugins/builtin/research-search/pane.tsx +4 -0
- package/src/plugins/builtin/scanner/headless.test.ts +59 -0
- package/src/plugins/builtin/scanner/headless.ts +125 -0
- package/src/plugins/builtin/scanner/index.ts +4 -0
- package/src/plugins/builtin/sec/client.ts +16 -0
- package/src/plugins/builtin/sec/headless.test.ts +72 -0
- package/src/plugins/builtin/sec/headless.ts +69 -0
- package/src/plugins/builtin/sec/index.tsx +20 -75
- package/src/plugins/builtin/sec/model.ts +89 -0
- package/src/plugins/builtin/sectors/client.ts +54 -0
- package/src/plugins/builtin/sectors/headless.test.ts +28 -0
- package/src/plugins/builtin/sectors/headless.ts +80 -0
- package/src/plugins/builtin/sectors/index.tsx +6 -51
- package/src/plugins/builtin/shared/cloud-upgrade.ts +8 -4
- package/src/plugins/builtin/short-interest/client.ts +5 -2
- package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
- package/src/plugins/builtin/short-interest/headless.ts +111 -0
- package/src/plugins/builtin/short-interest/index.tsx +15 -9
- package/src/plugins/builtin/thirteenf/data.ts +13 -11
- package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
- package/src/plugins/builtin/thirteenf/headless.ts +286 -0
- package/src/plugins/builtin/thirteenf/index.tsx +4 -0
- package/src/plugins/builtin/thirteenf/model.ts +13 -0
- package/src/plugins/builtin/thirteenf/table.ts +3 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +14 -0
- package/src/plugins/builtin/treasury-auctions/headless.test.ts +34 -0
- package/src/plugins/builtin/treasury-auctions/headless.ts +95 -0
- package/src/plugins/builtin/treasury-auctions/index.tsx +4 -0
- package/src/plugins/builtin/volatility/client.ts +16 -3
- package/src/plugins/builtin/volatility/headless.test.ts +35 -0
- package/src/plugins/builtin/volatility/headless.ts +75 -0
- package/src/plugins/builtin/volatility/index.tsx +4 -0
- package/src/plugins/builtin/world-indices/client.ts +43 -0
- package/src/plugins/builtin/world-indices/headless.test.ts +32 -0
- package/src/plugins/builtin/world-indices/headless.ts +109 -0
- package/src/plugins/builtin/world-indices/index.tsx +4 -0
- package/src/plugins/builtin/yield-curve/headless.test.ts +24 -0
- package/src/plugins/builtin/yield-curve/headless.ts +67 -0
- package/src/plugins/builtin/yield-curve/index.tsx +6 -8
- package/src/plugins/builtin/yield-curve/treasury-data.ts +16 -6
- package/src/plugins/bundle.test.ts +58 -1
- package/src/plugins/bundle.ts +4 -2
- package/src/plugins/catalog-backend.test.ts +26 -0
- package/src/plugins/catalog-backend.ts +9 -2
- package/src/plugins/catalog-browser.test.ts +0 -13
- package/src/plugins/catalog-browser.ts +4 -0
- package/src/plugins/catalog-manifest.test.ts +15 -0
- package/src/plugins/catalog-manifest.ts +12 -5
- package/src/plugins/catalog-ui.ts +0 -6
- package/src/plugins/host-contract.ts +9 -0
- package/src/plugins/host-modules.ts +12 -0
- package/src/plugins/loader.ts +63 -15
- package/src/plugins/prediction-markets/headless.test.ts +119 -0
- package/src/plugins/prediction-markets/headless.ts +255 -0
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/plugins/seed.ts +3 -0
- package/src/public/broker.ts +6 -0
- package/src/remote/schema.test.ts +65 -0
- package/src/remote/schema.ts +322 -39
- package/src/remote/types.ts +49 -2
- package/src/renderers/browser/app-services.ts +3 -0
- package/src/renderers/browser/config-host.ts +24 -8
- package/src/renderers/browser/deeplink-bridge.test.ts +11 -0
- package/src/renderers/browser/deeplink-bridge.ts +15 -3
- package/src/renderers/browser/main.tsx +3 -1
- package/src/renderers/browser/research-entry.test.ts +8 -0
- package/src/renderers/browser/research-entry.ts +7 -0
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +2 -2
- package/src/renderers/electrobun/bun/desktop/initialization.ts +10 -3
- package/src/renderers/electrobun/bun/external-plugins.ts +20 -4
- package/src/renderers/electrobun/view/build-assets.ts +0 -2
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +63 -10
- package/src/sources/gloomberb-cloud/news.ts +1 -0
- package/src/sync/core-contributors.test.ts +111 -0
- package/src/sync/core-contributors.ts +155 -1
- package/src/time-series/resolve.test.ts +118 -1
- package/src/time-series/resolve.ts +14 -16
- package/src/types/config.ts +2 -0
- package/src/plugins/broker-sync/broker-sync.test.ts +0 -188
- package/src/plugins/broker-sync/native-loader.ts +0 -10
- package/src/plugins/broker-sync/normalize.ts +0 -308
- package/src/plugins/broker-sync/public.ts +0 -122
- package/src/plugins/broker-sync/robinhood-native.ts +0 -367
- package/src/plugins/broker-sync/robinhood.ts +0 -114
- package/src/plugins/broker-sync/simplefin-native.ts +0 -201
- package/src/plugins/broker-sync/simplefin.ts +0 -70
- package/src/renderers/electrobun/view/native-stubs/broker-sync-native-loader.ts +0 -7
|
@@ -16,9 +16,11 @@ import type {
|
|
|
16
16
|
} from "./types";
|
|
17
17
|
import {
|
|
18
18
|
buildCompositeTimeScale,
|
|
19
|
+
COMPOSITE_RIGHT_OFFSET_RATIO,
|
|
19
20
|
projectCompositeTimestamp,
|
|
21
|
+
unprojectCompositeTimestamp,
|
|
20
22
|
} from "./time-scale";
|
|
21
|
-
import type { CompositeTimeScale } from "./types";
|
|
23
|
+
import type { CompositeLastPriceMarker, CompositeTimeScale } from "./types";
|
|
22
24
|
|
|
23
25
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
24
26
|
|
|
@@ -331,7 +333,8 @@ function projectSeries(
|
|
|
331
333
|
series: ResolvedSeries,
|
|
332
334
|
domain: CompositeAxisDomain,
|
|
333
335
|
startTime: number,
|
|
334
|
-
|
|
336
|
+
/** Where a step series holds its last level to, never past the newest bar. */
|
|
337
|
+
stepEndTime: number,
|
|
335
338
|
timeScale: CompositeTimeScale,
|
|
336
339
|
): CompositeProjectedPoint[] {
|
|
337
340
|
const projected: CompositeProjectedPoint[] = [];
|
|
@@ -372,16 +375,74 @@ function projectSeries(
|
|
|
372
375
|
if (stepSeries && projected.length > 0) {
|
|
373
376
|
const last = projected.at(-1)!;
|
|
374
377
|
const trailingGap = normalizedSourcePoints(series, timeScale).some(({ timestamp, value }) => (
|
|
375
|
-
timestamp > last.timestamp && timestamp <=
|
|
378
|
+
timestamp > last.timestamp && timestamp <= stepEndTime
|
|
376
379
|
&& (value === null || projectCompositeValue(value, domain) === null)
|
|
377
380
|
));
|
|
378
|
-
|
|
379
|
-
|
|
381
|
+
// The synthetic tail is a level, not an observation, so it lands on its own
|
|
382
|
+
// timestamp rather than snapping forward to the next trading slot.
|
|
383
|
+
const trailingRatio = projectCompositeTimestamp(timeScale, stepEndTime)?.ratio;
|
|
384
|
+
if (last.timestamp < stepEndTime && !trailingGap && trailingRatio !== undefined) {
|
|
385
|
+
projected.push({
|
|
386
|
+
...last,
|
|
387
|
+
timestamp: stepEndTime,
|
|
388
|
+
xRatio: Math.min(trailingRatio, 1),
|
|
389
|
+
xSlot: undefined,
|
|
390
|
+
breakBefore: false,
|
|
391
|
+
});
|
|
380
392
|
}
|
|
381
393
|
}
|
|
382
394
|
return projected;
|
|
383
395
|
}
|
|
384
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Unit groups a price can wear, raw or transformed. Volume, oscillators and
|
|
399
|
+
* other derived study outputs are deliberately absent: a level line there
|
|
400
|
+
* would mark a quantity that has no last price to read.
|
|
401
|
+
*/
|
|
402
|
+
const PRICE_UNIT_GROUPS = /^(?:price|currency|percent|index|log)(?::|$)/;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The chart's primary price series: the first exchange-traded price, in
|
|
406
|
+
* authored order. Study outputs are appended after the series they derive
|
|
407
|
+
* from, so an overlay indicator never wins over the price it follows.
|
|
408
|
+
*/
|
|
409
|
+
function primaryPriceSeries(series: readonly ResolvedSeries[]): ResolvedSeries | null {
|
|
410
|
+
return series.find((entry) => (
|
|
411
|
+
entry.timeBasis?.kind === "market"
|
|
412
|
+
&& PRICE_UNIT_GROUPS.test(entry.unitGroup.toLowerCase())
|
|
413
|
+
)) ?? null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function lastCloseOf(points: readonly CompositeProjectedPoint[]): number | null {
|
|
417
|
+
const last = points.at(-1);
|
|
418
|
+
if (!last) return null;
|
|
419
|
+
return finiteNumber(last.point.close) ? last.point.close : last.value;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Marks the newest close of the primary price series on the panel that draws it. */
|
|
423
|
+
function attachLastPriceMarker(
|
|
424
|
+
panels: CompositePanelScene[],
|
|
425
|
+
series: readonly ResolvedSeries[],
|
|
426
|
+
): void {
|
|
427
|
+
const primary = primaryPriceSeries(series);
|
|
428
|
+
if (!primary) return;
|
|
429
|
+
const panel = panels.find((entry) => entry.id === primary.panelId);
|
|
430
|
+
const projected = panel?.series.find((entry) => entry.source.id === primary.id);
|
|
431
|
+
const domain = panel?.axes[primary.axis];
|
|
432
|
+
if (!panel || !projected || !domain) return;
|
|
433
|
+
const value = lastCloseOf(projected.points);
|
|
434
|
+
if (value === null) return;
|
|
435
|
+
const yRatio = projectCompositeValue(value, domain);
|
|
436
|
+
if (yRatio === null) return;
|
|
437
|
+
panel.lastPrice = {
|
|
438
|
+
seriesId: primary.id,
|
|
439
|
+
color: primary.color,
|
|
440
|
+
axis: primary.axis,
|
|
441
|
+
value,
|
|
442
|
+
yRatio,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
385
446
|
function nearestDate(dates: Date[], requested: Date): Date | null {
|
|
386
447
|
const target = requested.getTime();
|
|
387
448
|
if (!Number.isFinite(target) || dates.length === 0) return null;
|
|
@@ -483,7 +544,16 @@ export function buildCompositeChartScene(
|
|
|
483
544
|
timelineSeries,
|
|
484
545
|
startTime,
|
|
485
546
|
endTime,
|
|
547
|
+
options.rightOffsetRatio ?? COMPOSITE_RIGHT_OFFSET_RATIO,
|
|
486
548
|
);
|
|
549
|
+
// The plot reaches past the viewport by the reserved right offset. Panned
|
|
550
|
+
// back into history that space holds real observations, so navigation and
|
|
551
|
+
// the cursor follow the drawn edge rather than the viewport end.
|
|
552
|
+
const plotEndTime = Math.max(endTime, unprojectCompositeTimestamp(timeScale, 1));
|
|
553
|
+
// A step holds its level to the newest observation and no further: the space
|
|
554
|
+
// reserved after it stays empty. With data still ahead of the window, the
|
|
555
|
+
// level runs to the drawn edge instead.
|
|
556
|
+
const stepTrailingTime = Math.min(plotEndTime, Math.max(lastTime, endTime));
|
|
487
557
|
const scopedSeries = viewport
|
|
488
558
|
? dataSeries.flatMap((entry) => scopeSeriesToViewport(entry, startTime, endTime, timeScale) ?? [])
|
|
489
559
|
: dataSeries;
|
|
@@ -493,11 +563,11 @@ export function buildCompositeChartScene(
|
|
|
493
563
|
const usableSeries = emptyRange
|
|
494
564
|
? dataSeries.map((entry) => ({ ...entry, points: [] }))
|
|
495
565
|
: scopedSeries;
|
|
496
|
-
const visibleTimes = uniqueTimes.filter((time) => time >= startTime && time <=
|
|
566
|
+
const visibleTimes = uniqueTimes.filter((time) => time >= startTime && time <= plotEndTime);
|
|
497
567
|
const marketTimes = timeScale.kind === "market"
|
|
498
568
|
? timeScale.anchors
|
|
499
569
|
.map(({ timestamp }) => timestamp)
|
|
500
|
-
.filter((time) => time >= startTime && time <=
|
|
570
|
+
.filter((time) => time >= startTime && time <= plotEndTime)
|
|
501
571
|
: [];
|
|
502
572
|
const cursorTimes = timeScale.kind === "market" ? marketTimes : visibleTimes;
|
|
503
573
|
const dates = (cursorTimes.length > 0
|
|
@@ -514,22 +584,24 @@ export function buildCompositeChartScene(
|
|
|
514
584
|
const cursorXRatio = cursorDate
|
|
515
585
|
? projectCompositeTimestamp(timeScale, cursorDate.getTime())?.ratio ?? null
|
|
516
586
|
: null;
|
|
517
|
-
|
|
587
|
+
// Panels belong to the authored series, not to whichever of them happen to
|
|
588
|
+
// hold observations right now. A panel that disappears while its data loads
|
|
589
|
+
// reflows every other panel, and the chart jumps again when it comes back.
|
|
590
|
+
const orderedPanels = panelSpecsForSeries(series, panels);
|
|
518
591
|
const panelHeights = allocateCompositePanelHeights(orderedPanels, options.height);
|
|
519
592
|
|
|
520
|
-
const panelScenes: CompositePanelScene[] = orderedPanels.
|
|
593
|
+
const panelScenes: CompositePanelScene[] = orderedPanels.map((panel) => {
|
|
521
594
|
const panelSeries = usableSeries.filter((entry) => entry.panelId === panel.id);
|
|
522
|
-
if (panelSeries.length === 0) return [];
|
|
523
595
|
const scale = panel.scale ?? "linear";
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
const domainSeries = emptyRange
|
|
596
|
+
// With no in-view values, scale the axes to the loaded history rather than
|
|
597
|
+
// the meaningless 0..1 fallback, so the panel keeps its gutter and grid.
|
|
598
|
+
const domainSeries = emptyRange || panelSeries.length === 0
|
|
527
599
|
? dataSeries.filter((entry) => entry.panelId === panel.id)
|
|
528
600
|
: panelSeries;
|
|
529
601
|
const left = buildAxisDomain("left", domainSeries, scale);
|
|
530
602
|
const right = buildAxisDomain("right", domainSeries, scale);
|
|
531
603
|
const axes: Partial<Record<CompositeAxisSide, CompositeAxisDomain>> = { left, right };
|
|
532
|
-
return
|
|
604
|
+
return {
|
|
533
605
|
id: panel.id,
|
|
534
606
|
label: panel.label,
|
|
535
607
|
height: panelHeights.get(panel.id) ?? 1,
|
|
@@ -538,11 +610,15 @@ export function buildCompositeChartScene(
|
|
|
538
610
|
series: panelSeries.flatMap((entry) => {
|
|
539
611
|
const domain = axes[entry.axis];
|
|
540
612
|
return domain
|
|
541
|
-
? [{
|
|
613
|
+
? [{
|
|
614
|
+
source: entry,
|
|
615
|
+
points: projectSeries(entry, domain, startTime, stepTrailingTime, timeScale),
|
|
616
|
+
}]
|
|
542
617
|
: [];
|
|
543
618
|
}),
|
|
544
|
-
}
|
|
619
|
+
};
|
|
545
620
|
});
|
|
621
|
+
attachLastPriceMarker(panelScenes, usableSeries);
|
|
546
622
|
|
|
547
623
|
return {
|
|
548
624
|
width: Math.max(1, Math.floor(options.width)),
|
|
@@ -251,6 +251,19 @@ export function renderCompositePanelText(
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// Blank cells only, so the dashed level reads as if it ran under the marks.
|
|
255
|
+
if (panel.lastPrice) {
|
|
256
|
+
const row = clamp(
|
|
257
|
+
Math.round(panel.lastPrice.yRatio * Math.max(height - 1, 0)),
|
|
258
|
+
0,
|
|
259
|
+
Math.max(height - 1, 0),
|
|
260
|
+
);
|
|
261
|
+
for (let x = 0; x < plotWidth; x += 1) {
|
|
262
|
+
const current = rows[row]?.[x];
|
|
263
|
+
if (current === " " || current === "·") setCell(rows, x, row, "╌");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
254
267
|
if (cursorXRatio !== null) {
|
|
255
268
|
const cursorX = clamp(Math.round(cursorXRatio * Math.max(plotWidth - 1, 0)), 0, Math.max(plotWidth - 1, 0));
|
|
256
269
|
for (let y = 0; y < height; y += 1) {
|
|
@@ -3,6 +3,14 @@ import type { CompositeTimeScale } from "./types";
|
|
|
3
3
|
|
|
4
4
|
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
5
5
|
const QUARTER_HOUR_MS = 15 * 60 * 1_000;
|
|
6
|
+
/**
|
|
7
|
+
* Empty space kept after the newest observation, as a fraction of the visible
|
|
8
|
+
* span, so the last bar is drawn whole with air after it instead of hugging the
|
|
9
|
+
* value axis. Six percent is three to five bar slots on the windows these
|
|
10
|
+
* charts show, and because it scales with the plot it reads the same in a
|
|
11
|
+
* narrow terminal pane and at desktop pixel widths.
|
|
12
|
+
*/
|
|
13
|
+
export const COMPOSITE_RIGHT_OFFSET_RATIO = 0.06;
|
|
6
14
|
const MINIMUM_SLOT_FRACTION = 0.25;
|
|
7
15
|
const dateFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
8
16
|
// Session dates are looked up once per quarter hour, not once per bar: a day
|
|
@@ -202,11 +210,13 @@ export function buildCompositeTimeScale(
|
|
|
202
210
|
timelineSeries: readonly ResolvedSeries[],
|
|
203
211
|
startTime: number,
|
|
204
212
|
endTime: number,
|
|
213
|
+
rightOffsetRatio = 0,
|
|
205
214
|
): CompositeTimeScale {
|
|
215
|
+
const offset = Number.isFinite(rightOffsetRatio) && rightOffsetRatio > 0 ? rightOffsetRatio : 0;
|
|
206
216
|
const anchorSeries = timelineSeries.find((series) => series.timeBasis?.kind === "market");
|
|
207
217
|
const resolved = anchorSeries ? resolveMarketAnchors(anchorSeries) : null;
|
|
208
218
|
if (!anchorSeries || !resolved || resolved.anchors.length === 0) {
|
|
209
|
-
return { kind: "calendar", startTime, endTime };
|
|
219
|
+
return { kind: "calendar", startTime, endTime, rightOffsetRatio: offset };
|
|
210
220
|
}
|
|
211
221
|
const { cadenceMs, anchors } = resolved;
|
|
212
222
|
const startPosition = positionForTimestamp({ anchors, cadenceMs }, startTime);
|
|
@@ -220,9 +230,24 @@ export function buildCompositeTimeScale(
|
|
|
220
230
|
anchors,
|
|
221
231
|
startPosition,
|
|
222
232
|
endPosition: endPosition > startPosition ? endPosition : startPosition + 1,
|
|
233
|
+
rightOffsetRatio: offset,
|
|
223
234
|
};
|
|
224
235
|
}
|
|
225
236
|
|
|
237
|
+
/** Fraction of the visible span held empty after the newest observation. */
|
|
238
|
+
export function compositeRightOffsetRatio(scale: CompositeTimeScale): number {
|
|
239
|
+
const ratio = scale.rightOffsetRatio ?? 0;
|
|
240
|
+
return Number.isFinite(ratio) && ratio > 0 ? ratio : 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Span the plot's full width covers, viewport plus reserved right offset. */
|
|
244
|
+
function projectionSpan(scale: CompositeTimeScale): number {
|
|
245
|
+
const viewportSpan = scale.kind === "calendar"
|
|
246
|
+
? Math.max(scale.endTime - scale.startTime, 1)
|
|
247
|
+
: Math.max(scale.endPosition - scale.startPosition, Number.EPSILON);
|
|
248
|
+
return viewportSpan * (1 + compositeRightOffsetRatio(scale));
|
|
249
|
+
}
|
|
250
|
+
|
|
226
251
|
/** Slot position of a timestamp on a market scale, or wall-clock time on a calendar scale. */
|
|
227
252
|
export function compositeTimePosition(scale: CompositeTimeScale, timestamp: number): number {
|
|
228
253
|
return scale.kind === "calendar" ? timestamp : positionForTimestamp(scale, timestamp);
|
|
@@ -261,8 +286,7 @@ export function projectCompositeTimestamp(
|
|
|
261
286
|
): { ratio: number; xSlot?: number } | null {
|
|
262
287
|
if (!Number.isFinite(timestamp)) return null;
|
|
263
288
|
if (scale.kind === "calendar") {
|
|
264
|
-
|
|
265
|
-
return { ratio: (timestamp - scale.startTime) / span };
|
|
289
|
+
return { ratio: (timestamp - scale.startTime) / projectionSpan(scale) };
|
|
266
290
|
}
|
|
267
291
|
|
|
268
292
|
let position: number;
|
|
@@ -279,8 +303,7 @@ export function projectCompositeTimestamp(
|
|
|
279
303
|
position = positionForTimestamp(scale, timestamp);
|
|
280
304
|
if (exact?.timestamp === timestamp) xSlot = index;
|
|
281
305
|
}
|
|
282
|
-
|
|
283
|
-
return { ratio: (position - scale.startPosition) / span, xSlot };
|
|
306
|
+
return { ratio: (position - scale.startPosition) / projectionSpan(scale), xSlot };
|
|
284
307
|
}
|
|
285
308
|
|
|
286
309
|
export function unprojectCompositeTimestamp(
|
|
@@ -289,10 +312,7 @@ export function unprojectCompositeTimestamp(
|
|
|
289
312
|
): number {
|
|
290
313
|
const safeRatio = Math.max(0, Math.min(1, ratio));
|
|
291
314
|
if (scale.kind === "calendar") {
|
|
292
|
-
return scale.startTime + (scale
|
|
315
|
+
return scale.startTime + projectionSpan(scale) * safeRatio;
|
|
293
316
|
}
|
|
294
|
-
return compositeTimeAtPosition(
|
|
295
|
-
scale,
|
|
296
|
-
scale.startPosition + (scale.endPosition - scale.startPosition) * safeRatio,
|
|
297
|
-
);
|
|
317
|
+
return compositeTimeAtPosition(scale, scale.startPosition + projectionSpan(scale) * safeRatio);
|
|
298
318
|
}
|
|
@@ -43,6 +43,12 @@ export interface CompositeCalendarTimeScale {
|
|
|
43
43
|
kind: "calendar";
|
|
44
44
|
startTime: number;
|
|
45
45
|
endTime: number;
|
|
46
|
+
/**
|
|
47
|
+
* Empty space reserved after `endTime`, as a fraction of the visible span.
|
|
48
|
+
* The 0..1 projection covers `startTime` to `endTime` plus that space, so
|
|
49
|
+
* the newest observation is drawn whole with air after it. Absent means none.
|
|
50
|
+
*/
|
|
51
|
+
rightOffsetRatio?: number;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
export interface CompositeMarketTimeScale {
|
|
@@ -56,7 +62,10 @@ export interface CompositeMarketTimeScale {
|
|
|
56
62
|
position: number;
|
|
57
63
|
}>;
|
|
58
64
|
startPosition: number;
|
|
65
|
+
/** Slot of `endTime`; the reserved right offset extends past it. */
|
|
59
66
|
endPosition: number;
|
|
67
|
+
/** Empty slots reserved after `endPosition`, as a fraction of the visible span. */
|
|
68
|
+
rightOffsetRatio?: number;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
export type CompositeTimeScale = CompositeCalendarTimeScale | CompositeMarketTimeScale;
|
|
@@ -66,6 +75,15 @@ export interface CompositeProjectedSeries {
|
|
|
66
75
|
points: CompositeProjectedPoint[];
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
/** Level line at the newest close of the chart's primary price series. */
|
|
79
|
+
export interface CompositeLastPriceMarker {
|
|
80
|
+
seriesId: string;
|
|
81
|
+
color: string;
|
|
82
|
+
axis: CompositeAxisSide;
|
|
83
|
+
value: number;
|
|
84
|
+
yRatio: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
export interface CompositePanelScene {
|
|
70
88
|
id: string;
|
|
71
89
|
label?: string;
|
|
@@ -73,6 +91,8 @@ export interface CompositePanelScene {
|
|
|
73
91
|
scale: PanelScale;
|
|
74
92
|
axes: Partial<Record<CompositeAxisSide, CompositeAxisDomain>>;
|
|
75
93
|
series: CompositeProjectedSeries[];
|
|
94
|
+
/** Present only on the panel that holds the primary price series. */
|
|
95
|
+
lastPrice?: CompositeLastPriceMarker;
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
export interface CompositeCursorValue {
|
|
@@ -109,6 +129,12 @@ export interface BuildCompositeChartSceneOptions {
|
|
|
109
129
|
};
|
|
110
130
|
/** Authored series order retained even when the primary anchor is hidden. */
|
|
111
131
|
timelineSeries?: ResolvedSeries[];
|
|
132
|
+
/**
|
|
133
|
+
* Empty space kept after the newest observation, as a fraction of the visible
|
|
134
|
+
* span. Defaults to `COMPOSITE_RIGHT_OFFSET_RATIO`; pass 0 for charts whose x
|
|
135
|
+
* is not time, where the last observation belongs at the right edge.
|
|
136
|
+
*/
|
|
137
|
+
rightOffsetRatio?: number;
|
|
112
138
|
}
|
|
113
139
|
|
|
114
140
|
export interface CompositeChartXMarker {
|
|
@@ -42,17 +42,21 @@ export function useCommandBarAssist({
|
|
|
42
42
|
askAssist: () => void;
|
|
43
43
|
resetAssist: () => boolean;
|
|
44
44
|
} {
|
|
45
|
-
const [assistState,
|
|
45
|
+
const [assistState, setRenderedAssistState] = useState<AssistRequestState>({ status: "idle" });
|
|
46
46
|
const assistStateRef = useRef(assistState);
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Event handlers can run again before React commits the state they just
|
|
49
|
+
* queued. Keep their read model current synchronously so repeated Enter
|
|
50
|
+
* claims one request instead of aborting it and starting another.
|
|
51
|
+
*/
|
|
52
|
+
const updateAssistState = useCallback((next: AssistRequestState) => {
|
|
53
|
+
assistStateRef.current = next;
|
|
54
|
+
setRenderedAssistState(next);
|
|
55
|
+
}, []);
|
|
48
56
|
const abortRef = useRef<AbortController | null>(null);
|
|
49
57
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
50
58
|
const answersRef = useRef(new Map<string, AssistCommandCandidate[]>());
|
|
51
|
-
/**
|
|
52
|
-
* The query the runtime has already acted on, updated the moment a request
|
|
53
|
-
* starts. Rendered state lags a tick behind, and reading it instead would let
|
|
54
|
-
* a re-render slip a second debounce past an ask that is already in flight.
|
|
55
|
-
*/
|
|
59
|
+
/** Query the runtime has already acted on, updated when a request starts. */
|
|
56
60
|
const handledQueryRef = useRef<string | null>(null);
|
|
57
61
|
/** Query the user dismissed with Esc; the section stays gone until it changes. */
|
|
58
62
|
const dismissedQueryRef = useRef<string | null>(null);
|
|
@@ -65,12 +69,13 @@ export function useCommandBarAssist({
|
|
|
65
69
|
getInventoryRef.current = getInventory;
|
|
66
70
|
|
|
67
71
|
const cancelPending = useCallback(() => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
abortRef.current
|
|
72
|
+
const debounce = debounceRef.current;
|
|
73
|
+
debounceRef.current = null;
|
|
74
|
+
if (debounce !== null) clearTimeout(debounce);
|
|
75
|
+
|
|
76
|
+
const controller = abortRef.current;
|
|
73
77
|
abortRef.current = null;
|
|
78
|
+
controller?.abort();
|
|
74
79
|
}, []);
|
|
75
80
|
|
|
76
81
|
const resetAssist = useCallback((): boolean => {
|
|
@@ -79,9 +84,9 @@ export function useCommandBarAssist({
|
|
|
79
84
|
if (active.status === "idle" || active.source === "auto") return false;
|
|
80
85
|
cancelPending();
|
|
81
86
|
dismissedQueryRef.current = active.query;
|
|
82
|
-
|
|
87
|
+
updateAssistState({ status: "idle" });
|
|
83
88
|
return true;
|
|
84
|
-
}, [cancelPending]);
|
|
89
|
+
}, [cancelPending, updateAssistState]);
|
|
85
90
|
|
|
86
91
|
const runAssist = useCallback((query: string, source: AssistRequestSource) => {
|
|
87
92
|
const trimmed = query.trim();
|
|
@@ -96,35 +101,35 @@ export function useCommandBarAssist({
|
|
|
96
101
|
|
|
97
102
|
const cached = answersRef.current.get(trimmed);
|
|
98
103
|
if (cached) {
|
|
99
|
-
|
|
104
|
+
updateAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates: cached });
|
|
100
105
|
return;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
const controller = new AbortController();
|
|
104
109
|
abortRef.current = controller;
|
|
105
|
-
|
|
110
|
+
updateAssistState({ status: "loading", query: trimmed, source: resolveSource() });
|
|
106
111
|
|
|
107
112
|
void (async () => {
|
|
108
113
|
try {
|
|
109
114
|
const response = await apiClient.assistCommand(trimmed, getInventoryRef.current(), {
|
|
110
115
|
signal: controller.signal,
|
|
111
116
|
});
|
|
112
|
-
if (controller.signal.aborted) return;
|
|
117
|
+
if (controller.signal.aborted || abortRef.current !== controller) return;
|
|
113
118
|
const candidates = response?.candidates ?? [];
|
|
114
119
|
answersRef.current.set(trimmed, candidates);
|
|
115
|
-
|
|
120
|
+
updateAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates });
|
|
116
121
|
} catch (error) {
|
|
117
|
-
if (controller.signal.aborted) return;
|
|
122
|
+
if (controller.signal.aborted || abortRef.current !== controller) return;
|
|
118
123
|
const kind = classifyAssistError(error);
|
|
119
124
|
if (kind === "rate-limited") {
|
|
120
125
|
rateLimitedUntilRef.current = Date.now() + ASSIST_RATE_LIMIT_BACKOFF_MS;
|
|
121
126
|
}
|
|
122
|
-
|
|
127
|
+
updateAssistState({ status: "error", query: trimmed, source: resolveSource(), kind });
|
|
123
128
|
} finally {
|
|
124
129
|
if (abortRef.current === controller) abortRef.current = null;
|
|
125
130
|
}
|
|
126
131
|
})();
|
|
127
|
-
}, [cancelPending]);
|
|
132
|
+
}, [cancelPending, updateAssistState]);
|
|
128
133
|
|
|
129
134
|
const askAssist = useCallback(() => {
|
|
130
135
|
const trimmed = rootQueryRef.current.trim();
|
|
@@ -134,17 +139,17 @@ export function useCommandBarAssist({
|
|
|
134
139
|
// The background ask already on the wire asks this exact question; asking
|
|
135
140
|
// again would only abort it and start the wait over.
|
|
136
141
|
if (active.status === "loading" && active.query === trimmed) {
|
|
137
|
-
|
|
142
|
+
updateAssistState({ ...active, source: "explicit" });
|
|
138
143
|
return;
|
|
139
144
|
}
|
|
140
145
|
runAssist(trimmed, "explicit");
|
|
141
|
-
}, [runAssist]);
|
|
146
|
+
}, [runAssist, updateAssistState]);
|
|
142
147
|
|
|
143
148
|
useEffect(() => {
|
|
144
149
|
const trimmed = rootQuery.trim();
|
|
145
150
|
const active = assistStateRef.current;
|
|
146
151
|
if (active.status !== "idle" && active.query !== trimmed) {
|
|
147
|
-
|
|
152
|
+
updateAssistState({ status: "idle" });
|
|
148
153
|
}
|
|
149
154
|
if (handledQueryRef.current !== null && handledQueryRef.current !== trimmed) {
|
|
150
155
|
// Whatever is in flight describes text the user has already moved past.
|
|
@@ -158,21 +163,24 @@ export function useCommandBarAssist({
|
|
|
158
163
|
const cached = answersRef.current.get(trimmed);
|
|
159
164
|
if (cached) {
|
|
160
165
|
handledQueryRef.current = trimmed;
|
|
161
|
-
|
|
166
|
+
updateAssistState({ status: "answered", query: trimmed, source: "auto", candidates: cached });
|
|
162
167
|
return;
|
|
163
168
|
}
|
|
164
169
|
if (Date.now() < rateLimitedUntilRef.current) return;
|
|
165
170
|
|
|
166
|
-
|
|
171
|
+
const debounce = setTimeout(() => {
|
|
172
|
+
// A cleared timer can already be queued. Only the timer still owned by
|
|
173
|
+
// this effect may start a request.
|
|
174
|
+
if (debounceRef.current !== debounce) return;
|
|
167
175
|
debounceRef.current = null;
|
|
168
176
|
runAssist(trimmed, "auto");
|
|
169
177
|
}, ASSIST_DEBOUNCE_MS);
|
|
178
|
+
debounceRef.current = debounce;
|
|
170
179
|
return () => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
debounceRef.current = null;
|
|
180
|
+
clearTimeout(debounce);
|
|
181
|
+
if (debounceRef.current === debounce) debounceRef.current = null;
|
|
174
182
|
};
|
|
175
|
-
}, [autoAsk, cancelPending, rootQuery, runAssist]);
|
|
183
|
+
}, [autoAsk, cancelPending, rootQuery, runAssist, updateAssistState]);
|
|
176
184
|
|
|
177
185
|
useEffect(() => cancelPending, [cancelPending]);
|
|
178
186
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act } from "react";
|
|
2
3
|
import { apiClient, setCloudApiFetchTransport } from "../../../api-client";
|
|
3
4
|
import { testRender } from "../../../renderers/opentui/test-utils";
|
|
4
5
|
import type { PluginRegistry } from "../../../plugins/registry";
|
|
@@ -7,9 +8,22 @@ import {
|
|
|
7
8
|
CommandBarHarness,
|
|
8
9
|
createCommandBarTestControls,
|
|
9
10
|
emitKeypress,
|
|
11
|
+
settleFrame,
|
|
10
12
|
} from "./test-harness";
|
|
11
13
|
|
|
12
14
|
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
15
|
+
const originalWebSocket = globalThis.WebSocket;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
apiClient.dispose();
|
|
19
|
+
// The header subscribes to SPY. A real socket rejects this test's fake token
|
|
20
|
+
// and can mark the account unverified before the assist debounce completes.
|
|
21
|
+
globalThis.WebSocket = class {
|
|
22
|
+
static readonly OPEN = 1;
|
|
23
|
+
readyState = 0;
|
|
24
|
+
close() { this.readyState = 3; }
|
|
25
|
+
} as unknown as typeof WebSocket;
|
|
26
|
+
});
|
|
13
27
|
|
|
14
28
|
afterEach(() => {
|
|
15
29
|
setCloudApiFetchTransport(null);
|
|
@@ -18,6 +32,8 @@ afterEach(() => {
|
|
|
18
32
|
testSetup.renderer.destroy();
|
|
19
33
|
testSetup = undefined;
|
|
20
34
|
}
|
|
35
|
+
apiClient.dispose();
|
|
36
|
+
globalThis.WebSocket = originalWebSocket;
|
|
21
37
|
});
|
|
22
38
|
|
|
23
39
|
const { waitForFrameToContain } = createCommandBarTestControls(() => testSetup!);
|
|
@@ -84,8 +100,7 @@ const ASSIST_WAIT_ATTEMPTS = 40;
|
|
|
84
100
|
async function waitForRequest(requests: string[]): Promise<void> {
|
|
85
101
|
for (let attempt = 0; attempt < ASSIST_WAIT_ATTEMPTS; attempt++) {
|
|
86
102
|
if (requests.length > 0) return;
|
|
87
|
-
await
|
|
88
|
-
await testSetup!.renderOnce();
|
|
103
|
+
await settleFrame(testSetup!);
|
|
89
104
|
}
|
|
90
105
|
throw new Error("Timed out waiting for the assist request.");
|
|
91
106
|
}
|
|
@@ -94,8 +109,7 @@ async function waitForFrameWithout(text: string): Promise<string> {
|
|
|
94
109
|
for (let attempt = 0; attempt < ASSIST_WAIT_ATTEMPTS; attempt++) {
|
|
95
110
|
const frame = testSetup!.captureCharFrame();
|
|
96
111
|
if (!frame.includes(text)) return frame;
|
|
97
|
-
await
|
|
98
|
-
await testSetup!.renderOnce();
|
|
112
|
+
await settleFrame(testSetup!);
|
|
99
113
|
}
|
|
100
114
|
throw new Error(`Timed out waiting for "${text}" to disappear.`);
|
|
101
115
|
}
|
|
@@ -236,13 +250,54 @@ describe("CommandBar AI assist", () => {
|
|
|
236
250
|
|
|
237
251
|
releaseResponse();
|
|
238
252
|
for (let attempt = 0; attempt < ASSIST_WAIT_ATTEMPTS && created.length === 0; attempt++) {
|
|
239
|
-
await
|
|
240
|
-
await testSetup.renderOnce();
|
|
253
|
+
await settleFrame(testSetup);
|
|
241
254
|
}
|
|
242
255
|
expect(created).toEqual([{ templateId: "new-chat-pane", options: { arg: "#general" } }]);
|
|
243
256
|
expect(requests).toHaveLength(1);
|
|
244
257
|
});
|
|
245
258
|
|
|
259
|
+
test("does not restart an ask when Enter repeats before loading renders", async () => {
|
|
260
|
+
signInVerified();
|
|
261
|
+
let releaseResponse = () => {};
|
|
262
|
+
const held = new Promise<void>((resolve) => { releaseResponse = resolve; });
|
|
263
|
+
const requests = mockAssistTransport(async () => {
|
|
264
|
+
await held;
|
|
265
|
+
return jsonResponse({
|
|
266
|
+
candidates: [{ input: "CHAT #general", title: "Open the general channel", prefix: "CHAT", confidence: 0.9 }],
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
testSetup = await testRender(
|
|
271
|
+
<CommandBarHarness query="new chat pane" />,
|
|
272
|
+
{ width: 120, height: 20 },
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
await testSetup.renderOnce();
|
|
276
|
+
expect(testSetup.captureCharFrame()).toContain("Thinking…");
|
|
277
|
+
|
|
278
|
+
await act(async () => {
|
|
279
|
+
for (let press = 0; press < 2; press += 1) {
|
|
280
|
+
testSetup!.renderer.keyInput.emit("keypress", {
|
|
281
|
+
ctrl: false,
|
|
282
|
+
meta: false,
|
|
283
|
+
option: false,
|
|
284
|
+
shift: false,
|
|
285
|
+
eventType: "press",
|
|
286
|
+
name: "return",
|
|
287
|
+
sequence: "\r",
|
|
288
|
+
repeated: press > 0,
|
|
289
|
+
stopPropagation: () => {},
|
|
290
|
+
preventDefault: () => {},
|
|
291
|
+
} as any);
|
|
292
|
+
}
|
|
293
|
+
await testSetup!.renderOnce();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(requests).toHaveLength(1);
|
|
297
|
+
releaseResponse();
|
|
298
|
+
await waitForFrameToContain("#general · Open the general channel", ASSIST_WAIT_ATTEMPTS);
|
|
299
|
+
});
|
|
300
|
+
|
|
246
301
|
test("drops the section when a background ask fails", async () => {
|
|
247
302
|
signInVerified();
|
|
248
303
|
const requests = mockAssistTransport(() => jsonResponse({ error: "assist-unavailable" }, 503));
|
|
@@ -275,8 +330,7 @@ describe("CommandBar AI assist", () => {
|
|
|
275
330
|
await testSetup.renderOnce();
|
|
276
331
|
expect(testSetup.captureCharFrame()).toContain("Ask AI — sign up to enable");
|
|
277
332
|
|
|
278
|
-
await
|
|
279
|
-
await testSetup.renderOnce();
|
|
333
|
+
await settleFrame(testSetup, 700);
|
|
280
334
|
expect(requests).toEqual([]);
|
|
281
335
|
|
|
282
336
|
// The offer sits under the list and never takes the Enter that belongs to
|
|
@@ -41,6 +41,24 @@ export async function emitKeypress(
|
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Advances one polling step: sleeps, then renders. Both happen inside `act` so
|
|
46
|
+
* that anything a resolving promise queued during the sleep is flushed before
|
|
47
|
+
* the next frame is captured. Polling outside `act` leaves the update to
|
|
48
|
+
* React's scheduler, which is why a loaded CI box could time out waiting for a
|
|
49
|
+
* frame the app had already produced.
|
|
50
|
+
*/
|
|
51
|
+
export async function settleFrame(
|
|
52
|
+
renderer: Awaited<ReturnType<typeof testRender>>,
|
|
53
|
+
delayMs = 50,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
await act(async () => {
|
|
56
|
+
await Bun.sleep(delayMs);
|
|
57
|
+
});
|
|
58
|
+
// Paint after React has committed the updates collected by act.
|
|
59
|
+
await renderer.renderOnce();
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
export function createCommandBarTestControls(
|
|
45
63
|
getRenderer: () => Awaited<ReturnType<typeof testRender>>,
|
|
46
64
|
) {
|
|
@@ -51,10 +69,9 @@ export function createCommandBarTestControls(
|
|
|
51
69
|
if (frame.includes(text)) {
|
|
52
70
|
return frame;
|
|
53
71
|
}
|
|
54
|
-
await
|
|
55
|
-
await renderer.renderOnce();
|
|
72
|
+
await settleFrame(renderer, delayMs);
|
|
56
73
|
}
|
|
57
|
-
throw new Error(`Timed out waiting for frame to contain "${text}"
|
|
74
|
+
throw new Error(`Timed out waiting for frame to contain "${text}".\n${renderer.captureCharFrame()}`);
|
|
58
75
|
};
|
|
59
76
|
|
|
60
77
|
const clickFrameText = async (text: string): Promise<void> => {
|
|
@@ -76,7 +93,9 @@ export function createCommandBarTestControls(
|
|
|
76
93
|
const renderFrames = async (count = 2): Promise<void> => {
|
|
77
94
|
const renderer = getRenderer();
|
|
78
95
|
for (let index = 0; index < count; index += 1) {
|
|
79
|
-
await
|
|
96
|
+
await act(async () => {
|
|
97
|
+
await renderer.renderOnce();
|
|
98
|
+
});
|
|
80
99
|
}
|
|
81
100
|
};
|
|
82
101
|
|