gloomberb 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
- package/src/app.tsx +9 -6
- package/src/cli/registry.test.ts +0 -1
- package/src/components/chart/chart-controller.ts +0 -1
- package/src/components/chart/chart-data.ts +1 -31
- package/src/components/chart/chart-renderer.ts +70 -0
- package/src/components/chart/chart-resolution.test.ts +0 -36
- package/src/components/chart/chart-resolution.ts +0 -27
- package/src/components/chart/chart.test.ts +17 -45
- package/src/components/chart/comparison-chart-data.test.ts +1 -5
- package/src/components/chart/comparison-chart-data.ts +1 -4
- package/src/components/chart/comparison-stock-chart.tsx +48 -27
- package/src/components/chart/static-chart-surface.tsx +28 -4
- package/src/components/chart/stock-chart.test.ts +0 -16
- package/src/components/chart/stock-chart.tsx +38 -21
- package/src/components/chart/time-axis-label.tsx +43 -0
- package/src/components/command-bar/command-bar.test.tsx +71 -8
- package/src/components/command-bar/command-bar.tsx +388 -713
- package/src/components/command-bar/confirm-body.tsx +51 -0
- package/src/components/command-bar/list-model.ts +77 -0
- package/src/components/command-bar/view-model.ts +0 -19
- package/src/components/command-bar/workflow-ops.ts +4 -0
- package/src/components/data-table-stack-view.test.tsx +39 -4
- package/src/components/data-table-stack-view.tsx +11 -104
- package/src/components/data-table-view.tsx +43 -79
- package/src/components/feed-data-table-stack-view.tsx +10 -17
- package/src/components/feed-stack-controller.ts +39 -0
- package/src/components/index.ts +5 -1
- package/src/components/layout/floating-pane.tsx +30 -2
- package/src/components/layout/header.tsx +10 -7
- package/src/components/layout/pane-cycle-order.test.ts +30 -0
- package/src/components/layout/pane-cycle-order.ts +32 -0
- package/src/components/layout/pane-footer.tsx +11 -4
- package/src/components/layout/pane-header.tsx +5 -5
- package/src/components/layout/shell.test.tsx +66 -22
- package/src/components/layout/shell.tsx +13 -6
- package/src/components/onboarding/onboarding-wizard.tsx +1 -2
- package/src/components/pane-settings-dialog.tsx +12 -4
- package/src/components/pane-template-wizard.test.tsx +3 -4
- package/src/components/speedometer-gauge.test.tsx +45 -0
- package/src/components/speedometer-gauge.tsx +558 -0
- package/src/components/table-view-shared.tsx +158 -0
- package/src/components/ticker-list-table-view.tsx +42 -72
- package/src/components/ui/confirm-dialog.tsx +66 -0
- package/src/components/ui/data-table.tsx +18 -10
- package/src/components/ui/index.ts +0 -1
- package/src/components/ui/page-stack-view.tsx +39 -10
- package/src/components/ui/table-layout.test.ts +16 -0
- package/src/components/ui/table-layout.ts +10 -3
- package/src/components/ui/ui.test.tsx +100 -20
- package/src/core/state/app-state.ts +6 -3
- package/src/market-data/coordinator.test.ts +1 -1
- package/src/market-data/coordinator.ts +0 -1
- package/src/news/aggregator.ts +0 -3
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
- package/src/plugins/builtin/ai/providers.ts +0 -17
- package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
- package/src/plugins/builtin/ai/screener-pane.tsx +0 -3
- package/src/plugins/builtin/ask-ai.test.tsx +89 -2
- package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
- package/src/plugins/builtin/broker-manager/index.tsx +19 -36
- package/src/plugins/builtin/chat-controller.test.ts +105 -0
- package/src/plugins/builtin/chat-controller.ts +75 -6
- package/src/plugins/builtin/chat.test.tsx +6 -6
- package/src/plugins/builtin/chat.tsx +29 -20
- package/src/plugins/builtin/cloud-tweets.tsx +0 -1
- package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
- package/src/plugins/builtin/comparison-chart.tsx +11 -5
- package/src/plugins/builtin/correlation/index.tsx +1 -6
- package/src/plugins/builtin/debug.tsx +1 -3
- package/src/plugins/builtin/econ/index.tsx +1 -2
- package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
- package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
- package/src/plugins/builtin/fear-greed/index.tsx +478 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/help.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +10 -15
- package/src/plugins/builtin/insider/index.tsx +22 -89
- package/src/plugins/builtin/market-movers/index.tsx +1 -1
- package/src/plugins/builtin/news-wire/index.tsx +41 -2
- package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
- package/src/plugins/builtin/news.tsx +4 -16
- package/src/plugins/builtin/notes.tsx +18 -55
- package/src/plugins/builtin/options.test.tsx +58 -2
- package/src/plugins/builtin/options.tsx +108 -49
- package/src/plugins/builtin/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
- package/src/plugins/builtin/research/index.tsx +5 -21
- package/src/plugins/builtin/sec-filing-display.tsx +84 -0
- package/src/plugins/builtin/sec.tsx +13 -66
- package/src/plugins/builtin/sectors/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
- package/src/plugins/builtin/world-indices/index.tsx +1 -1
- package/src/plugins/builtin/yield-curve/index.tsx +1 -1
- package/src/plugins/ibkr/broker-adapter.ts +1 -3
- package/src/plugins/ibkr/gateway-service-native.ts +1 -5
- package/src/plugins/ibkr/gateway-service.ts +1 -1
- package/src/plugins/ibkr/index.test.tsx +0 -2
- package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
- package/src/plugins/pane-manager.ts +0 -13
- package/src/plugins/plugin-runtime.test.tsx +6 -6
- package/src/plugins/plugin-runtime.tsx +13 -8
- package/src/plugins/prediction-markets/cli.ts +0 -1
- package/src/plugins/prediction-markets/controller-data.ts +0 -2
- package/src/plugins/prediction-markets/pane.test.tsx +3 -11
- package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
- package/src/plugins/registry.ts +10 -39
- package/src/react/input.ts +1 -0
- package/src/renderers/electrobun/view/data-table.tsx +28 -6
- package/src/renderers/electrobun/view/desktop-controls.tsx +34 -20
- package/src/renderers/electrobun/view/dialog-host.tsx +11 -1
- package/src/renderers/electrobun/view/input-host.tsx +74 -10
- package/src/renderers/electrobun/view/key-event.ts +1 -1
- package/src/renderers/electrobun/view/styles.css +331 -0
- package/src/renderers/opentui/chart-surface.tsx +179 -0
- package/src/renderers/opentui/start.tsx +48 -30
- package/src/renderers/opentui/test-utils.tsx +17 -0
- package/src/renderers/opentui/ui-host.tsx +5 -2
- package/src/sources/provider-router.ts +0 -4
- package/src/state/app-context.test.ts +45 -0
- package/src/state/use-inline-tickers.test.tsx +73 -0
- package/src/state/use-inline-tickers.ts +7 -1
- package/src/test-support/plugin-runtime.ts +3 -2
- package/src/theme/colors.ts +12 -3
- package/src/theme/themes.test.ts +23 -1
- package/src/theme/themes.ts +115 -0
- package/src/types/broker.ts +1 -1
- package/src/types/data-provider.ts +0 -1
- package/src/types/plugin.ts +0 -2
- package/src/ui/host.tsx +1 -0
- package/src/utils/back-navigation.ts +24 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
- package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
|
@@ -83,6 +83,15 @@ class MemoryPersistence implements PluginPersistence {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
class TrackingPersistence extends MemoryPersistence {
|
|
87
|
+
stateWrites = 0;
|
|
88
|
+
|
|
89
|
+
setState(key: string, value: unknown, options?: { schemaVersion?: number }): void {
|
|
90
|
+
this.stateWrites += 1;
|
|
91
|
+
super.setState(key, value, options);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
afterEach(() => {
|
|
87
96
|
apiClient.dispose();
|
|
88
97
|
apiClient.setSessionToken(null);
|
|
@@ -209,6 +218,34 @@ describe("ChatController", () => {
|
|
|
209
218
|
})).toBeNull();
|
|
210
219
|
});
|
|
211
220
|
|
|
221
|
+
test("defers draft persistence and subscriber sync until the user pauses or leaves", () => {
|
|
222
|
+
const persistence = new TrackingPersistence();
|
|
223
|
+
const controller = new ChatController();
|
|
224
|
+
const draftSnapshots: string[] = [];
|
|
225
|
+
|
|
226
|
+
controller.attachPersistence(persistence);
|
|
227
|
+
const unsubscribe = controller.subscribe((snapshot) => {
|
|
228
|
+
draftSnapshots.push(snapshot.draft);
|
|
229
|
+
});
|
|
230
|
+
draftSnapshots.length = 0;
|
|
231
|
+
persistence.stateWrites = 0;
|
|
232
|
+
|
|
233
|
+
controller.setDraft("h");
|
|
234
|
+
controller.setDraft("he");
|
|
235
|
+
|
|
236
|
+
expect(controller.getSnapshot().draft).toBe("he");
|
|
237
|
+
expect(draftSnapshots).toEqual([]);
|
|
238
|
+
expect(persistence.stateWrites).toBe(0);
|
|
239
|
+
expect(persistence.getState("channel:everyone", { schemaVersion: 1 })).toBeNull();
|
|
240
|
+
|
|
241
|
+
unsubscribe();
|
|
242
|
+
controller.dispose();
|
|
243
|
+
|
|
244
|
+
expect(persistence.getState<{ draft: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
245
|
+
draft: "he",
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
212
249
|
test("pauses verification polling while the app is backgrounded", () => {
|
|
213
250
|
const controller = new ChatController();
|
|
214
251
|
|
|
@@ -254,6 +291,74 @@ describe("ChatController", () => {
|
|
|
254
291
|
expect((controller as any).wsConnection).toBeNull();
|
|
255
292
|
});
|
|
256
293
|
|
|
294
|
+
test("runs a quiet safety refresh while the live connection is active", async () => {
|
|
295
|
+
const persistence = new MemoryPersistence();
|
|
296
|
+
const controller = new ChatController();
|
|
297
|
+
const originalSetInterval = globalThis.setInterval;
|
|
298
|
+
const originalClearInterval = globalThis.clearInterval;
|
|
299
|
+
const intervalCallbacks: Array<() => void> = [];
|
|
300
|
+
const intervalHandle = { unref: () => {} };
|
|
301
|
+
let cleared = false;
|
|
302
|
+
let getMessagesCalls = 0;
|
|
303
|
+
const loadingSnapshots: boolean[] = [];
|
|
304
|
+
|
|
305
|
+
persistence.setState("session", {
|
|
306
|
+
sessionToken: "token-123",
|
|
307
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
308
|
+
}, { schemaVersion: 1 });
|
|
309
|
+
|
|
310
|
+
(globalThis as any).setInterval = (callback: () => void, timeout: number) => {
|
|
311
|
+
expect(timeout).toBe(30_000);
|
|
312
|
+
intervalCallbacks.push(callback);
|
|
313
|
+
return intervalHandle;
|
|
314
|
+
};
|
|
315
|
+
(globalThis as any).clearInterval = (handle: unknown) => {
|
|
316
|
+
if (handle === intervalHandle) {
|
|
317
|
+
cleared = true;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
apiClient.getMessages = async () => {
|
|
323
|
+
getMessagesCalls += 1;
|
|
324
|
+
return [];
|
|
325
|
+
};
|
|
326
|
+
apiClient.connectChannel = () => ({
|
|
327
|
+
send: async () => {
|
|
328
|
+
throw new Error("not implemented");
|
|
329
|
+
},
|
|
330
|
+
close: () => {},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
controller.attachPersistence(persistence);
|
|
334
|
+
controller.ensureConnection();
|
|
335
|
+
await flushMicrotasks();
|
|
336
|
+
|
|
337
|
+
expect(intervalCallbacks).toHaveLength(1);
|
|
338
|
+
expect(getMessagesCalls).toBe(1);
|
|
339
|
+
expect(controller.getSnapshot().loading).toBe(false);
|
|
340
|
+
|
|
341
|
+
controller.subscribe((snapshot) => {
|
|
342
|
+
loadingSnapshots.push(snapshot.loading);
|
|
343
|
+
});
|
|
344
|
+
loadingSnapshots.length = 0;
|
|
345
|
+
|
|
346
|
+
intervalCallbacks[0]!();
|
|
347
|
+
await flushMicrotasks();
|
|
348
|
+
|
|
349
|
+
expect(getMessagesCalls).toBe(2);
|
|
350
|
+
expect(loadingSnapshots).toEqual([false]);
|
|
351
|
+
|
|
352
|
+
controller.clearSession();
|
|
353
|
+
expect((controller as any).safetyRefreshTimer).toBeNull();
|
|
354
|
+
expect(cleared).toBe(true);
|
|
355
|
+
} finally {
|
|
356
|
+
controller.dispose();
|
|
357
|
+
(globalThis as any).setInterval = originalSetInterval;
|
|
358
|
+
(globalThis as any).clearInterval = originalClearInterval;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
257
362
|
test("keeps the cached session when session refresh fails transiently", async () => {
|
|
258
363
|
const persistence = new MemoryPersistence();
|
|
259
364
|
const controller = new ChatController();
|
|
@@ -16,7 +16,9 @@ const TRANSCRIPT_CACHE_POLICY = {
|
|
|
16
16
|
staleMs: 30 * 24 * 60 * 60_000,
|
|
17
17
|
expireMs: 90 * 24 * 60 * 60_000,
|
|
18
18
|
};
|
|
19
|
+
const DRAFT_SYNC_DEBOUNCE_MS = 250;
|
|
19
20
|
const VERIFICATION_POLL_MS = 5_000;
|
|
21
|
+
const SAFETY_REFRESH_MS = 30_000;
|
|
20
22
|
const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
21
23
|
const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
|
|
22
24
|
const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
|
|
@@ -125,6 +127,8 @@ export class ChatController {
|
|
|
125
127
|
private wsConnection: ChatConnection | null = null;
|
|
126
128
|
private wsConnected = false;
|
|
127
129
|
private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
130
|
+
private safetyRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
131
|
+
private draftSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
128
132
|
private openViewCount = 0;
|
|
129
133
|
private pendingMessageSeq = 0;
|
|
130
134
|
private notifyFn: (notification: AppNotificationRequest) => void = () => {};
|
|
@@ -212,6 +216,7 @@ export class ChatController {
|
|
|
212
216
|
const token = apiClient.getSessionToken();
|
|
213
217
|
if (!token) {
|
|
214
218
|
this.stopVerificationPolling();
|
|
219
|
+
this.stopSafetyRefresh();
|
|
215
220
|
this.wsConnection?.close();
|
|
216
221
|
this.wsConnection = null;
|
|
217
222
|
this.wsConnected = false;
|
|
@@ -227,6 +232,7 @@ export class ChatController {
|
|
|
227
232
|
const session = await apiClient.getSession();
|
|
228
233
|
if (!session) {
|
|
229
234
|
this.stopVerificationPolling();
|
|
235
|
+
this.stopSafetyRefresh();
|
|
230
236
|
this.wsConnection?.close();
|
|
231
237
|
this.wsConnection = null;
|
|
232
238
|
this.wsConnected = false;
|
|
@@ -259,23 +265,32 @@ export class ChatController {
|
|
|
259
265
|
}
|
|
260
266
|
|
|
261
267
|
this.syncVerificationPolling();
|
|
268
|
+
this.stopSafetyRefresh();
|
|
262
269
|
this.wsConnection?.close();
|
|
263
270
|
this.wsConnection = null;
|
|
264
271
|
this.wsConnected = false;
|
|
265
272
|
}
|
|
266
273
|
|
|
267
274
|
async refreshMessages(): Promise<void> {
|
|
275
|
+
return this.runMessagesRefresh({ showLoading: true });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async runMessagesRefresh(options: { showLoading: boolean }): Promise<void> {
|
|
268
279
|
if (this.refreshMessagesPromise) return this.refreshMessagesPromise;
|
|
269
280
|
|
|
270
|
-
|
|
271
|
-
|
|
281
|
+
if (options.showLoading) {
|
|
282
|
+
this.messagesLoading = true;
|
|
283
|
+
this.emit();
|
|
284
|
+
}
|
|
272
285
|
|
|
273
286
|
const request = this.fetchMessages()
|
|
274
287
|
.catch(() => {
|
|
275
288
|
this.persistChannelState();
|
|
276
289
|
})
|
|
277
290
|
.finally(() => {
|
|
278
|
-
|
|
291
|
+
if (options.showLoading) {
|
|
292
|
+
this.messagesLoading = false;
|
|
293
|
+
}
|
|
279
294
|
this.refreshMessagesPromise = null;
|
|
280
295
|
this.emit();
|
|
281
296
|
});
|
|
@@ -309,8 +324,7 @@ export class ChatController {
|
|
|
309
324
|
setDraft(draft: string): void {
|
|
310
325
|
if (draft === this.draft) return;
|
|
311
326
|
this.draft = draft;
|
|
312
|
-
this.
|
|
313
|
-
this.emit();
|
|
327
|
+
this.scheduleDraftSync();
|
|
314
328
|
}
|
|
315
329
|
|
|
316
330
|
setReplyToId(replyToId: string | null): void {
|
|
@@ -326,11 +340,13 @@ export class ChatController {
|
|
|
326
340
|
}
|
|
327
341
|
return () => {
|
|
328
342
|
this.openViewCount = Math.max(0, this.openViewCount - 1);
|
|
343
|
+
this.flushDraftSync();
|
|
329
344
|
};
|
|
330
345
|
}
|
|
331
346
|
|
|
332
347
|
clearSession(): void {
|
|
333
348
|
this.stopVerificationPolling();
|
|
349
|
+
this.stopSafetyRefresh();
|
|
334
350
|
this.wsConnection?.close();
|
|
335
351
|
this.wsConnection = null;
|
|
336
352
|
this.wsConnected = false;
|
|
@@ -353,6 +369,7 @@ export class ChatController {
|
|
|
353
369
|
|
|
354
370
|
reset(clearSession = false): void {
|
|
355
371
|
this.stopVerificationPolling();
|
|
372
|
+
this.stopSafetyRefresh();
|
|
356
373
|
this.wsConnection?.close();
|
|
357
374
|
this.wsConnection = null;
|
|
358
375
|
this.wsConnected = false;
|
|
@@ -381,7 +398,9 @@ export class ChatController {
|
|
|
381
398
|
listeners: this.listeners.size,
|
|
382
399
|
hasConnection: !!this.wsConnection,
|
|
383
400
|
});
|
|
401
|
+
this.flushDraftSync();
|
|
384
402
|
this.stopVerificationPolling();
|
|
403
|
+
this.stopSafetyRefresh();
|
|
385
404
|
this.wsConnection?.close();
|
|
386
405
|
this.wsConnection = null;
|
|
387
406
|
this.wsConnected = false;
|
|
@@ -428,7 +447,12 @@ export class ChatController {
|
|
|
428
447
|
}
|
|
429
448
|
|
|
430
449
|
ensureConnection(): void {
|
|
431
|
-
if (
|
|
450
|
+
if (!this.user?.emailVerified || !apiClient.getSessionToken()) {
|
|
451
|
+
this.stopSafetyRefresh();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
this.startSafetyRefresh();
|
|
455
|
+
if (this.wsConnected) return;
|
|
432
456
|
this.wsConnected = true;
|
|
433
457
|
|
|
434
458
|
void this.refreshMessages().catch(() => {});
|
|
@@ -473,6 +497,24 @@ export class ChatController {
|
|
|
473
497
|
this.verificationPollTimer = null;
|
|
474
498
|
}
|
|
475
499
|
|
|
500
|
+
private startSafetyRefresh(): void {
|
|
501
|
+
if (this.safetyRefreshTimer) return;
|
|
502
|
+
this.safetyRefreshTimer = setInterval(() => {
|
|
503
|
+
if (!this.user?.emailVerified || !apiClient.getSessionToken()) {
|
|
504
|
+
this.stopSafetyRefresh();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
void this.runMessagesRefresh({ showLoading: false }).catch(() => {});
|
|
508
|
+
}, SAFETY_REFRESH_MS);
|
|
509
|
+
this.safetyRefreshTimer.unref?.();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private stopSafetyRefresh(): void {
|
|
513
|
+
if (!this.safetyRefreshTimer) return;
|
|
514
|
+
clearInterval(this.safetyRefreshTimer);
|
|
515
|
+
this.safetyRefreshTimer = null;
|
|
516
|
+
}
|
|
517
|
+
|
|
476
518
|
private async fetchMessages(): Promise<void> {
|
|
477
519
|
const legacyTimestampCursor = isLegacyTimestampCursor(this.lastCursor);
|
|
478
520
|
const hasIncrementalCursor = !!this.lastCursor;
|
|
@@ -568,6 +610,11 @@ export class ChatController {
|
|
|
568
610
|
}
|
|
569
611
|
|
|
570
612
|
private persistChannelState(): void {
|
|
613
|
+
this.clearDraftSyncTimer();
|
|
614
|
+
this.writeChannelState();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private writeChannelState(): void {
|
|
571
618
|
const value = {
|
|
572
619
|
draft: this.draft,
|
|
573
620
|
replyToId: this.replyToId,
|
|
@@ -582,6 +629,28 @@ export class ChatController {
|
|
|
582
629
|
});
|
|
583
630
|
}
|
|
584
631
|
|
|
632
|
+
private scheduleDraftSync(): void {
|
|
633
|
+
this.clearDraftSyncTimer();
|
|
634
|
+
this.draftSyncTimer = setTimeout(() => {
|
|
635
|
+
this.draftSyncTimer = null;
|
|
636
|
+
this.writeChannelState();
|
|
637
|
+
this.emit();
|
|
638
|
+
}, DRAFT_SYNC_DEBOUNCE_MS);
|
|
639
|
+
this.draftSyncTimer.unref?.();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private clearDraftSyncTimer(): void {
|
|
643
|
+
if (!this.draftSyncTimer) return;
|
|
644
|
+
clearTimeout(this.draftSyncTimer);
|
|
645
|
+
this.draftSyncTimer = null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private flushDraftSync(): void {
|
|
649
|
+
if (!this.draftSyncTimer) return;
|
|
650
|
+
this.clearDraftSyncTimer();
|
|
651
|
+
this.writeChannelState();
|
|
652
|
+
}
|
|
653
|
+
|
|
585
654
|
private persistTranscript(): void {
|
|
586
655
|
if (this.messages.length === 0) {
|
|
587
656
|
this.persistence?.deleteResource(TRANSCRIPT_KIND, "everyone", { sourceKey: TRANSCRIPT_SOURCE });
|
|
@@ -1431,13 +1431,13 @@ describe("ChatContent", () => {
|
|
|
1431
1431
|
sessionToken: "token-123",
|
|
1432
1432
|
user: { id: "u1", username: "vince", emailVerified: true },
|
|
1433
1433
|
});
|
|
1434
|
-
const
|
|
1434
|
+
const openedPanes: string[] = [];
|
|
1435
1435
|
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
|
|
1436
1436
|
state.config.disabledPlugins = [];
|
|
1437
1437
|
|
|
1438
1438
|
const runtime = createTestPluginRuntime({
|
|
1439
|
-
|
|
1440
|
-
|
|
1439
|
+
showPane(paneId: string) {
|
|
1440
|
+
openedPanes.push(paneId);
|
|
1441
1441
|
},
|
|
1442
1442
|
});
|
|
1443
1443
|
|
|
@@ -1480,7 +1480,7 @@ describe("ChatContent", () => {
|
|
|
1480
1480
|
await testSetup!.renderOnce();
|
|
1481
1481
|
});
|
|
1482
1482
|
|
|
1483
|
-
expect(
|
|
1483
|
+
expect(openedPanes).toEqual(["chat"]);
|
|
1484
1484
|
});
|
|
1485
1485
|
|
|
1486
1486
|
test("auth commands use a single-form layout and signup no longer prompts for display name", () => {
|
|
@@ -1500,8 +1500,8 @@ describe("ChatContent", () => {
|
|
|
1500
1500
|
registerCommand: (command: { id: string; wizardLayout?: string; wizard?: Array<{ key: string }> }) => {
|
|
1501
1501
|
registeredCommands.push(command);
|
|
1502
1502
|
},
|
|
1503
|
-
|
|
1504
|
-
|
|
1503
|
+
showPane: () => {},
|
|
1504
|
+
hidePane: () => {},
|
|
1505
1505
|
notify: () => {},
|
|
1506
1506
|
} as any);
|
|
1507
1507
|
|
|
@@ -222,6 +222,11 @@ export function ChatContent({
|
|
|
222
222
|
}: ChatContentProps) {
|
|
223
223
|
const dispatch = useAppDispatch();
|
|
224
224
|
const initialSnapshot = controller.getSnapshot();
|
|
225
|
+
const { nativePaneChrome } = useUiCapabilities();
|
|
226
|
+
const contentWidth = Math.max(width - 2, 1);
|
|
227
|
+
const composerPrefixWidth = nativePaneChrome ? 0 : 3;
|
|
228
|
+
const composerTextWidth = Math.max(contentWidth - composerPrefixWidth, 1);
|
|
229
|
+
const inputValueRef = useRef(initialSnapshot.draft);
|
|
225
230
|
const [messages, setMessages] = useState<ChatMessage[]>(initialSnapshot.messages);
|
|
226
231
|
const [hasSavedSession, setHasSavedSession] = useState(initialSnapshot.hasSavedSession);
|
|
227
232
|
const [user, setUser] = useState<{ id: string; username: string; emailVerified: boolean } | null>(initialSnapshot.user);
|
|
@@ -229,15 +234,13 @@ export function ChatContent({
|
|
|
229
234
|
const [loadingOlderMessages, setLoadingOlderMessages] = useState(initialSnapshot.loadingOlderMessages);
|
|
230
235
|
const [hasOlderMessages, setHasOlderMessages] = useState(initialSnapshot.hasOlderMessages);
|
|
231
236
|
const [inputFocused, setInputFocused] = useState(false);
|
|
232
|
-
const [
|
|
237
|
+
const [composerRows, setComposerRows] = useState(() => estimateComposerHeight(initialSnapshot.draft, composerTextWidth));
|
|
233
238
|
const [selectedIdx, setSelectedIdx] = useState(-1);
|
|
234
239
|
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
|
235
240
|
const [followMessages, setFollowMessages] = useState(true);
|
|
236
|
-
const contentWidth = Math.max(width - 2, 1);
|
|
237
241
|
const [replyTo, setReplyTo] = useState<ChatMessage | null>(() => (
|
|
238
242
|
initialSnapshot.replyToId ? initialSnapshot.messages.find((message) => message.id === initialSnapshot.replyToId) ?? null : null
|
|
239
243
|
));
|
|
240
|
-
const { nativePaneChrome } = useUiCapabilities();
|
|
241
244
|
const inputRef = useRef<TextareaRenderable>(null);
|
|
242
245
|
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
243
246
|
const applyingExternalDraftRef = useRef(false);
|
|
@@ -250,9 +253,6 @@ export function ChatContent({
|
|
|
250
253
|
const canSend = !!user?.emailVerified;
|
|
251
254
|
const nativeComposerTopGap = nativePaneChrome && canSend ? NATIVE_CHAT_COMPOSER_TOP_GAP_PX : 0;
|
|
252
255
|
const messageBodyWidth = Math.max(contentWidth - 4, 1);
|
|
253
|
-
const composerPrefixWidth = nativePaneChrome ? 0 : 3;
|
|
254
|
-
const composerTextWidth = Math.max(contentWidth - composerPrefixWidth, 1);
|
|
255
|
-
const composerRows = estimateComposerHeight(inputValue, composerTextWidth);
|
|
256
256
|
const composerHeight = canSend
|
|
257
257
|
? nativePaneChrome
|
|
258
258
|
? Math.min(CHAT_COMPOSER_MAX_ROWS + 1, Math.max(2, composerRows + 1))
|
|
@@ -260,11 +260,19 @@ export function ChatContent({
|
|
|
260
260
|
: 0;
|
|
261
261
|
const selectionActive = selectedIdx >= 0 && selectedIdx < messages.length;
|
|
262
262
|
const stickyTranscript = followMessages && !selectionActive;
|
|
263
|
+
const updateComposerRows = useCallback((draft: string) => {
|
|
264
|
+
const nextRows = estimateComposerHeight(draft, composerTextWidth);
|
|
265
|
+
setComposerRows((current) => (current === nextRows ? current : nextRows));
|
|
266
|
+
}, [composerTextWidth]);
|
|
263
267
|
|
|
264
268
|
useEffect(() => {
|
|
265
269
|
return controller.attachView();
|
|
266
270
|
}, [controller]);
|
|
267
271
|
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
updateComposerRows(inputValueRef.current);
|
|
274
|
+
}, [updateComposerRows]);
|
|
275
|
+
|
|
268
276
|
useEffect(() => {
|
|
269
277
|
const unsubscribe = controller.subscribe((snapshot) => {
|
|
270
278
|
setMessages(snapshot.messages);
|
|
@@ -273,7 +281,10 @@ export function ChatContent({
|
|
|
273
281
|
setLoading(snapshot.loading);
|
|
274
282
|
setLoadingOlderMessages(snapshot.loadingOlderMessages);
|
|
275
283
|
setHasOlderMessages(snapshot.hasOlderMessages);
|
|
276
|
-
|
|
284
|
+
if (inputValueRef.current !== snapshot.draft) {
|
|
285
|
+
inputValueRef.current = snapshot.draft;
|
|
286
|
+
updateComposerRows(snapshot.draft);
|
|
287
|
+
}
|
|
277
288
|
const textarea = inputRef.current;
|
|
278
289
|
if (textarea && textarea.editBuffer.getText() !== snapshot.draft) {
|
|
279
290
|
applyingExternalDraftRef.current = true;
|
|
@@ -288,12 +299,10 @@ export function ChatContent({
|
|
|
288
299
|
void controller.refreshSession().catch(() => {});
|
|
289
300
|
void controller.refreshMessages().catch(() => {});
|
|
290
301
|
return unsubscribe;
|
|
291
|
-
}, [controller]);
|
|
302
|
+
}, [controller, updateComposerRows]);
|
|
292
303
|
|
|
293
304
|
const { catalog, openTicker } = useInlineTickers(messages.map((message) => message.content));
|
|
294
305
|
|
|
295
|
-
const inputValueRef = useRef(inputValue);
|
|
296
|
-
inputValueRef.current = inputValue;
|
|
297
306
|
const replyToRef = useRef(replyTo);
|
|
298
307
|
replyToRef.current = replyTo;
|
|
299
308
|
|
|
@@ -423,9 +432,9 @@ export function ChatContent({
|
|
|
423
432
|
const commitLocalDraft = useCallback((draft: string) => {
|
|
424
433
|
if (applyingExternalDraftRef.current) return;
|
|
425
434
|
inputValueRef.current = draft;
|
|
426
|
-
|
|
435
|
+
updateComposerRows(draft);
|
|
427
436
|
controller.setDraft(draft);
|
|
428
|
-
}, [controller]);
|
|
437
|
+
}, [controller, updateComposerRows]);
|
|
429
438
|
|
|
430
439
|
useEffect(() => {
|
|
431
440
|
const textarea = inputRef.current;
|
|
@@ -834,7 +843,7 @@ export function ChatContent({
|
|
|
834
843
|
|
|
835
844
|
<MessageComposer
|
|
836
845
|
inputRef={inputRef}
|
|
837
|
-
initialValue={
|
|
846
|
+
initialValue={inputValueRef.current}
|
|
838
847
|
focused={inputFocused && focused}
|
|
839
848
|
placeholder={inputPlaceholder}
|
|
840
849
|
terminalPrefix=" > "
|
|
@@ -882,7 +891,7 @@ export function ChatContent({
|
|
|
882
891
|
);
|
|
883
892
|
}
|
|
884
893
|
|
|
885
|
-
|
|
894
|
+
function ChatPane({ focused, width, height, close }: PaneProps) {
|
|
886
895
|
return (
|
|
887
896
|
<ChatContent
|
|
888
897
|
width={width}
|
|
@@ -894,7 +903,7 @@ export function ChatPane({ focused, width, height, close }: PaneProps) {
|
|
|
894
903
|
}
|
|
895
904
|
|
|
896
905
|
export function ChatStatusWidget({ controller = chatController }: ChatStatusWidgetProps) {
|
|
897
|
-
const {
|
|
906
|
+
const { showPane } = usePluginAppActions();
|
|
898
907
|
const cloudPluginDisabled = useAppSelector((state) => state.config.disabledPlugins.includes("gloomberb-cloud"));
|
|
899
908
|
const initialSnapshot = controller.getSnapshot();
|
|
900
909
|
const [username, setUsername] = useState<string | null>(initialSnapshot.user?.username ?? null);
|
|
@@ -905,7 +914,7 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
|
|
|
905
914
|
const openChat = (event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
|
|
906
915
|
event?.preventDefault?.();
|
|
907
916
|
event?.stopPropagation?.();
|
|
908
|
-
|
|
917
|
+
showPane("chat");
|
|
909
918
|
};
|
|
910
919
|
|
|
911
920
|
useEffect(() => {
|
|
@@ -1056,9 +1065,9 @@ export const gloomberbCloudPlugin: GloomPlugin = {
|
|
|
1056
1065
|
execute: () => {
|
|
1057
1066
|
const registry = getSharedRegistry();
|
|
1058
1067
|
if (registry?.isPaneFloating("chat")) {
|
|
1059
|
-
ctx.
|
|
1068
|
+
ctx.hidePane("chat");
|
|
1060
1069
|
} else {
|
|
1061
|
-
ctx.
|
|
1070
|
+
ctx.showPane("chat");
|
|
1062
1071
|
}
|
|
1063
1072
|
},
|
|
1064
1073
|
});
|
|
@@ -1086,7 +1095,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
|
|
|
1086
1095
|
}
|
|
1087
1096
|
chatController.clearSession();
|
|
1088
1097
|
await chatController.refreshSession();
|
|
1089
|
-
ctx.
|
|
1098
|
+
ctx.showPane("chat");
|
|
1090
1099
|
},
|
|
1091
1100
|
});
|
|
1092
1101
|
|
|
@@ -1122,7 +1131,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
|
|
|
1122
1131
|
await apiClient.sendVerification();
|
|
1123
1132
|
chatController.clearSession();
|
|
1124
1133
|
await chatController.refreshSession();
|
|
1125
|
-
ctx.
|
|
1134
|
+
ctx.showPane("chat");
|
|
1126
1135
|
},
|
|
1127
1136
|
});
|
|
1128
1137
|
|
|
@@ -109,7 +109,7 @@ function createRegistrySpy(spy: { selected: string[]; focused: string[] }): Plug
|
|
|
109
109
|
} as unknown as PluginRegistry;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function createRuntimeSpy(spy: { selected: string[]; focused: string[] }): PluginRuntimeAccess {
|
|
112
|
+
function createRuntimeSpy(spy: { selected: string[]; focused: string[]; settings?: string[] }): PluginRuntimeAccess {
|
|
113
113
|
return createTestPluginRuntime({
|
|
114
114
|
pinTicker: (symbol: string) => {
|
|
115
115
|
spy.selected.push(symbol);
|
|
@@ -119,6 +119,9 @@ function createRuntimeSpy(spy: { selected: string[]; focused: string[] }): Plugi
|
|
|
119
119
|
spy.selected.push(symbol);
|
|
120
120
|
spy.focused.push("ticker-detail");
|
|
121
121
|
},
|
|
122
|
+
openPaneSettings: (paneId) => {
|
|
123
|
+
spy.settings?.push(paneId ?? "");
|
|
124
|
+
},
|
|
122
125
|
});
|
|
123
126
|
}
|
|
124
127
|
|
|
@@ -196,7 +199,7 @@ async function mountComparisonHarness(
|
|
|
196
199
|
settings: Record<string, unknown>,
|
|
197
200
|
tickers: TickerRecord[],
|
|
198
201
|
financials: Array<[string, TickerFinancials]>,
|
|
199
|
-
spy: { selected: string[]; focused: string[] } = { selected: [], focused: [] },
|
|
202
|
+
spy: { selected: string[]; focused: string[]; settings?: string[] } = { selected: [], focused: [] },
|
|
200
203
|
) {
|
|
201
204
|
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
202
205
|
testSetup = await createTestRenderer({ width: 120, height: 20 });
|
|
@@ -251,6 +254,21 @@ describe("comparisonChartPlugin", () => {
|
|
|
251
254
|
});
|
|
252
255
|
});
|
|
253
256
|
|
|
257
|
+
test("requires at least two symbols when creating a comparison pane", async () => {
|
|
258
|
+
const template = comparisonChartPlugin.paneTemplates?.[0]!;
|
|
259
|
+
const context = {
|
|
260
|
+
config: createDefaultConfig("/tmp/gloomberb-compare"),
|
|
261
|
+
layout: { dockRoot: null, instances: [], floating: [], detached: [] },
|
|
262
|
+
focusedPaneId: null,
|
|
263
|
+
activeTicker: null,
|
|
264
|
+
activeCollectionId: null,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
expect(template.canCreate?.(context, { arg: "AMD" })).toBe(true);
|
|
268
|
+
expect(template.canCreate?.(context, { arg: "AMD", symbols: ["AMD"] })).toBe(false);
|
|
269
|
+
expect(await template.createInstance?.(context, { symbols: ["AMD"] })).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
254
272
|
test("renders one shared overlay chart with the mixed-currency warning", async () => {
|
|
255
273
|
const provider = createProvider({
|
|
256
274
|
AAPL: [100, 102, 104, 106],
|
|
@@ -284,6 +302,7 @@ describe("comparisonChartPlugin", () => {
|
|
|
284
302
|
expect(frame).toContain("2:1W");
|
|
285
303
|
expect(frame).toContain("1D");
|
|
286
304
|
expect(frame).toContain("view:");
|
|
305
|
+
expect(frame).toContain("[t]ickers");
|
|
287
306
|
expect(frame).toContain("[m]ode");
|
|
288
307
|
expect(frame).toContain("[r]es");
|
|
289
308
|
expect(frame).not.toContain("[up/down]legend");
|
|
@@ -364,4 +383,36 @@ describe("comparisonChartPlugin", () => {
|
|
|
364
383
|
expect(spy.selected).toEqual(["MSFT"]);
|
|
365
384
|
expect(spy.focused).toEqual(["ticker-detail"]);
|
|
366
385
|
});
|
|
386
|
+
|
|
387
|
+
test("opens comparison ticker settings from the t shortcut", async () => {
|
|
388
|
+
const spy = { selected: [] as string[], focused: [] as string[], settings: [] as string[] };
|
|
389
|
+
const provider = createProvider({
|
|
390
|
+
AAPL: [100, 102, 104],
|
|
391
|
+
MSFT: [200, 202, 204],
|
|
392
|
+
}, {
|
|
393
|
+
AAPL: "USD",
|
|
394
|
+
MSFT: "USD",
|
|
395
|
+
});
|
|
396
|
+
setSharedMarketDataForTests(provider);
|
|
397
|
+
sharedCoordinator = new MarketDataCoordinator(provider);
|
|
398
|
+
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
399
|
+
setSharedRegistryForTests(createRegistrySpy(spy));
|
|
400
|
+
|
|
401
|
+
await mountComparisonHarness({
|
|
402
|
+
axisMode: "percent",
|
|
403
|
+
symbols: ["AAPL", "MSFT"],
|
|
404
|
+
symbolsText: "AAPL, MSFT",
|
|
405
|
+
}, [
|
|
406
|
+
makeTicker("AAPL", "USD"),
|
|
407
|
+
makeTicker("MSFT", "USD"),
|
|
408
|
+
], [
|
|
409
|
+
["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
|
|
410
|
+
["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
|
|
411
|
+
], spy);
|
|
412
|
+
|
|
413
|
+
await flushFrames();
|
|
414
|
+
await pressComparisonInput(() => testSetup!.mockInput.pressKey("t"));
|
|
415
|
+
|
|
416
|
+
expect(spy.settings).toEqual([TEST_PANE_ID]);
|
|
417
|
+
});
|
|
367
418
|
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
DEFAULT_COMPARISON_CHART_RESOLUTION,
|
|
9
9
|
normalizeChartResolution,
|
|
10
10
|
} from "../../components/chart/chart-resolution";
|
|
11
|
-
import { usePluginTickerActions } from "../../plugins/plugin-runtime";
|
|
11
|
+
import { usePluginAppActions, usePluginTickerActions } from "../../plugins/plugin-runtime";
|
|
12
12
|
import { usePaneInstance } from "../../state/app-context";
|
|
13
13
|
import { colors } from "../../theme/colors";
|
|
14
14
|
import type { GloomPlugin, PaneProps, PaneSettingsDef } from "../../types/plugin";
|
|
@@ -16,6 +16,7 @@ import { formatTickerListInput, MAX_TICKER_LIST_SIZE, parseTickerListInput } fro
|
|
|
16
16
|
|
|
17
17
|
export const COMPARISON_CHART_PANE_ID = "comparison-chart";
|
|
18
18
|
export const COMPARISON_CHART_TEMPLATE_ID = "comparison-chart-pane";
|
|
19
|
+
export const MIN_COMPARISON_CHART_SYMBOLS = 2;
|
|
19
20
|
|
|
20
21
|
interface ComparisonChartPaneSettings {
|
|
21
22
|
axisMode: ChartAxisMode;
|
|
@@ -77,7 +78,7 @@ function buildComparisonChartSettingsDef(): PaneSettingsDef {
|
|
|
77
78
|
{
|
|
78
79
|
key: "symbolsText",
|
|
79
80
|
label: "Tickers",
|
|
80
|
-
description: `Enter
|
|
81
|
+
description: `Enter ${MIN_COMPARISON_CHART_SYMBOLS}-${MAX_TICKER_LIST_SIZE} tickers separated by commas.`,
|
|
81
82
|
type: "text",
|
|
82
83
|
placeholder: "AAPL, MSFT, NVDA",
|
|
83
84
|
},
|
|
@@ -102,12 +103,16 @@ export function buildComparisonChartPaneTitle(symbols: string[]): string {
|
|
|
102
103
|
}
|
|
103
104
|
function ComparisonChartPane({ paneId, focused, width, height }: PaneProps) {
|
|
104
105
|
const { navigateTicker } = usePluginTickerActions();
|
|
106
|
+
const { openPaneSettings } = usePluginAppActions();
|
|
105
107
|
const pane = usePaneInstance();
|
|
106
108
|
const settings = useMemo(() => getComparisonChartPaneSettings(pane?.settings), [pane?.settings]);
|
|
107
109
|
|
|
108
110
|
const openTicker = useCallback((symbol: string) => {
|
|
109
111
|
navigateTicker(symbol);
|
|
110
112
|
}, [navigateTicker]);
|
|
113
|
+
const editTickers = useCallback(() => {
|
|
114
|
+
openPaneSettings(paneId);
|
|
115
|
+
}, [openPaneSettings, paneId]);
|
|
111
116
|
|
|
112
117
|
if (settings.symbols.length === 0) {
|
|
113
118
|
return (
|
|
@@ -127,6 +132,7 @@ function ComparisonChartPane({ paneId, focused, width, height }: PaneProps) {
|
|
|
127
132
|
symbols={settings.symbols}
|
|
128
133
|
axisMode={settings.axisMode}
|
|
129
134
|
onOpenSymbol={openTicker}
|
|
135
|
+
onEditTickers={editTickers}
|
|
130
136
|
/>
|
|
131
137
|
</Box>
|
|
132
138
|
);
|
|
@@ -162,15 +168,15 @@ export const comparisonChartPlugin: GloomPlugin = {
|
|
|
162
168
|
label: "Comparison Tickers",
|
|
163
169
|
placeholder: "AAPL, MSFT, NVDA",
|
|
164
170
|
body: [
|
|
165
|
-
`Enter
|
|
171
|
+
`Enter ${MIN_COMPARISON_CHART_SYMBOLS}-${MAX_TICKER_LIST_SIZE} ticker symbols separated by commas.`,
|
|
166
172
|
],
|
|
167
173
|
type: "text",
|
|
168
174
|
},
|
|
169
175
|
],
|
|
170
|
-
canCreate: (_context, options) => !options?.symbols || options.symbols.length
|
|
176
|
+
canCreate: (_context, options) => !options?.symbols || options.symbols.length >= MIN_COMPARISON_CHART_SYMBOLS,
|
|
171
177
|
createInstance: (_context, options) => {
|
|
172
178
|
const symbols = options?.symbols ?? [];
|
|
173
|
-
if (symbols.length
|
|
179
|
+
if (symbols.length < MIN_COMPARISON_CHART_SYMBOLS) return null;
|
|
174
180
|
return {
|
|
175
181
|
title: buildComparisonChartPaneTitle(symbols),
|
|
176
182
|
placement: "floating",
|