gloomberb 0.3.7 → 0.4.1
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 +19 -8
- package/package.json +1 -1
- package/src/app.tsx +45 -69
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +455 -106
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +363 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +4 -2
- package/src/components/layout/header.tsx +3 -3
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +77 -5
- package/src/components/layout/shell.tsx +67 -52
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +181 -97
- package/src/components/ui/data-table.tsx +50 -5
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +400 -3
- package/src/plugins/builtin/chat-controller.ts +328 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +180 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/layout-manager.tsx +9 -0
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +67 -1
- package/src/plugins/ibkr/gateway-service.ts +147 -7
- package/src/plugins/ibkr/index.tsx +7 -1
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +132 -17
- package/src/plugins/prediction-markets/controller.ts +142 -122
- package/src/plugins/prediction-markets/index.tsx +7 -1
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +197 -57
- package/src/plugins/prediction-markets/pane.tsx +80 -155
- package/src/plugins/prediction-markets/services/fetch.ts +4 -0
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/plugins/registry.ts +23 -0
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +265 -3
- package/src/sources/provider-router.ts +239 -11
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/updater.test.ts +2 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +47 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -13,6 +13,8 @@ import { ChatController } from "./chat-controller";
|
|
|
13
13
|
const TRANSCRIPT_KIND = "channel-transcript";
|
|
14
14
|
const TRANSCRIPT_KEY = "everyone";
|
|
15
15
|
const TRANSCRIPT_SOURCE = "server";
|
|
16
|
+
const TRANSCRIPT_SCHEMA_VERSION = 2;
|
|
17
|
+
const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
|
|
16
18
|
|
|
17
19
|
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
18
20
|
|
|
@@ -115,17 +117,18 @@ function createController(options: {
|
|
|
115
117
|
persistence.setState("channel:everyone", {
|
|
116
118
|
draft: "",
|
|
117
119
|
replyToId: null,
|
|
118
|
-
lastCursor: messages[messages.length - 1]?.
|
|
120
|
+
lastCursor: messages[messages.length - 1]?.id ?? null,
|
|
119
121
|
}, { schemaVersion: 1 });
|
|
120
122
|
if (messages.length > 0) {
|
|
121
123
|
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages }, {
|
|
122
124
|
sourceKey: TRANSCRIPT_SOURCE,
|
|
123
|
-
schemaVersion:
|
|
125
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
124
126
|
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
125
127
|
});
|
|
126
128
|
}
|
|
127
129
|
controller.attachPersistence(persistence);
|
|
128
130
|
controller.refreshSession = async () => {};
|
|
131
|
+
controller.refreshMessages = async () => {};
|
|
129
132
|
return controller;
|
|
130
133
|
}
|
|
131
134
|
|
|
@@ -164,6 +167,7 @@ async function flushFrame() {
|
|
|
164
167
|
afterEach(async () => {
|
|
165
168
|
setSharedRegistryForTests(undefined);
|
|
166
169
|
setSharedDataProviderForTests(undefined);
|
|
170
|
+
apiClient.connectChannel = originalConnectChannel;
|
|
167
171
|
apiClient.setSessionToken(null);
|
|
168
172
|
|
|
169
173
|
if (testSetup) {
|
|
@@ -211,6 +215,34 @@ describe("ChatContent", () => {
|
|
|
211
215
|
expect(frameAfterType).not.toContain("> FCD");
|
|
212
216
|
});
|
|
213
217
|
|
|
218
|
+
test("renders optimistic sends with a sending status", async () => {
|
|
219
|
+
const controller = createController({
|
|
220
|
+
messages: [{
|
|
221
|
+
id: "local:1",
|
|
222
|
+
channelId: "everyone",
|
|
223
|
+
content: "hello",
|
|
224
|
+
replyToId: null,
|
|
225
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
226
|
+
user: { id: "u0", username: "vince", displayName: "Vince" },
|
|
227
|
+
clientStatus: "sending",
|
|
228
|
+
clientError: null,
|
|
229
|
+
}],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await act(async () => {
|
|
233
|
+
testSetup = await testRender(createHarness(controller), {
|
|
234
|
+
width: 60,
|
|
235
|
+
height: 12,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await flushFrame();
|
|
240
|
+
|
|
241
|
+
const frameAfterSubmit = testSetup.captureCharFrame();
|
|
242
|
+
expect(frameAfterSubmit).toContain("hello");
|
|
243
|
+
expect(frameAfterSubmit).toContain("sending...");
|
|
244
|
+
});
|
|
245
|
+
|
|
214
246
|
test("auto-scrolls to newly appended messages while following the latest transcript", async () => {
|
|
215
247
|
const controller = createController({
|
|
216
248
|
messages: [
|
|
@@ -323,7 +355,7 @@ describe("ChatContent", () => {
|
|
|
323
355
|
expect(opened).toEqual(["TSLA"]);
|
|
324
356
|
});
|
|
325
357
|
|
|
326
|
-
test("shows a saved-login
|
|
358
|
+
test("shows a saved-login read-only footer when a session token is cached", async () => {
|
|
327
359
|
const controller = createController({
|
|
328
360
|
sessionToken: "token-123",
|
|
329
361
|
user: null,
|
|
@@ -340,8 +372,33 @@ describe("ChatContent", () => {
|
|
|
340
372
|
|
|
341
373
|
const frame = testSetup.captureCharFrame();
|
|
342
374
|
expect(frame).toContain("Saved login found.");
|
|
343
|
-
expect(frame).toContain("
|
|
344
|
-
expect(frame).
|
|
375
|
+
expect(frame).toContain("Log in again to send.");
|
|
376
|
+
expect(frame).toContain("No messages yet.");
|
|
377
|
+
expect(frame).not.toContain("Type a message...");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("keeps the transcript visible for logged-out users and blocks the composer", async () => {
|
|
381
|
+
const controller = createController({
|
|
382
|
+
sessionToken: null,
|
|
383
|
+
user: null,
|
|
384
|
+
messages: [makeMessage(1)],
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await act(async () => {
|
|
388
|
+
testSetup = await testRender(createHarness(controller), {
|
|
389
|
+
width: 60,
|
|
390
|
+
height: 12,
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await flushFrame();
|
|
395
|
+
|
|
396
|
+
const frame = testSetup.captureCharFrame();
|
|
397
|
+
expect(frame).toContain("message 1");
|
|
398
|
+
expect(frame).toContain("Read-only chat.");
|
|
399
|
+
expect(frame).toContain("Login");
|
|
400
|
+
expect(frame).toContain("Sign Up");
|
|
401
|
+
expect(frame).not.toContain("Type a message...");
|
|
345
402
|
});
|
|
346
403
|
|
|
347
404
|
test("shows a logged-in icon in the cloud status widget for cached sessions", async () => {
|
|
@@ -365,7 +422,7 @@ describe("ChatContent", () => {
|
|
|
365
422
|
|
|
366
423
|
const frame = testSetup.captureCharFrame();
|
|
367
424
|
expect(frame).toContain("@");
|
|
368
|
-
expect(frame).toContain("Shift+C");
|
|
425
|
+
expect(frame).not.toContain("Shift+C");
|
|
369
426
|
expect(frame).not.toContain("vince");
|
|
370
427
|
});
|
|
371
428
|
|
|
@@ -415,6 +472,61 @@ describe("ChatContent", () => {
|
|
|
415
472
|
expect(openedQueries).toEqual(["Sign Up"]);
|
|
416
473
|
});
|
|
417
474
|
|
|
475
|
+
test("shows an unread mention badge and opens chat from the status widget", async () => {
|
|
476
|
+
const controller = createController({
|
|
477
|
+
sessionToken: "token-123",
|
|
478
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
479
|
+
});
|
|
480
|
+
const openedWidgets: string[] = [];
|
|
481
|
+
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
|
|
482
|
+
state.config.disabledPlugins = [];
|
|
483
|
+
|
|
484
|
+
setSharedRegistryForTests({
|
|
485
|
+
showWidget(paneId: string) {
|
|
486
|
+
openedWidgets.push(paneId);
|
|
487
|
+
},
|
|
488
|
+
} as any);
|
|
489
|
+
|
|
490
|
+
await act(async () => {
|
|
491
|
+
(controller as any).mergeMessages([{
|
|
492
|
+
id: "m1",
|
|
493
|
+
channelId: "everyone",
|
|
494
|
+
content: "pinging @vince before the bell",
|
|
495
|
+
replyToId: null,
|
|
496
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
497
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
498
|
+
} satisfies ChatMessage]);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
await act(async () => {
|
|
502
|
+
testSetup = await testRender(
|
|
503
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
504
|
+
<ChatStatusWidget controller={controller} />
|
|
505
|
+
</AppContext>,
|
|
506
|
+
{ width: 40, height: 1 },
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
await flushFrame();
|
|
511
|
+
|
|
512
|
+
const frame = testSetup.captureCharFrame();
|
|
513
|
+
expect(frame).toContain("vince");
|
|
514
|
+
expect(frame).toContain("[1]");
|
|
515
|
+
|
|
516
|
+
const line = frame.split("\n")[0] ?? "";
|
|
517
|
+
const badgeCol = line.indexOf("[1]");
|
|
518
|
+
|
|
519
|
+
expect(badgeCol).toBeGreaterThanOrEqual(0);
|
|
520
|
+
|
|
521
|
+
await act(async () => {
|
|
522
|
+
await testSetup!.mockMouse.click(badgeCol + 1, 0);
|
|
523
|
+
await testSetup!.renderOnce();
|
|
524
|
+
await testSetup!.renderOnce();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
expect(openedWidgets).toEqual(["chat"]);
|
|
528
|
+
});
|
|
529
|
+
|
|
418
530
|
test("auth commands use a single-form layout and signup no longer prompts for display name", () => {
|
|
419
531
|
const registeredCommands: Array<{ id: string; wizardLayout?: string; wizard?: Array<{ key: string }> }> = [];
|
|
420
532
|
|
|
@@ -19,7 +19,7 @@ interface ChatContentProps {
|
|
|
19
19
|
close?: () => void;
|
|
20
20
|
controller?: Pick<
|
|
21
21
|
ChatController,
|
|
22
|
-
"getSnapshot" | "refreshSession" | "send" | "setDraft" | "setReplyToId" | "subscribe"
|
|
22
|
+
"attachView" | "getSnapshot" | "refreshMessages" | "refreshSession" | "send" | "setDraft" | "setReplyToId" | "subscribe"
|
|
23
23
|
>;
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -34,16 +34,27 @@ function estimateWrappedLineCount(text: string, width: number) {
|
|
|
34
34
|
), 0);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const MESSAGE_GROUP_THRESHOLD_MS = 5 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
function isGroupedWithPrevious(messages: ChatMessage[], index: number) {
|
|
40
|
+
if (index === 0) return false;
|
|
41
|
+
const prev = messages[index - 1]!;
|
|
42
|
+
const curr = messages[index]!;
|
|
43
|
+
if (prev.user.id !== curr.user.id) return false;
|
|
44
|
+
return new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime() < MESSAGE_GROUP_THRESHOLD_MS;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function estimateMessageHeight(message: ChatMessage, width: number, grouped = false) {
|
|
38
48
|
const contentLineWidth = Math.max(width - 4, 1);
|
|
39
49
|
const normalizedContent = message.content.replace(/\$[A-Z][A-Z0-9.-]{0,9}/g, (match) => ` ${match.slice(1)} +0% `);
|
|
40
|
-
|
|
50
|
+
const headerHeight = grouped ? 0 : 1;
|
|
51
|
+
return headerHeight + (message.replyTo ? 1 : 0) + estimateWrappedLineCount(normalizedContent, contentLineWidth);
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
function getMessageTopOffset(messages: ChatMessage[], index: number, width: number) {
|
|
44
55
|
let offset = 0;
|
|
45
56
|
for (let i = 0; i < index; i += 1) {
|
|
46
|
-
offset += estimateMessageHeight(messages[i]!, width);
|
|
57
|
+
offset += estimateMessageHeight(messages[i]!, width, isGroupedWithPrevious(messages, i));
|
|
47
58
|
}
|
|
48
59
|
return offset;
|
|
49
60
|
}
|
|
@@ -53,6 +64,42 @@ function scrollToBottom(scrollBox: ScrollBoxRenderable | null) {
|
|
|
53
64
|
scrollBox.scrollTo(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height));
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
function openAuthCommand(query: string, event?: { preventDefault?: () => void; stopPropagation?: () => void }) {
|
|
68
|
+
event?.preventDefault?.();
|
|
69
|
+
event?.stopPropagation?.();
|
|
70
|
+
getSharedRegistry()?.openCommandBarFn(query);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function InlineAuthActions({ showSignup = true }: { showSignup?: boolean }) {
|
|
74
|
+
const [hoveredAction, setHoveredAction] = useState<"login" | "signup" | null>(null);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<box flexDirection="row">
|
|
78
|
+
<box
|
|
79
|
+
backgroundColor={hoveredAction === "login" ? hoverBg() : undefined}
|
|
80
|
+
onMouseMove={() => setHoveredAction("login")}
|
|
81
|
+
onMouseOut={() => setHoveredAction((current) => (current === "login" ? null : current))}
|
|
82
|
+
onMouseDown={(event: any) => openAuthCommand("Login", event)}
|
|
83
|
+
>
|
|
84
|
+
<text fg={hoveredAction === "login" ? colors.text : colors.textDim}> Login </text>
|
|
85
|
+
</box>
|
|
86
|
+
{showSignup && (
|
|
87
|
+
<>
|
|
88
|
+
<text fg={colors.textDim}>/</text>
|
|
89
|
+
<box
|
|
90
|
+
backgroundColor={hoveredAction === "signup" ? hoverBg() : undefined}
|
|
91
|
+
onMouseMove={() => setHoveredAction("signup")}
|
|
92
|
+
onMouseOut={() => setHoveredAction((current) => (current === "signup" ? null : current))}
|
|
93
|
+
onMouseDown={(event: any) => openAuthCommand("Sign Up", event)}
|
|
94
|
+
>
|
|
95
|
+
<text fg={hoveredAction === "signup" ? colors.text : colors.textDim}> Sign Up </text>
|
|
96
|
+
</box>
|
|
97
|
+
</>
|
|
98
|
+
)}
|
|
99
|
+
</box>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
56
103
|
export function ChatContent({
|
|
57
104
|
width,
|
|
58
105
|
height,
|
|
@@ -77,6 +124,11 @@ export function ChatContent({
|
|
|
77
124
|
));
|
|
78
125
|
const inputRef = useRef<InputRenderable>(null);
|
|
79
126
|
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
127
|
+
const canSend = !!user?.emailVerified;
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
return controller.attachView();
|
|
131
|
+
}, [controller]);
|
|
80
132
|
|
|
81
133
|
useEffect(() => {
|
|
82
134
|
const unsubscribe = controller.subscribe((snapshot) => {
|
|
@@ -91,6 +143,7 @@ export function ChatContent({
|
|
|
91
143
|
});
|
|
92
144
|
|
|
93
145
|
void controller.refreshSession().catch(() => {});
|
|
146
|
+
void controller.refreshMessages().catch(() => {});
|
|
94
147
|
return unsubscribe;
|
|
95
148
|
}, [controller]);
|
|
96
149
|
|
|
@@ -121,11 +174,16 @@ export function ChatContent({
|
|
|
121
174
|
const content = inputValueRef.current.trim();
|
|
122
175
|
if (!content) return;
|
|
123
176
|
controller.send(content, replyToRef.current?.id);
|
|
124
|
-
setReplyTo(null);
|
|
125
177
|
setSelectedIdx(-1);
|
|
126
178
|
setFollowMessages(true);
|
|
127
179
|
}, [controller]);
|
|
128
180
|
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (!canSend && inputFocused) {
|
|
183
|
+
blurInput();
|
|
184
|
+
}
|
|
185
|
+
}, [blurInput, canSend, inputFocused]);
|
|
186
|
+
|
|
129
187
|
useEffect(() => {
|
|
130
188
|
if (!focused && inputFocused) {
|
|
131
189
|
blurInput();
|
|
@@ -138,6 +196,12 @@ export function ChatContent({
|
|
|
138
196
|
}
|
|
139
197
|
}, [focused, inputFocused]);
|
|
140
198
|
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (canSend || !replyTo) return;
|
|
201
|
+
setReplyTo(null);
|
|
202
|
+
controller.setReplyToId(null);
|
|
203
|
+
}, [canSend, controller, replyTo]);
|
|
204
|
+
|
|
141
205
|
useKeyboard((event) => {
|
|
142
206
|
if (!focused) return;
|
|
143
207
|
|
|
@@ -161,7 +225,7 @@ export function ChatContent({
|
|
|
161
225
|
return;
|
|
162
226
|
}
|
|
163
227
|
|
|
164
|
-
if (event.name === "return" || event.name === "i") {
|
|
228
|
+
if ((event.name === "return" || event.name === "i") && canSend) {
|
|
165
229
|
focusInput();
|
|
166
230
|
return;
|
|
167
231
|
}
|
|
@@ -187,7 +251,7 @@ export function ChatContent({
|
|
|
187
251
|
return;
|
|
188
252
|
}
|
|
189
253
|
|
|
190
|
-
if (event.name === "r" && selectedIdx >= 0 && selectedIdx < messages.length) {
|
|
254
|
+
if (canSend && event.name === "r" && selectedIdx >= 0 && selectedIdx < messages.length) {
|
|
191
255
|
const nextReplyTo = messages[selectedIdx] ?? null;
|
|
192
256
|
setReplyTo(nextReplyTo);
|
|
193
257
|
controller.setReplyToId(nextReplyTo?.id ?? null);
|
|
@@ -207,7 +271,7 @@ export function ChatContent({
|
|
|
207
271
|
queueMicrotask(() => scrollToBottom(scrollRef.current));
|
|
208
272
|
return;
|
|
209
273
|
}
|
|
210
|
-
}, [blurInput, close, controller, focusInput, focused, inputFocused, messages, replyTo, selectedIdx]);
|
|
274
|
+
}, [blurInput, canSend, close, controller, focusInput, focused, inputFocused, messages, replyTo, selectedIdx]);
|
|
211
275
|
|
|
212
276
|
useEffect(() => {
|
|
213
277
|
if (selectedIdx < messages.length) return;
|
|
@@ -220,7 +284,7 @@ export function ChatContent({
|
|
|
220
284
|
if (followMessages) return;
|
|
221
285
|
if (selectedIdx < 0 || selectedIdx >= messages.length) return;
|
|
222
286
|
const top = getMessageTopOffset(messages, selectedIdx, contentWidth);
|
|
223
|
-
const rowHeight = estimateMessageHeight(messages[selectedIdx]!, contentWidth);
|
|
287
|
+
const rowHeight = estimateMessageHeight(messages[selectedIdx]!, contentWidth, isGroupedWithPrevious(messages, selectedIdx));
|
|
224
288
|
const viewportHeight = Math.max(sb.viewport.height, 1);
|
|
225
289
|
if (top < sb.scrollTop) {
|
|
226
290
|
sb.scrollTo(top);
|
|
@@ -229,13 +293,13 @@ export function ChatContent({
|
|
|
229
293
|
}
|
|
230
294
|
}, [contentWidth, followMessages, messages, selectedIdx]);
|
|
231
295
|
|
|
232
|
-
const replyBarHeight = replyTo ? 1 : 0;
|
|
233
|
-
const inputAreaHeight = 1 + replyBarHeight;
|
|
296
|
+
const replyBarHeight = canSend && replyTo ? 1 : 0;
|
|
297
|
+
const inputAreaHeight = canSend ? 1 + replyBarHeight : 2;
|
|
234
298
|
const headerHeight = 1;
|
|
235
299
|
const separatorHeight = 1;
|
|
236
300
|
const messageAreaHeight = Math.max(1, height - headerHeight - separatorHeight - inputAreaHeight - 1);
|
|
237
301
|
|
|
238
|
-
if (loading) {
|
|
302
|
+
if (loading && messages.length === 0) {
|
|
239
303
|
return (
|
|
240
304
|
<box flexGrow={1} alignItems="center" justifyContent="center">
|
|
241
305
|
<text fg={colors.textDim}>Loading...</text>
|
|
@@ -243,39 +307,6 @@ export function ChatContent({
|
|
|
243
307
|
);
|
|
244
308
|
}
|
|
245
309
|
|
|
246
|
-
if (!user && !hasSavedSession) {
|
|
247
|
-
return (
|
|
248
|
-
<box flexGrow={1} alignItems="center" justifyContent="center" flexDirection="column">
|
|
249
|
-
<text fg={colors.textDim}>Not logged in.</text>
|
|
250
|
-
<text fg={colors.textDim}> </text>
|
|
251
|
-
<text fg={colors.text}>Press Ctrl+P and search "Login" or "Sign Up"</text>
|
|
252
|
-
<text fg={colors.textDim}>to start chatting.</text>
|
|
253
|
-
</box>
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (!user && hasSavedSession) {
|
|
258
|
-
return (
|
|
259
|
-
<box flexGrow={1} alignItems="center" justifyContent="center" flexDirection="column">
|
|
260
|
-
<text fg={colors.positive}>Saved login found.</text>
|
|
261
|
-
<text fg={colors.textDim}> </text>
|
|
262
|
-
<text fg={colors.text}>Reconnect to refresh your Gloomberb Cloud session.</text>
|
|
263
|
-
<text fg={colors.textDim}>Your cached transcript is still available locally.</text>
|
|
264
|
-
</box>
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (!user.emailVerified) {
|
|
269
|
-
return (
|
|
270
|
-
<box flexGrow={1} alignItems="center" justifyContent="center" flexDirection="column">
|
|
271
|
-
<text fg={colors.positive}>Check your email to unlock Gloomberb Cloud.</text>
|
|
272
|
-
<text fg={colors.textDim}> </text>
|
|
273
|
-
<text fg={colors.text}>Near real-time data and chat are free, but require email verification.</text>
|
|
274
|
-
<text fg={colors.textDim}>Use Ctrl+P and search "Resend Verification Email" if you need another link.</text>
|
|
275
|
-
</box>
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
310
|
return (
|
|
280
311
|
<box flexDirection="column" width={width} height={height}>
|
|
281
312
|
<box height={1} width={contentWidth} flexDirection="row">
|
|
@@ -305,6 +336,13 @@ export function ChatContent({
|
|
|
305
336
|
const isSelected = index === selectedIdx;
|
|
306
337
|
const isHovered = index === hoveredIdx && !isSelected;
|
|
307
338
|
const bgColor = isSelected ? colors.selected : isHovered ? hoverBg() : undefined;
|
|
339
|
+
const grouped = isGroupedWithPrevious(messages, index);
|
|
340
|
+
const isSending = msg.clientStatus === "sending";
|
|
341
|
+
const hasFailed = msg.clientStatus === "failed";
|
|
342
|
+
const headerStatus = isSending ? "sending..." : hasFailed ? "failed" : formatTimeAgo(msg.createdAt);
|
|
343
|
+
const headerStatusColor = isSending ? colors.textDim : hasFailed ? colors.negative : colors.textMuted;
|
|
344
|
+
const authorAttributes = (isSending ? TextAttributes.DIM : 0) | TextAttributes.BOLD;
|
|
345
|
+
const bodyColor = hasFailed ? colors.negative : isSending ? colors.textDim : colors.text;
|
|
308
346
|
|
|
309
347
|
return (
|
|
310
348
|
<box
|
|
@@ -329,18 +367,20 @@ export function ChatContent({
|
|
|
329
367
|
</text>
|
|
330
368
|
</box>
|
|
331
369
|
)}
|
|
332
|
-
|
|
333
|
-
<
|
|
334
|
-
{
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
370
|
+
{!grouped && (
|
|
371
|
+
<box flexDirection="row" height={1} paddingLeft={1}>
|
|
372
|
+
<text fg={colors.positive} attributes={authorAttributes}>
|
|
373
|
+
{msg.user.username ?? "anon"}
|
|
374
|
+
</text>
|
|
375
|
+
<text fg={headerStatusColor}> ({headerStatus})</text>
|
|
376
|
+
</box>
|
|
377
|
+
)}
|
|
338
378
|
<box paddingLeft={3}>
|
|
339
379
|
<TickerBadgeText
|
|
340
380
|
text={msg.content}
|
|
341
381
|
lineWidth={contentWidth - 4}
|
|
342
382
|
catalog={catalog}
|
|
343
|
-
textColor={
|
|
383
|
+
textColor={bodyColor}
|
|
344
384
|
openTicker={openTicker}
|
|
345
385
|
/>
|
|
346
386
|
</box>
|
|
@@ -353,35 +393,58 @@ export function ChatContent({
|
|
|
353
393
|
<text fg={colors.border}>{"-".repeat(contentWidth)}</text>
|
|
354
394
|
</box>
|
|
355
395
|
|
|
356
|
-
{
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
396
|
+
{canSend ? (
|
|
397
|
+
<>
|
|
398
|
+
{replyTo && (
|
|
399
|
+
<box height={1} width={contentWidth} flexDirection="row">
|
|
400
|
+
<text fg={colors.textMuted}> replying to </text>
|
|
401
|
+
<text fg={colors.positive}>{replyTo.user.username}</text>
|
|
402
|
+
<box flexGrow={1} />
|
|
403
|
+
<text fg={colors.textDim}>[Esc cancel]</text>
|
|
404
|
+
</box>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
<box height={1} width={contentWidth} flexDirection="row" onMouseDown={focusInput}>
|
|
408
|
+
<text fg={colors.textDim}> {">"} </text>
|
|
409
|
+
<input
|
|
410
|
+
ref={inputRef}
|
|
411
|
+
value={inputValue}
|
|
412
|
+
focused={inputFocused && focused}
|
|
413
|
+
placeholder="Type a message..."
|
|
414
|
+
placeholderColor={colors.textMuted}
|
|
415
|
+
textColor={colors.text}
|
|
416
|
+
backgroundColor={colors.bg}
|
|
417
|
+
flexGrow={1}
|
|
418
|
+
onInput={updateDraft}
|
|
419
|
+
onChange={updateDraft}
|
|
420
|
+
onSubmit={() => {
|
|
421
|
+
if (inputValueRef.current.trim()) {
|
|
422
|
+
sendMessage();
|
|
423
|
+
}
|
|
424
|
+
}}
|
|
425
|
+
/>
|
|
426
|
+
</box>
|
|
427
|
+
</>
|
|
428
|
+
) : (
|
|
429
|
+
<box width={contentWidth} height={2} flexDirection="column">
|
|
430
|
+
{!user && !hasSavedSession ? (
|
|
431
|
+
<>
|
|
432
|
+
<text fg={colors.textDim}>Read-only chat. Log in or sign up to send.</text>
|
|
433
|
+
<InlineAuthActions />
|
|
434
|
+
</>
|
|
435
|
+
) : !user ? (
|
|
436
|
+
<>
|
|
437
|
+
<text fg={colors.positive}>Saved login found. Log in again to send.</text>
|
|
438
|
+
<InlineAuthActions showSignup={false} />
|
|
439
|
+
</>
|
|
440
|
+
) : (
|
|
441
|
+
<>
|
|
442
|
+
<text fg={colors.positive}>Verify your email to send messages.</text>
|
|
443
|
+
<text fg={colors.textDim}>Ctrl+P: Resend Verification Email</text>
|
|
444
|
+
</>
|
|
445
|
+
)}
|
|
362
446
|
</box>
|
|
363
447
|
)}
|
|
364
|
-
|
|
365
|
-
<box height={1} width={contentWidth} flexDirection="row" onMouseDown={focusInput}>
|
|
366
|
-
<text fg={colors.textDim}> {">"} </text>
|
|
367
|
-
<input
|
|
368
|
-
ref={inputRef}
|
|
369
|
-
value={inputValue}
|
|
370
|
-
focused={inputFocused && focused}
|
|
371
|
-
placeholder="Type a message..."
|
|
372
|
-
placeholderColor={colors.textMuted}
|
|
373
|
-
textColor={colors.text}
|
|
374
|
-
backgroundColor={colors.bg}
|
|
375
|
-
flexGrow={1}
|
|
376
|
-
onInput={updateDraft}
|
|
377
|
-
onChange={updateDraft}
|
|
378
|
-
onSubmit={() => {
|
|
379
|
-
if (inputValueRef.current.trim()) {
|
|
380
|
-
sendMessage();
|
|
381
|
-
}
|
|
382
|
-
}}
|
|
383
|
-
/>
|
|
384
|
-
</box>
|
|
385
448
|
</box>
|
|
386
449
|
);
|
|
387
450
|
}
|
|
@@ -398,17 +461,24 @@ export function ChatPane({ focused, width, height, close }: PaneProps) {
|
|
|
398
461
|
}
|
|
399
462
|
|
|
400
463
|
export function ChatStatusWidget({ controller = chatController }: ChatStatusWidgetProps) {
|
|
401
|
-
const registry = getSharedRegistry();
|
|
402
464
|
const { state } = useAppState();
|
|
403
465
|
const initialSnapshot = controller.getSnapshot();
|
|
404
|
-
const [hoveredAction, setHoveredAction] = useState<"login" | "signup" | null>(null);
|
|
405
466
|
const [username, setUsername] = useState<string | null>(initialSnapshot.user?.username ?? null);
|
|
406
467
|
const [hasSavedSession, setHasSavedSession] = useState(initialSnapshot.hasSavedSession);
|
|
468
|
+
const [unreadMentionCount, setUnreadMentionCount] = useState(initialSnapshot.unreadMentionCount);
|
|
469
|
+
const [hovered, setHovered] = useState(false);
|
|
470
|
+
|
|
471
|
+
const openChat = (event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
|
|
472
|
+
event?.preventDefault?.();
|
|
473
|
+
event?.stopPropagation?.();
|
|
474
|
+
getSharedRegistry()?.showWidget("chat");
|
|
475
|
+
};
|
|
407
476
|
|
|
408
477
|
useEffect(() => {
|
|
409
478
|
const unsubscribe = controller.subscribe((snapshot) => {
|
|
410
479
|
setUsername(snapshot.user?.username ?? null);
|
|
411
480
|
setHasSavedSession(snapshot.hasSavedSession);
|
|
481
|
+
setUnreadMentionCount(snapshot.unreadMentionCount);
|
|
412
482
|
});
|
|
413
483
|
void controller.refreshSession().catch(() => {});
|
|
414
484
|
return unsubscribe;
|
|
@@ -416,47 +486,34 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
|
|
|
416
486
|
|
|
417
487
|
if (state.config.disabledPlugins.includes("gloomberb-cloud")) return null;
|
|
418
488
|
|
|
419
|
-
const openAuthCommand = (query: string, event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
|
|
420
|
-
event?.preventDefault?.();
|
|
421
|
-
event?.stopPropagation?.();
|
|
422
|
-
registry?.openCommandBarFn(query);
|
|
423
|
-
};
|
|
424
|
-
|
|
425
489
|
return (
|
|
426
490
|
<box flexDirection="row" paddingRight={1}>
|
|
427
491
|
{!username && !hasSavedSession ? (
|
|
428
492
|
<>
|
|
429
493
|
<text fg={colors.textDim}>☁ </text>
|
|
430
|
-
<
|
|
431
|
-
backgroundColor={hoveredAction === "login" ? hoverBg() : undefined}
|
|
432
|
-
onMouseMove={() => setHoveredAction("login")}
|
|
433
|
-
onMouseOut={() => setHoveredAction((current) => (current === "login" ? null : current))}
|
|
434
|
-
onMouseDown={(event: any) => openAuthCommand("Login", event)}
|
|
435
|
-
>
|
|
436
|
-
<text fg={hoveredAction === "login" ? colors.text : colors.textDim}> Login </text>
|
|
437
|
-
</box>
|
|
438
|
-
<text fg={colors.textDim}>/</text>
|
|
439
|
-
<box
|
|
440
|
-
backgroundColor={hoveredAction === "signup" ? hoverBg() : undefined}
|
|
441
|
-
onMouseMove={() => setHoveredAction("signup")}
|
|
442
|
-
onMouseOut={() => setHoveredAction((current) => (current === "signup" ? null : current))}
|
|
443
|
-
onMouseDown={(event: any) => openAuthCommand("Sign Up", event)}
|
|
444
|
-
>
|
|
445
|
-
<text fg={hoveredAction === "signup" ? colors.text : colors.textDim}> Sign Up </text>
|
|
446
|
-
</box>
|
|
494
|
+
<InlineAuthActions />
|
|
447
495
|
</>
|
|
448
496
|
) : (
|
|
449
|
-
<
|
|
450
|
-
|
|
451
|
-
{
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
497
|
+
<box
|
|
498
|
+
flexDirection="row"
|
|
499
|
+
backgroundColor={hovered ? hoverBg() : undefined}
|
|
500
|
+
onMouseMove={() => setHovered(true)}
|
|
501
|
+
onMouseOut={() => setHovered(false)}
|
|
502
|
+
onMouseDown={openChat}
|
|
503
|
+
>
|
|
504
|
+
<text fg={unreadMentionCount > 0 ? colors.text : colors.textDim}>
|
|
505
|
+
<span fg={colors.positive}>@</span>
|
|
506
|
+
{username ? (
|
|
507
|
+
<>
|
|
508
|
+
{" "}
|
|
509
|
+
<span fg={colors.positive}>{username}</span>
|
|
510
|
+
</>
|
|
511
|
+
) : null}
|
|
512
|
+
</text>
|
|
513
|
+
{unreadMentionCount > 0 ? (
|
|
514
|
+
<text fg={colors.positive} attributes={TextAttributes.BOLD}>{` [${unreadMentionCount}]`}</text>
|
|
515
|
+
) : null}
|
|
516
|
+
</box>
|
|
460
517
|
)}
|
|
461
518
|
</box>
|
|
462
519
|
);
|
|
@@ -488,6 +545,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
|
|
|
488
545
|
|
|
489
546
|
setup(ctx) {
|
|
490
547
|
chatController.attachPersistence(ctx.persistence, ctx.resume);
|
|
548
|
+
chatController.setToastNotifier(ctx.showToast);
|
|
491
549
|
|
|
492
550
|
ctx.registerPane({
|
|
493
551
|
id: "chat",
|
|
@@ -609,12 +667,18 @@ export const gloomberbCloudPlugin: GloomPlugin = {
|
|
|
609
667
|
return;
|
|
610
668
|
}
|
|
611
669
|
await apiClient.signOut();
|
|
612
|
-
chatController.
|
|
670
|
+
await chatController.refreshSession();
|
|
671
|
+
await chatController.refreshMessages();
|
|
613
672
|
ctx.showToast("Logged out.", { type: "info" });
|
|
614
673
|
},
|
|
615
674
|
hidden: () => !apiClient.getSessionToken(),
|
|
616
675
|
});
|
|
617
676
|
},
|
|
677
|
+
|
|
678
|
+
dispose() {
|
|
679
|
+
chatController.dispose();
|
|
680
|
+
apiClient.dispose();
|
|
681
|
+
},
|
|
618
682
|
};
|
|
619
683
|
|
|
620
684
|
export const chatPlugin = gloomberbCloudPlugin;
|