gloomberb 0.4.1 → 0.5.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/README.md +2 -2
- package/package.json +1 -1
- package/src/app.tsx +168 -65
- package/src/cli/commands/ticker.ts +45 -15
- package/src/cli/helpers.ts +4 -2
- package/src/cli/registry.test.ts +42 -35
- package/src/cli.test.ts +4 -2
- package/src/components/chart/chart-control-hint.tsx +39 -0
- package/src/components/chart/chart-controller.test.ts +110 -0
- package/src/components/chart/chart-controller.ts +364 -2
- package/src/components/chart/chart-data.ts +177 -29
- package/src/components/chart/chart-indicator-selector.tsx +53 -0
- package/src/components/chart/chart-pane-settings.ts +20 -0
- package/src/components/chart/chart-renderer.ts +220 -30
- package/src/components/chart/chart-resolution.test.ts +67 -0
- package/src/components/chart/chart-resolution.ts +74 -4
- package/src/components/chart/chart-scroll.test.ts +69 -0
- package/src/components/chart/chart-scroll.ts +104 -0
- package/src/components/chart/chart-types.ts +12 -0
- package/src/components/chart/chart-viewport.test.ts +5 -1
- package/src/components/chart/chart-viewport.ts +1 -1
- package/src/components/chart/chart.test.ts +249 -10
- package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
- package/src/components/chart/comparison-chart-renderer.ts +40 -3
- package/src/components/chart/comparison-stock-chart.tsx +218 -123
- package/src/components/chart/indicators/bands.test.ts +50 -0
- package/src/components/chart/indicators/bands.ts +29 -0
- package/src/components/chart/indicators/moving-averages.test.ts +54 -0
- package/src/components/chart/indicators/moving-averages.ts +42 -0
- package/src/components/chart/indicators/options.test.ts +32 -0
- package/src/components/chart/indicators/options.ts +107 -0
- package/src/components/chart/indicators/oscillators.test.ts +79 -0
- package/src/components/chart/indicators/oscillators.ts +92 -0
- package/src/components/chart/indicators/types.ts +36 -0
- package/src/components/chart/native/chart-rasterizer.ts +51 -0
- package/src/components/chart/native/kitty.test.ts +68 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
- package/src/components/chart/stock-chart.test.ts +239 -0
- package/src/components/chart/stock-chart.tsx +2257 -372
- package/src/components/command-bar/command-bar.test.tsx +337 -5
- package/src/components/command-bar/command-bar.tsx +292 -120
- package/src/components/command-bar/helpers.ts +1 -1
- package/src/components/command-bar/root-shortcuts.ts +33 -4
- package/src/components/command-bar/view-model.test.ts +3 -3
- package/src/components/command-bar/view-model.ts +4 -4
- package/src/components/command-bar/workflow-ops.ts +10 -0
- package/src/components/layout/header.tsx +2 -1
- package/src/components/layout/shell.test.tsx +186 -27
- package/src/components/layout/shell.tsx +5 -4
- package/src/components/layout/status-bar.test.tsx +16 -7
- package/src/components/layout/status-bar.tsx +4 -1
- package/src/components/markdown-editor.tsx +1 -0
- package/src/components/pane-settings-dialog.tsx +13 -152
- package/src/components/pane-template-wizard.tsx +1 -0
- package/src/components/price-selector-dialog.tsx +12 -7
- package/src/components/ticker-badge-text.test.tsx +50 -0
- package/src/components/ticker-badge-text.tsx +17 -2
- package/src/components/ticker-badge.tsx +2 -2
- package/src/components/ticker-list-table.tsx +54 -1
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +60 -3
- package/src/components/ui/external-link.tsx +48 -11
- package/src/components/ui/fields.tsx +2 -1
- package/src/components/ui/index.ts +8 -1
- package/src/components/ui/multi-select-chips.tsx +69 -0
- package/src/components/ui/multi-select-dialog.tsx +267 -0
- package/src/components/ui/multi-select.ts +78 -0
- package/src/components/ui/ui.test.tsx +343 -1
- package/src/data/plugin-state-store.ts +30 -19
- package/src/data/resource-store.ts +133 -103
- package/src/data/session-store.ts +19 -12
- package/src/data/sqlite-cache.test.ts +37 -2
- package/src/data/sqlite-database.ts +8 -2
- package/src/data/sqlite-retry.test.ts +63 -0
- package/src/data/sqlite-retry.ts +66 -0
- package/src/data/ticker-store.ts +29 -18
- package/src/index.tsx +6 -1
- package/src/market-data/coordinator.test.ts +114 -5
- package/src/market-data/coordinator.ts +70 -1
- package/src/market-data/hooks.test.tsx +73 -2
- package/src/market-data/hooks.tsx +16 -4
- package/src/notifications/app-notifier.test.ts +177 -0
- package/src/notifications/app-notifier.ts +241 -0
- package/src/pane-settings.test.ts +2 -0
- package/src/plugins/builtin/ai/index.test.tsx +1 -1
- package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
- package/src/plugins/builtin/ai/ticker-context.ts +3 -2
- package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
- package/src/plugins/builtin/alerts/index.test.tsx +357 -0
- package/src/plugins/builtin/alerts/index.tsx +519 -0
- package/src/plugins/builtin/alerts/types.ts +14 -0
- package/src/plugins/builtin/analytics/index.test.tsx +300 -0
- package/src/plugins/builtin/analytics/index.tsx +775 -0
- package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
- package/src/plugins/builtin/analytics/metrics.ts +155 -0
- package/src/plugins/builtin/ask-ai.test.tsx +2 -0
- package/src/plugins/builtin/chat-controller.test.ts +62 -18
- package/src/plugins/builtin/chat-controller.ts +23 -12
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +415 -119
- package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
- package/src/plugins/builtin/comparison-chart.tsx +1 -2
- package/src/plugins/builtin/correlation/compute.test.ts +147 -0
- package/src/plugins/builtin/correlation/compute.ts +115 -0
- package/src/plugins/builtin/correlation/index.tsx +385 -0
- package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
- package/src/plugins/builtin/correlation/settings.test.ts +34 -0
- package/src/plugins/builtin/correlation/settings.ts +95 -0
- package/src/plugins/builtin/debug.tsx +36 -50
- package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
- package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
- package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
- package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
- package/src/plugins/builtin/earnings/index.tsx +337 -0
- package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
- package/src/plugins/builtin/econ/calendar-source.ts +121 -0
- package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
- package/src/plugins/builtin/econ/fred-client.ts +79 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
- package/src/plugins/builtin/econ/index.tsx +999 -0
- package/src/plugins/builtin/econ/types.ts +13 -0
- package/src/plugins/builtin/fred-settings.ts +10 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
- package/src/plugins/builtin/help.test.tsx +1 -5
- package/src/plugins/builtin/help.tsx +1 -16
- package/src/plugins/builtin/insider/index.tsx +417 -0
- package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
- package/src/plugins/builtin/insider/insider-data.ts +49 -0
- package/src/plugins/builtin/layout-manager.tsx +24 -16
- package/src/plugins/builtin/market-movers/index.tsx +573 -0
- package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
- package/src/plugins/builtin/market-movers/screener.ts +105 -0
- package/src/plugins/builtin/options.tsx +4 -3
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
- package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
- package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
- package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
- package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
- package/src/plugins/builtin/sec.tsx +114 -7
- package/src/plugins/builtin/sectors/index.tsx +359 -0
- package/src/plugins/builtin/sectors/sector-data.ts +18 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
- package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
- package/src/plugins/builtin/world-indices/index.tsx +410 -0
- package/src/plugins/builtin/world-indices/indices.ts +52 -0
- package/src/plugins/builtin/yield-curve/index.tsx +242 -0
- package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
- package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
- package/src/plugins/catalog.ts +22 -0
- package/src/plugins/gridlock-notification.ts +15 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +1 -1
- package/src/plugins/ibkr/trade-tab.tsx +23 -5
- package/src/plugins/ibkr/trade-utils.test.ts +1 -1
- package/src/plugins/ibkr/trade-utils.ts +7 -6
- package/src/plugins/ibkr/trading-pane.tsx +8 -7
- package/src/plugins/plugin-runtime.test.tsx +2 -0
- package/src/plugins/plugin-runtime.tsx +10 -0
- package/src/plugins/prediction-markets/controller-data.ts +8 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +13 -7
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
- package/src/plugins/registry.ts +22 -13
- package/src/sources/provider-router.test.ts +3 -2
- package/src/sources/sec-edgar.ts +13 -1
- package/src/sources/yahoo-finance.ts +75 -1
- package/src/state/app-context-selector.test.tsx +17 -1
- package/src/state/app-context.test.tsx +24 -0
- package/src/state/app-context.tsx +29 -6
- package/src/theme/colors.ts +5 -0
- package/src/theme/themes.ts +25 -0
- package/src/types/config.test.ts +107 -0
- package/src/types/config.ts +30 -1
- package/src/types/data-provider.ts +13 -0
- package/src/types/plugin.ts +35 -1
- package/src/utils/format.test.ts +11 -1
- package/src/utils/format.ts +86 -8
- package/src/utils/inline-content-tokenizer.test.ts +22 -0
- package/src/utils/inline-content-tokenizer.ts +76 -0
- package/src/utils/link-tokenizer.test.ts +27 -0
- package/src/utils/link-tokenizer.ts +93 -0
- package/src/utils/market-format.test.ts +109 -0
- package/src/utils/market-format.ts +299 -0
- package/src/utils/market-status.ts +3 -2
- package/src/utils/selection-clipboard.test.ts +43 -0
- package/src/utils/selection-clipboard.ts +88 -0
- package/src/utils/terminal-input-reset.test.ts +50 -0
- package/src/utils/terminal-input-reset.ts +24 -0
- package/src/utils/throttled-fetch.test.ts +145 -0
- package/src/utils/throttled-fetch.ts +212 -0
- package/src/utils/ticker-tokenizer.test.ts +31 -0
- package/src/utils/ticker-tokenizer.ts +7 -42
- package/src/version.ts +1 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { act } from "react";
|
|
2
|
+
import { act, useState } from "react";
|
|
3
3
|
import { testRender } from "@opentui/react/test-utils";
|
|
4
4
|
import { AppContext, createInitialState } from "../../state/app-context";
|
|
5
|
+
import { colors } from "../../theme/colors";
|
|
5
6
|
import { createDefaultConfig } from "../../types/config";
|
|
6
7
|
import type { PersistedResourceValue } from "../../types/persistence";
|
|
7
8
|
import type { PluginPersistence } from "../../types/plugin";
|
|
8
9
|
import { apiClient, type ChatMessage } from "../../utils/api-client";
|
|
9
10
|
import { setSharedDataProviderForTests, setSharedRegistryForTests } from "../registry";
|
|
10
|
-
import { ChatContent, ChatStatusWidget, gloomberbCloudPlugin } from "./chat";
|
|
11
|
+
import { ChatContent, ChatStatusWidget, getSelectedMessageScrollTop, gloomberbCloudPlugin } from "./chat";
|
|
11
12
|
import { ChatController } from "./chat-controller";
|
|
12
13
|
|
|
13
14
|
const TRANSCRIPT_KIND = "channel-transcript";
|
|
@@ -103,6 +104,7 @@ function createController(options: {
|
|
|
103
104
|
messages?: ChatMessage[];
|
|
104
105
|
sessionToken?: string | null;
|
|
105
106
|
user?: { id: string; username: string; emailVerified: boolean } | null;
|
|
107
|
+
replyToId?: string | null;
|
|
106
108
|
} = {}) {
|
|
107
109
|
const messages = options.messages ?? [];
|
|
108
110
|
const persistence = new MemoryPersistence();
|
|
@@ -116,7 +118,7 @@ function createController(options: {
|
|
|
116
118
|
}, { schemaVersion: 1 });
|
|
117
119
|
persistence.setState("channel:everyone", {
|
|
118
120
|
draft: "",
|
|
119
|
-
replyToId: null,
|
|
121
|
+
replyToId: options.replyToId ?? null,
|
|
120
122
|
lastCursor: messages[messages.length - 1]?.id ?? null,
|
|
121
123
|
}, { schemaVersion: 1 });
|
|
122
124
|
if (messages.length > 0) {
|
|
@@ -138,6 +140,7 @@ function createHarness(
|
|
|
138
140
|
width?: number;
|
|
139
141
|
height?: number;
|
|
140
142
|
configureState?: (state: ReturnType<typeof createInitialState>) => void;
|
|
143
|
+
close?: () => void;
|
|
141
144
|
},
|
|
142
145
|
) {
|
|
143
146
|
const width = options?.width ?? 60;
|
|
@@ -152,6 +155,7 @@ function createHarness(
|
|
|
152
155
|
width={width}
|
|
153
156
|
height={height}
|
|
154
157
|
focused
|
|
158
|
+
close={options?.close}
|
|
155
159
|
/>
|
|
156
160
|
</AppContext>
|
|
157
161
|
);
|
|
@@ -164,6 +168,44 @@ async function flushFrame() {
|
|
|
164
168
|
});
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
async function emitKeypress(event: {
|
|
172
|
+
name?: string;
|
|
173
|
+
sequence?: string;
|
|
174
|
+
ctrl?: boolean;
|
|
175
|
+
meta?: boolean;
|
|
176
|
+
shift?: boolean;
|
|
177
|
+
option?: boolean;
|
|
178
|
+
}) {
|
|
179
|
+
await act(async () => {
|
|
180
|
+
testSetup!.renderer.keyInput.emit("keypress", {
|
|
181
|
+
ctrl: false,
|
|
182
|
+
meta: false,
|
|
183
|
+
option: false,
|
|
184
|
+
shift: false,
|
|
185
|
+
eventType: "press",
|
|
186
|
+
repeated: false,
|
|
187
|
+
stopPropagation: () => {},
|
|
188
|
+
preventDefault: () => {},
|
|
189
|
+
...event,
|
|
190
|
+
} as any);
|
|
191
|
+
await testSetup!.renderOnce();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function lineText(line: { spans: Array<{ text: string }> }) {
|
|
196
|
+
return line.spans.map((span) => span.text).join("");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function hexToRgbaInts(hex: string) {
|
|
200
|
+
const normalized = hex.replace("#", "");
|
|
201
|
+
return [
|
|
202
|
+
parseInt(normalized.slice(0, 2), 16),
|
|
203
|
+
parseInt(normalized.slice(2, 4), 16),
|
|
204
|
+
parseInt(normalized.slice(4, 6), 16),
|
|
205
|
+
255,
|
|
206
|
+
].join(",");
|
|
207
|
+
}
|
|
208
|
+
|
|
167
209
|
afterEach(async () => {
|
|
168
210
|
setSharedRegistryForTests(undefined);
|
|
169
211
|
setSharedDataProviderForTests(undefined);
|
|
@@ -215,6 +257,588 @@ describe("ChatContent", () => {
|
|
|
215
257
|
expect(frameAfterType).not.toContain("> FCD");
|
|
216
258
|
});
|
|
217
259
|
|
|
260
|
+
test("keeps appending typed text while transcript updates arrive", async () => {
|
|
261
|
+
const controller = createController({
|
|
262
|
+
messages: [makeMessage(1)],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await act(async () => {
|
|
266
|
+
testSetup = await testRender(createHarness(controller), {
|
|
267
|
+
width: 60,
|
|
268
|
+
height: 12,
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await flushFrame();
|
|
273
|
+
|
|
274
|
+
const frameBeforeClick = testSetup.captureCharFrame().split("\n");
|
|
275
|
+
const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
|
|
276
|
+
const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
|
|
277
|
+
|
|
278
|
+
expect(inputRow).toBeGreaterThanOrEqual(0);
|
|
279
|
+
expect(inputCol).toBeGreaterThanOrEqual(0);
|
|
280
|
+
|
|
281
|
+
await act(async () => {
|
|
282
|
+
await testSetup!.mockMouse.click(inputCol + 1, inputRow);
|
|
283
|
+
await testSetup!.renderOnce();
|
|
284
|
+
await testSetup!.renderOnce();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await act(async () => {
|
|
288
|
+
await testSetup!.mockInput.typeText("alpha");
|
|
289
|
+
await testSetup!.renderOnce();
|
|
290
|
+
await testSetup!.renderOnce();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await act(async () => {
|
|
294
|
+
(controller as any).mergeMessages([makeMessage(2)]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await flushFrame();
|
|
298
|
+
|
|
299
|
+
await act(async () => {
|
|
300
|
+
await testSetup!.mockInput.typeText("beta");
|
|
301
|
+
await testSetup!.renderOnce();
|
|
302
|
+
await testSetup!.renderOnce();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const frameAfterType = testSetup.captureCharFrame();
|
|
306
|
+
expect(frameAfterType).toContain("> alphabeta");
|
|
307
|
+
expect(frameAfterType).not.toContain("> betaalpha");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("does not render an idle helper row above the composer", async () => {
|
|
311
|
+
const controller = createController({
|
|
312
|
+
messages: [makeMessage(1)],
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await act(async () => {
|
|
316
|
+
testSetup = await testRender(createHarness(controller, {
|
|
317
|
+
width: 72,
|
|
318
|
+
height: 12,
|
|
319
|
+
}), {
|
|
320
|
+
width: 72,
|
|
321
|
+
height: 12,
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await flushFrame();
|
|
326
|
+
|
|
327
|
+
const frame = testSetup.captureCharFrame();
|
|
328
|
+
expect(frame).toContain("Type a message...");
|
|
329
|
+
expect(frame).not.toContain("click a message");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("does not select a message when the row is clicked", async () => {
|
|
333
|
+
const controller = createController({
|
|
334
|
+
messages: [makeMessage(1)],
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await act(async () => {
|
|
338
|
+
testSetup = await testRender(createHarness(controller, {
|
|
339
|
+
width: 72,
|
|
340
|
+
height: 12,
|
|
341
|
+
}), {
|
|
342
|
+
width: 72,
|
|
343
|
+
height: 12,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await flushFrame();
|
|
348
|
+
|
|
349
|
+
const lines = testSetup.captureCharFrame().split("\n");
|
|
350
|
+
const messageRow = lines.findIndex((line) => line.includes("message 1"));
|
|
351
|
+
const messageCol = lines[messageRow]?.indexOf("message 1") ?? -1;
|
|
352
|
+
|
|
353
|
+
expect(messageRow).toBeGreaterThanOrEqual(0);
|
|
354
|
+
expect(messageCol).toBeGreaterThanOrEqual(0);
|
|
355
|
+
|
|
356
|
+
await act(async () => {
|
|
357
|
+
await testSetup!.mockMouse.click(messageCol + 1, messageRow);
|
|
358
|
+
await testSetup!.renderOnce();
|
|
359
|
+
await testSetup!.renderOnce();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const frame = testSetup.captureCharFrame();
|
|
363
|
+
expect(frame).toContain("Type a message...");
|
|
364
|
+
expect(frame).not.toContain("Reply to @user1...");
|
|
365
|
+
expect(frame).not.toContain("Cancel");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("down arrow does not jump to the oldest message when nothing is selected", async () => {
|
|
369
|
+
const controller = createController({
|
|
370
|
+
messages: [makeMessage(1)],
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await act(async () => {
|
|
374
|
+
testSetup = await testRender(createHarness(controller, {
|
|
375
|
+
width: 72,
|
|
376
|
+
height: 12,
|
|
377
|
+
}), {
|
|
378
|
+
width: 72,
|
|
379
|
+
height: 12,
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await flushFrame();
|
|
384
|
+
|
|
385
|
+
await emitKeypress({ name: "down", sequence: "\u001b[B" });
|
|
386
|
+
await flushFrame();
|
|
387
|
+
|
|
388
|
+
const frame = testSetup.captureCharFrame();
|
|
389
|
+
expect(frame).toContain("Type a message...");
|
|
390
|
+
expect(frame).not.toContain("replying to @user1");
|
|
391
|
+
expect(frame).not.toContain("Reply to @user1...");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("up arrow selects the newest message first when nothing is selected", async () => {
|
|
395
|
+
const controller = createController({
|
|
396
|
+
messages: [makeMessage(1), makeMessage(2)],
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await act(async () => {
|
|
400
|
+
testSetup = await testRender(createHarness(controller, {
|
|
401
|
+
width: 72,
|
|
402
|
+
height: 12,
|
|
403
|
+
}), {
|
|
404
|
+
width: 72,
|
|
405
|
+
height: 12,
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await flushFrame();
|
|
410
|
+
|
|
411
|
+
await emitKeypress({ name: "up", sequence: "\u001b[A" });
|
|
412
|
+
await emitKeypress({ name: "return", sequence: "\r" });
|
|
413
|
+
await flushFrame();
|
|
414
|
+
|
|
415
|
+
const frame = testSetup.captureCharFrame();
|
|
416
|
+
expect(frame).toContain("replying to @user2");
|
|
417
|
+
expect(frame).toContain("Reply to @user2...");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("down arrow from the newest selected message returns focus to the composer", async () => {
|
|
421
|
+
const controller = createController({
|
|
422
|
+
messages: [makeMessage(1), makeMessage(2)],
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
await act(async () => {
|
|
426
|
+
testSetup = await testRender(createHarness(controller, {
|
|
427
|
+
width: 72,
|
|
428
|
+
height: 12,
|
|
429
|
+
}), {
|
|
430
|
+
width: 72,
|
|
431
|
+
height: 12,
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
await flushFrame();
|
|
436
|
+
|
|
437
|
+
await emitKeypress({ name: "up", sequence: "\u001b[A" });
|
|
438
|
+
await flushFrame();
|
|
439
|
+
await emitKeypress({ name: "down", sequence: "\u001b[B" });
|
|
440
|
+
await flushFrame();
|
|
441
|
+
|
|
442
|
+
const frame = testSetup.captureCharFrame();
|
|
443
|
+
expect(frame).toContain("Type a message...");
|
|
444
|
+
expect(frame).not.toContain("Reply to @user2...");
|
|
445
|
+
expect(frame).not.toContain("replying to @user2");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("up arrow leaves the composer and selects the latest message when the caret is already at the top", async () => {
|
|
449
|
+
const controller = createController({
|
|
450
|
+
messages: [makeMessage(1)],
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
await act(async () => {
|
|
454
|
+
testSetup = await testRender(createHarness(controller, {
|
|
455
|
+
width: 72,
|
|
456
|
+
height: 12,
|
|
457
|
+
}), {
|
|
458
|
+
width: 72,
|
|
459
|
+
height: 12,
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
await flushFrame();
|
|
464
|
+
|
|
465
|
+
const lines = testSetup.captureCharFrame().split("\n");
|
|
466
|
+
const inputRow = lines.findIndex((line) => line.includes("Type a message..."));
|
|
467
|
+
const inputCol = lines[inputRow]?.indexOf("Type a message...") ?? -1;
|
|
468
|
+
|
|
469
|
+
expect(inputRow).toBeGreaterThanOrEqual(0);
|
|
470
|
+
expect(inputCol).toBeGreaterThanOrEqual(0);
|
|
471
|
+
|
|
472
|
+
await act(async () => {
|
|
473
|
+
await testSetup!.mockMouse.click(inputCol + 1, inputRow);
|
|
474
|
+
await testSetup!.renderOnce();
|
|
475
|
+
await testSetup!.renderOnce();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await act(async () => {
|
|
479
|
+
testSetup!.mockInput.pressArrow("up");
|
|
480
|
+
await testSetup!.renderOnce();
|
|
481
|
+
await testSetup!.renderOnce();
|
|
482
|
+
});
|
|
483
|
+
await emitKeypress({ name: "return", sequence: "\r" });
|
|
484
|
+
await flushFrame();
|
|
485
|
+
|
|
486
|
+
const frame = testSetup.captureCharFrame();
|
|
487
|
+
expect(frame).toContain("replying to @user1");
|
|
488
|
+
expect(frame).toContain("Reply to @user1...");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("up arrow targets the newest bottom message when multiple messages share a timestamp", async () => {
|
|
492
|
+
const controller = createController({
|
|
493
|
+
messages: [
|
|
494
|
+
{
|
|
495
|
+
id: "z-older",
|
|
496
|
+
channelId: "everyone",
|
|
497
|
+
content: "older same timestamp",
|
|
498
|
+
replyToId: null,
|
|
499
|
+
createdAt: "2026-03-30T00:00:01.000Z",
|
|
500
|
+
user: { id: "u1", username: "older", displayName: "Older" },
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
id: "a-newer",
|
|
504
|
+
channelId: "everyone",
|
|
505
|
+
content: "newer same timestamp",
|
|
506
|
+
replyToId: null,
|
|
507
|
+
createdAt: "2026-03-30T00:00:01.000Z",
|
|
508
|
+
user: { id: "u2", username: "newer", displayName: "Newer" },
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
await act(async () => {
|
|
514
|
+
testSetup = await testRender(createHarness(controller, {
|
|
515
|
+
width: 72,
|
|
516
|
+
height: 12,
|
|
517
|
+
}), {
|
|
518
|
+
width: 72,
|
|
519
|
+
height: 12,
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
await flushFrame();
|
|
524
|
+
|
|
525
|
+
const lines = testSetup.captureCharFrame().split("\n");
|
|
526
|
+
const inputRow = lines.findIndex((line) => line.includes("Type a message..."));
|
|
527
|
+
const inputCol = lines[inputRow]?.indexOf("Type a message...") ?? -1;
|
|
528
|
+
|
|
529
|
+
expect(inputRow).toBeGreaterThanOrEqual(0);
|
|
530
|
+
expect(inputCol).toBeGreaterThanOrEqual(0);
|
|
531
|
+
|
|
532
|
+
await act(async () => {
|
|
533
|
+
await testSetup!.mockMouse.click(inputCol + 1, inputRow);
|
|
534
|
+
await testSetup!.renderOnce();
|
|
535
|
+
await testSetup!.renderOnce();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
await act(async () => {
|
|
539
|
+
testSetup!.mockInput.pressArrow("up");
|
|
540
|
+
await testSetup!.renderOnce();
|
|
541
|
+
await testSetup!.renderOnce();
|
|
542
|
+
});
|
|
543
|
+
await emitKeypress({ name: "return", sequence: "\r" });
|
|
544
|
+
await flushFrame();
|
|
545
|
+
|
|
546
|
+
const frame = testSetup.captureCharFrame();
|
|
547
|
+
expect(frame).toContain("replying to @newer");
|
|
548
|
+
expect(frame).toContain("Reply to @newer...");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("leaves one row of clearance when scrolling a selected message into the bottom edge", () => {
|
|
552
|
+
const nextScrollTop = getSelectedMessageScrollTop({
|
|
553
|
+
scrollTop: 12,
|
|
554
|
+
viewportHeight: 4,
|
|
555
|
+
top: 14,
|
|
556
|
+
rowHeight: 2,
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
expect(nextScrollTop).toBe(13);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("up arrow still moves within a multi-line draft before handing off to message selection", async () => {
|
|
563
|
+
const controller = createController({ sessionToken: "token-123", messages: [makeMessage(1)] });
|
|
564
|
+
const sentMessages: string[] = [];
|
|
565
|
+
apiClient.connectChannel = (() => ({
|
|
566
|
+
send: async (content: string) => {
|
|
567
|
+
sentMessages.push(content);
|
|
568
|
+
return {
|
|
569
|
+
id: `server:${sentMessages.length}`,
|
|
570
|
+
channelId: "everyone",
|
|
571
|
+
content,
|
|
572
|
+
replyToId: null,
|
|
573
|
+
createdAt: "2026-03-30T00:00:30.000Z",
|
|
574
|
+
user: { id: "u0", username: "vince", displayName: "Vince" },
|
|
575
|
+
};
|
|
576
|
+
},
|
|
577
|
+
close: () => {},
|
|
578
|
+
})) as typeof apiClient.connectChannel;
|
|
579
|
+
|
|
580
|
+
await act(async () => {
|
|
581
|
+
testSetup = await testRender(createHarness(controller, {
|
|
582
|
+
width: 60,
|
|
583
|
+
height: 12,
|
|
584
|
+
}), {
|
|
585
|
+
width: 60,
|
|
586
|
+
height: 12,
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await flushFrame();
|
|
591
|
+
|
|
592
|
+
const lines = testSetup.captureCharFrame().split("\n");
|
|
593
|
+
const inputRow = lines.findIndex((line) => line.includes("Type a message..."));
|
|
594
|
+
const inputCol = lines[inputRow]?.indexOf("Type a message...") ?? -1;
|
|
595
|
+
|
|
596
|
+
expect(inputRow).toBeGreaterThanOrEqual(0);
|
|
597
|
+
expect(inputCol).toBeGreaterThanOrEqual(0);
|
|
598
|
+
|
|
599
|
+
await act(async () => {
|
|
600
|
+
await testSetup!.mockMouse.click(inputCol + 1, inputRow);
|
|
601
|
+
await testSetup!.renderOnce();
|
|
602
|
+
await testSetup!.renderOnce();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
await act(async () => {
|
|
606
|
+
await testSetup!.mockInput.typeText("first line");
|
|
607
|
+
await testSetup!.renderOnce();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
await emitKeypress({ name: "return", sequence: "\r", shift: true });
|
|
611
|
+
|
|
612
|
+
await act(async () => {
|
|
613
|
+
await testSetup!.mockInput.typeText("second line");
|
|
614
|
+
await testSetup!.renderOnce();
|
|
615
|
+
await testSetup!.renderOnce();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
await emitKeypress({ name: "up", sequence: "\u001b[A" });
|
|
619
|
+
|
|
620
|
+
await act(async () => {
|
|
621
|
+
testSetup!.mockInput.pressEnter();
|
|
622
|
+
await testSetup!.renderOnce();
|
|
623
|
+
await testSetup!.renderOnce();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
expect(sentMessages).toEqual(["first line\nsecond line"]);
|
|
627
|
+
expect(testSetup.captureCharFrame()).not.toContain("replying to @user1");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("shows a clear reply composer state when a reply target is active", async () => {
|
|
631
|
+
const controller = createController({
|
|
632
|
+
messages: [makeMessage(1)],
|
|
633
|
+
replyToId: "m1",
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
await act(async () => {
|
|
637
|
+
testSetup = await testRender(createHarness(controller, {
|
|
638
|
+
width: 72,
|
|
639
|
+
height: 12,
|
|
640
|
+
}), {
|
|
641
|
+
width: 72,
|
|
642
|
+
height: 12,
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
await flushFrame();
|
|
647
|
+
|
|
648
|
+
const frame = testSetup.captureCharFrame();
|
|
649
|
+
expect(frame).toContain("replying to @user1");
|
|
650
|
+
expect(frame).toContain("Cancel");
|
|
651
|
+
expect(frame).toContain("Reply to @user1...");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("uses selected text colors for selected message rows", async () => {
|
|
655
|
+
const controller = createController({
|
|
656
|
+
messages: [makeMessage(1), makeMessage(2)],
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
await act(async () => {
|
|
660
|
+
testSetup = await testRender(createHarness(controller, {
|
|
661
|
+
width: 72,
|
|
662
|
+
height: 12,
|
|
663
|
+
}), {
|
|
664
|
+
width: 72,
|
|
665
|
+
height: 12,
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
await flushFrame();
|
|
670
|
+
|
|
671
|
+
await emitKeypress({ name: "up", sequence: "\u001b[A" });
|
|
672
|
+
await flushFrame();
|
|
673
|
+
|
|
674
|
+
const expectedSelectedFg = hexToRgbaInts(colors.selectedText);
|
|
675
|
+
const frame = testSetup.captureSpans();
|
|
676
|
+
const headerLine = frame.lines.find((line) => lineText(line).includes("user2"));
|
|
677
|
+
const bodyLine = frame.lines.find((line) => lineText(line).includes("message 2"));
|
|
678
|
+
const headerSpan = headerLine?.spans.find((span) => span.text.includes("user2"));
|
|
679
|
+
const bodySpan = bodyLine?.spans.find((span) => span.text.includes("message 2"));
|
|
680
|
+
|
|
681
|
+
expect(headerSpan).toBeDefined();
|
|
682
|
+
expect(bodySpan).toBeDefined();
|
|
683
|
+
expect(headerSpan!.fg.toInts().join(",")).toBe(expectedSelectedFg);
|
|
684
|
+
expect(bodySpan!.fg.toInts().join(",")).toBe(expectedSelectedFg);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("does not use escape as a pane-close shortcut", async () => {
|
|
688
|
+
const controller = createController({
|
|
689
|
+
messages: [makeMessage(1)],
|
|
690
|
+
});
|
|
691
|
+
let closeCalls = 0;
|
|
692
|
+
|
|
693
|
+
await act(async () => {
|
|
694
|
+
testSetup = await testRender(createHarness(controller, {
|
|
695
|
+
width: 72,
|
|
696
|
+
height: 12,
|
|
697
|
+
close: () => {
|
|
698
|
+
closeCalls += 1;
|
|
699
|
+
},
|
|
700
|
+
}), {
|
|
701
|
+
width: 72,
|
|
702
|
+
height: 12,
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
await flushFrame();
|
|
707
|
+
|
|
708
|
+
await act(async () => {
|
|
709
|
+
testSetup!.mockInput.pressKey("escape");
|
|
710
|
+
await testSetup!.renderOnce();
|
|
711
|
+
await testSetup!.renderOnce();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
expect(closeCalls).toBe(0);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("grows the composer for multi-line drafts", async () => {
|
|
718
|
+
const controller = createController();
|
|
719
|
+
|
|
720
|
+
await act(async () => {
|
|
721
|
+
testSetup = await testRender(createHarness(controller, {
|
|
722
|
+
width: 32,
|
|
723
|
+
height: 12,
|
|
724
|
+
}), {
|
|
725
|
+
width: 32,
|
|
726
|
+
height: 12,
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
await flushFrame();
|
|
731
|
+
|
|
732
|
+
const frameBeforeClick = testSetup.captureCharFrame().split("\n");
|
|
733
|
+
const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
|
|
734
|
+
const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
|
|
735
|
+
|
|
736
|
+
expect(inputRow).toBeGreaterThanOrEqual(0);
|
|
737
|
+
expect(inputCol).toBeGreaterThanOrEqual(0);
|
|
738
|
+
|
|
739
|
+
await act(async () => {
|
|
740
|
+
await testSetup!.mockMouse.click(inputCol + 1, inputRow);
|
|
741
|
+
await testSetup!.renderOnce();
|
|
742
|
+
await testSetup!.renderOnce();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
await act(async () => {
|
|
746
|
+
await testSetup!.mockInput.typeText("alpha bravo");
|
|
747
|
+
await testSetup!.renderOnce();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await emitKeypress({ name: "return", sequence: "\r", shift: true });
|
|
751
|
+
|
|
752
|
+
await act(async () => {
|
|
753
|
+
await testSetup!.mockInput.typeText("charlie delta");
|
|
754
|
+
await testSetup!.renderOnce();
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
await emitKeypress({ name: "return", sequence: "\r", shift: true });
|
|
758
|
+
|
|
759
|
+
await act(async () => {
|
|
760
|
+
await testSetup!.mockInput.typeText("echo foxtrot");
|
|
761
|
+
await testSetup!.renderOnce();
|
|
762
|
+
await testSetup!.renderOnce();
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const rows = testSetup.captureCharFrame().split("\n");
|
|
766
|
+
const firstRow = rows.findIndex((line) => line.includes("alpha bravo"));
|
|
767
|
+
const secondRow = rows.findIndex((line) => line.includes("charlie delta"));
|
|
768
|
+
const thirdRow = rows.findIndex((line) => line.includes("echo foxtrot"));
|
|
769
|
+
|
|
770
|
+
expect(firstRow).toBeGreaterThanOrEqual(0);
|
|
771
|
+
expect(secondRow).toBeGreaterThan(firstRow);
|
|
772
|
+
expect(thirdRow).toBeGreaterThan(secondRow);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("keeps Enter as send and uses Shift+Enter for composer newlines", async () => {
|
|
776
|
+
const controller = createController({ sessionToken: "token-123" });
|
|
777
|
+
const sentMessages: string[] = [];
|
|
778
|
+
apiClient.connectChannel = (() => ({
|
|
779
|
+
send: async (content: string) => {
|
|
780
|
+
sentMessages.push(content);
|
|
781
|
+
return {
|
|
782
|
+
id: `server:${sentMessages.length}`,
|
|
783
|
+
channelId: "everyone",
|
|
784
|
+
content,
|
|
785
|
+
replyToId: null,
|
|
786
|
+
createdAt: "2026-03-30T00:00:30.000Z",
|
|
787
|
+
user: { id: "u0", username: "vince", displayName: "Vince" },
|
|
788
|
+
};
|
|
789
|
+
},
|
|
790
|
+
close: () => {},
|
|
791
|
+
})) as typeof apiClient.connectChannel;
|
|
792
|
+
|
|
793
|
+
await act(async () => {
|
|
794
|
+
testSetup = await testRender(createHarness(controller), {
|
|
795
|
+
width: 60,
|
|
796
|
+
height: 12,
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
await flushFrame();
|
|
801
|
+
|
|
802
|
+
const frameBeforeClick = testSetup.captureCharFrame().split("\n");
|
|
803
|
+
const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
|
|
804
|
+
const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
|
|
805
|
+
|
|
806
|
+
expect(inputRow).toBeGreaterThanOrEqual(0);
|
|
807
|
+
expect(inputCol).toBeGreaterThanOrEqual(0);
|
|
808
|
+
|
|
809
|
+
await act(async () => {
|
|
810
|
+
await testSetup!.mockMouse.click(inputCol + 1, inputRow);
|
|
811
|
+
await testSetup!.renderOnce();
|
|
812
|
+
await testSetup!.renderOnce();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
await act(async () => {
|
|
816
|
+
await testSetup!.mockInput.typeText("first line");
|
|
817
|
+
await testSetup!.renderOnce();
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
await emitKeypress({ name: "return", sequence: "\r", shift: true });
|
|
821
|
+
|
|
822
|
+
await act(async () => {
|
|
823
|
+
await testSetup!.mockInput.typeText("second line");
|
|
824
|
+
await testSetup!.renderOnce();
|
|
825
|
+
await testSetup!.renderOnce();
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
const frameAfterNewline = testSetup.captureCharFrame();
|
|
829
|
+
expect(frameAfterNewline).toContain("first line");
|
|
830
|
+
expect(frameAfterNewline).toContain("second line");
|
|
831
|
+
expect(sentMessages).toEqual([]);
|
|
832
|
+
|
|
833
|
+
await act(async () => {
|
|
834
|
+
testSetup!.mockInput.pressEnter();
|
|
835
|
+
await testSetup!.renderOnce();
|
|
836
|
+
await testSetup!.renderOnce();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
expect(sentMessages).toEqual(["first line\nsecond line"]);
|
|
840
|
+
});
|
|
841
|
+
|
|
218
842
|
test("renders optimistic sends with a sending status", async () => {
|
|
219
843
|
const controller = createController({
|
|
220
844
|
messages: [{
|
|
@@ -280,6 +904,65 @@ describe("ChatContent", () => {
|
|
|
280
904
|
expect(frameAfterUpdate).not.toContain("message 1");
|
|
281
905
|
});
|
|
282
906
|
|
|
907
|
+
test("keeps the bottom reachable after narrowing the chat pane", async () => {
|
|
908
|
+
const controller = createController({
|
|
909
|
+
messages: [
|
|
910
|
+
{
|
|
911
|
+
...makeMessage(1),
|
|
912
|
+
content: "A wide pane can fit this update in fewer rows, but a narrow pane needs several wrapped rows before the final message.",
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
...makeMessage(2),
|
|
916
|
+
content: "Another wrapped message takes up more transcript height after the pane gets smaller.",
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
...makeMessage(3),
|
|
920
|
+
user: { id: "u3", username: "vince", displayName: "Vince" },
|
|
921
|
+
content: "newest message stays reachable after shrink",
|
|
922
|
+
},
|
|
923
|
+
],
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat-resizable"));
|
|
927
|
+
let resize: ((size: { width: number; height: number }) => void) | undefined;
|
|
928
|
+
|
|
929
|
+
function ResizableChatHarness() {
|
|
930
|
+
const [size, setSize] = useState({ width: 78, height: 12 });
|
|
931
|
+
resize = setSize;
|
|
932
|
+
|
|
933
|
+
return (
|
|
934
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
935
|
+
<ChatContent
|
|
936
|
+
controller={controller}
|
|
937
|
+
width={size.width}
|
|
938
|
+
height={size.height}
|
|
939
|
+
focused
|
|
940
|
+
/>
|
|
941
|
+
</AppContext>
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
await act(async () => {
|
|
946
|
+
testSetup = await testRender(<ResizableChatHarness />, {
|
|
947
|
+
width: 78,
|
|
948
|
+
height: 12,
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
await flushFrame();
|
|
953
|
+
|
|
954
|
+
await act(async () => {
|
|
955
|
+
resize?.({ width: 42, height: 9 });
|
|
956
|
+
testSetup!.resize(42, 9);
|
|
957
|
+
});
|
|
958
|
+
await flushFrame();
|
|
959
|
+
|
|
960
|
+
const frame = testSetup!.captureCharFrame();
|
|
961
|
+
expect(frame).toContain("vince");
|
|
962
|
+
expect(frame).toContain("newest message stays");
|
|
963
|
+
expect(frame).toContain("reachable after shrink");
|
|
964
|
+
});
|
|
965
|
+
|
|
283
966
|
test("renders ticker badges and opens a floating detail pane on click", async () => {
|
|
284
967
|
const controller = createController({
|
|
285
968
|
messages: [{
|
|
@@ -355,6 +1038,34 @@ describe("ChatContent", () => {
|
|
|
355
1038
|
expect(opened).toEqual(["TSLA"]);
|
|
356
1039
|
});
|
|
357
1040
|
|
|
1041
|
+
test("renders detected links in chat messages", async () => {
|
|
1042
|
+
const controller = createController({
|
|
1043
|
+
messages: [{
|
|
1044
|
+
id: "m1",
|
|
1045
|
+
channelId: "everyone",
|
|
1046
|
+
content: "Read https://example.com/story.",
|
|
1047
|
+
replyToId: null,
|
|
1048
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
1049
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
1050
|
+
}],
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
await act(async () => {
|
|
1054
|
+
testSetup = await testRender(createHarness(controller, {
|
|
1055
|
+
width: 60,
|
|
1056
|
+
height: 12,
|
|
1057
|
+
}), {
|
|
1058
|
+
width: 60,
|
|
1059
|
+
height: 12,
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
await flushFrame();
|
|
1064
|
+
|
|
1065
|
+
const frame = testSetup.captureCharFrame();
|
|
1066
|
+
expect(frame).toContain("https://example.com/story.");
|
|
1067
|
+
});
|
|
1068
|
+
|
|
358
1069
|
test("shows a saved-login read-only footer when a session token is cached", async () => {
|
|
359
1070
|
const controller = createController({
|
|
360
1071
|
sessionToken: "token-123",
|
|
@@ -544,7 +1255,7 @@ describe("ChatContent", () => {
|
|
|
544
1255
|
},
|
|
545
1256
|
showWidget: () => {},
|
|
546
1257
|
hideWidget: () => {},
|
|
547
|
-
|
|
1258
|
+
notify: () => {},
|
|
548
1259
|
} as any);
|
|
549
1260
|
|
|
550
1261
|
const loginCommand = registeredCommands.find((command) => command.id === "auth-login");
|