gloomberb 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -52
- package/package.json +1 -1
- package/src/api-client/chat.ts +8 -0
- package/src/api-client/index.test.ts +30 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/normalizers.ts +1 -0
- package/src/api-client/types.ts +1 -0
- package/src/capabilities/factories.ts +4 -0
- package/src/components/chart/comparison/data.test.ts +54 -0
- package/src/components/chart/comparison/data.ts +47 -24
- package/src/components/chart/core/projection.ts +51 -27
- package/src/components/data-table/stack-view.test.tsx +14 -11
- package/src/components/data-table/stack-view.tsx +1 -2
- package/src/components/data-table/view.test.tsx +24 -18
- package/src/components/data-table/view.tsx +313 -83
- package/src/components/feed-data-table/stack-view.tsx +6 -8
- package/src/components/layout/pane/footer/index.test.tsx +6 -0
- package/src/components/layout/pane/footer/index.tsx +14 -2
- package/src/components/layout/pane/title.ts +5 -1
- package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
- package/src/components/pane-settings-dialog/tui.tsx +1 -1
- package/src/components/ticker/list-table-view.tsx +11 -25
- package/src/components/ui/choice-dialog.tsx +12 -2
- package/src/components/ui/multi-select/dialog.tsx +17 -5
- package/src/components/ui/shortcut-hint.tsx +14 -2
- package/src/components/ui/table-layout.test.ts +2 -2
- package/src/components/ui/table-layout.ts +8 -2
- package/src/components/ui/tabs.tsx +20 -6
- package/src/components/ui/ui.test.tsx +30 -5
- package/src/market-data/coordinator/auxiliary.ts +29 -1
- package/src/market-data/coordinator/index.ts +17 -1
- package/src/market-data/hooks.tsx +17 -1
- package/src/market-data/selectors.ts +4 -0
- package/src/plugins/builtin/account-management/form-components.tsx +0 -4
- package/src/plugins/builtin/account-management/model.test.ts +54 -0
- package/src/plugins/builtin/account-management/model.ts +13 -3
- package/src/plugins/builtin/account-management/pane.tsx +5 -11
- package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
- package/src/plugins/builtin/alerts/pane.tsx +5 -3
- package/src/plugins/builtin/analytics/index.tsx +0 -3
- package/src/plugins/builtin/analytics/view.tsx +6 -5
- package/src/plugins/builtin/broker-manager/index.tsx +6 -6
- package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
- package/src/plugins/builtin/changelog/index.tsx +40 -27
- package/src/plugins/builtin/changelog/model.test.ts +77 -0
- package/src/plugins/builtin/changelog/model.ts +98 -0
- package/src/plugins/builtin/chat/channel-labels.ts +53 -0
- package/src/plugins/builtin/chat/channels.test.ts +28 -0
- package/src/plugins/builtin/chat/channels.ts +36 -19
- package/src/plugins/builtin/chat/chat.test.tsx +264 -5
- package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
- package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
- package/src/plugins/builtin/chat/content/composer.tsx +23 -2
- package/src/plugins/builtin/chat/content/index.tsx +61 -0
- package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
- package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
- package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
- package/src/plugins/builtin/chat/content/types.ts +1 -0
- package/src/plugins/builtin/chat/controller/index.ts +41 -0
- package/src/plugins/builtin/chat/controller/messages.ts +18 -0
- package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
- package/src/plugins/builtin/chat/controller/utils.ts +6 -3
- package/src/plugins/builtin/chat/controller/view.ts +1 -1
- package/src/plugins/builtin/chat/controller.test.ts +189 -0
- package/src/plugins/builtin/chat/edit-window.ts +24 -0
- package/src/plugins/builtin/chat/layout.ts +94 -31
- package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
- package/src/plugins/builtin/chat/message/render-state.ts +5 -1
- package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
- package/src/plugins/builtin/chat/message/types.ts +2 -0
- package/src/plugins/builtin/chat/pane.tsx +35 -7
- package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
- package/src/plugins/builtin/chat/status-widget.tsx +29 -11
- package/src/plugins/builtin/chat/test-harness.tsx +2 -0
- package/src/plugins/builtin/cloud/plugin.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
- package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
- package/src/plugins/builtin/earnings/index.tsx +8 -7
- package/src/plugins/builtin/econ/index.tsx +6 -8
- package/src/plugins/builtin/help/index.tsx +6 -0
- package/src/plugins/builtin/holders/pane.tsx +7 -6
- package/src/plugins/builtin/market-movers/index.tsx +6 -3
- package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
- package/src/plugins/builtin/options/view.tsx +16 -8
- package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
- package/src/plugins/builtin/research/index.test.ts +30 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
- package/src/plugins/builtin/sec/filing-documents.ts +66 -0
- package/src/plugins/builtin/sec/index.tsx +132 -20
- package/src/plugins/builtin/sectors/index.tsx +13 -23
- package/src/plugins/builtin/shared/ticker-request.ts +1 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
- package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
- package/src/plugins/builtin/world-indices/index.tsx +9 -6
- package/src/plugins/prediction-markets/pane.tsx +10 -9
- package/src/plugins/registry/index.ts +3 -0
- package/src/plugins/runtime/context.tsx +1 -0
- package/src/plugins/runtime/index.tsx +4 -0
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
- package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
- package/src/renderers/electrobun/view/host/input.tsx +23 -2
- package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -0
- package/src/sources/provider-router/cache.ts +1 -0
- package/src/sources/provider-router/document-routes.ts +45 -0
- package/src/sources/provider-router/index.ts +4 -0
- package/src/sources/sec-edgar/content.ts +9 -3
- package/src/sources/sec-edgar.test.ts +108 -1
- package/src/sources/sec-edgar.ts +105 -1
- package/src/sources/yahoo-finance.ts +4 -0
- package/src/test-support/plugin-runtime.ts +1 -0
- package/src/types/data-provider.ts +11 -0
- package/src/ui/host.tsx +1 -0
- package/src/version.ts +1 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { CommandResultDef, GloomPluginContext } from "../../../types/plugin";
|
|
2
|
-
import type { ChatChannel } from "../../../api-client";
|
|
2
|
+
import type { ChatChannel, ChatChannelState } from "../../../api-client";
|
|
3
|
+
import type { AppConfig, PaneInstanceConfig } from "../../../types/config";
|
|
3
4
|
import { chatController } from "./controller";
|
|
5
|
+
import { formatChannelLabel } from "./channel-labels";
|
|
6
|
+
|
|
7
|
+
export { formatChannelLabel, formatChatPaneTitle } from "./channel-labels";
|
|
4
8
|
|
|
5
9
|
export const DEFAULT_CHAT_CHANNEL_ID = "everyone";
|
|
6
10
|
export const LAST_VISITED_CHAT_CHANNEL_KEY = "lastChatChannelId";
|
|
@@ -21,15 +25,28 @@ export function getLastVisitedChatChannelId(config: { pluginConfig: Record<strin
|
|
|
21
25
|
return normalizeChannelId(config.pluginConfig["gloomberb-cloud"]?.[LAST_VISITED_CHAT_CHANNEL_KEY] as string | undefined);
|
|
22
26
|
}
|
|
23
27
|
|
|
24
|
-
export function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
export function findChatPaneInstanceForChannel(config: AppConfig, channelId: string): PaneInstanceConfig | undefined {
|
|
29
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
30
|
+
return config.layout.instances.find((instance) => (
|
|
31
|
+
instance.paneId === "chat"
|
|
32
|
+
&& normalizeChannelId(typeof instance.settings?.channelId === "string" ? instance.settings.channelId : null) === normalizedChannelId
|
|
33
|
+
));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getPreferredChatOpenChannelId(
|
|
37
|
+
config: AppConfig,
|
|
38
|
+
snapshot?: { channels: ChatChannel[]; channelStates: ChatChannelState[] },
|
|
39
|
+
) {
|
|
40
|
+
if (!snapshot) return getLastVisitedChatChannelId(config);
|
|
41
|
+
const channelById = new Map(snapshot.channels.map((channel) => [channel.id, channel]));
|
|
42
|
+
const unreadStates = snapshot.channelStates
|
|
43
|
+
.filter((state) => state.unreadCount > 0 && channelById.has(state.channelId))
|
|
44
|
+
.sort((a, b) => b.unreadCount - a.unreadCount);
|
|
45
|
+
const unreadConversation = unreadStates.find((state) => {
|
|
46
|
+
const kind = channelById.get(state.channelId)?.kind ?? "public";
|
|
47
|
+
return kind === "direct" || kind === "group";
|
|
48
|
+
});
|
|
49
|
+
return normalizeChannelId(unreadConversation?.channelId ?? unreadStates[0]?.channelId ?? getLastVisitedChatChannelId(config));
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
function conversationDetail(channel: ChatChannel): string {
|
|
@@ -63,20 +80,20 @@ export function hasOnlyDmUsernameArgs(value: string): boolean {
|
|
|
63
80
|
return parts.length > 0 && parts.every((part) => CHAT_USERNAME_ARG.test(part));
|
|
64
81
|
}
|
|
65
82
|
|
|
66
|
-
function openChatChannelFromCommand(ctx: GloomPluginContext, channelId: string): void {
|
|
83
|
+
function openChatChannelFromCommand(ctx: Pick<GloomPluginContext, "createPaneFromTemplate" | "focusPane" | "getConfig">, channelId: string): void {
|
|
84
|
+
const existingInstance = findChatPaneInstanceForChannel(ctx.getConfig(), channelId);
|
|
85
|
+
if (existingInstance) {
|
|
86
|
+
ctx.focusPane(existingInstance.instanceId);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
67
89
|
ctx.createPaneFromTemplate("new-chat-pane", { arg: channelId });
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
export function openDefaultChatFromCommand(
|
|
71
|
-
ctx: Pick<GloomPluginContext, "createPaneFromTemplate" | "
|
|
93
|
+
ctx: Pick<GloomPluginContext, "createPaneFromTemplate" | "focusPane" | "getConfig">,
|
|
72
94
|
): void {
|
|
73
95
|
const config = ctx.getConfig();
|
|
74
|
-
|
|
75
|
-
if (hasChatPane) {
|
|
76
|
-
ctx.showPane("chat");
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
ctx.createPaneFromTemplate("new-chat-pane", { arg: getLastVisitedChatChannelId(config) });
|
|
96
|
+
openChatChannelFromCommand(ctx, getPreferredChatOpenChannelId(config, chatController.getSnapshot()));
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
export async function openDmTargetFromCommand(ctx: GloomPluginContext, usernames: string[]): Promise<void> {
|
|
@@ -143,7 +160,7 @@ export function buildDmCommandResults(ctx: GloomPluginContext, arg: string): Com
|
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
export function channelPrefix(channel: ChatChannel | undefined, active: boolean) {
|
|
146
|
-
if (channel?.kind === "direct") return
|
|
163
|
+
if (channel?.kind === "direct") return " ";
|
|
147
164
|
if (channel?.kind === "group") return active ? "+" : " ";
|
|
148
165
|
return active ? "#" : " ";
|
|
149
166
|
}
|
|
@@ -9,7 +9,7 @@ import type { ChatMessage } from "../../../api-client";
|
|
|
9
9
|
import { apiClient } from "../../../api-client";
|
|
10
10
|
import { PluginRenderProvider } from "../../runtime";
|
|
11
11
|
import { setSharedRegistryForTests } from "../../registry";
|
|
12
|
-
import { ChatStatusWidget } from ".";
|
|
12
|
+
import { ChatContent, ChatStatusWidget } from ".";
|
|
13
13
|
import {
|
|
14
14
|
cleanupChatTest,
|
|
15
15
|
createChatTestControls,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
createHarness,
|
|
18
18
|
hexToRgbaInts,
|
|
19
19
|
installChatApiTestDefaults,
|
|
20
|
+
installServerChannels,
|
|
20
21
|
lineText,
|
|
21
22
|
makeMessage,
|
|
22
23
|
type ChatTestSetup,
|
|
@@ -29,6 +30,21 @@ function setup(): ChatTestSetup {
|
|
|
29
30
|
}
|
|
30
31
|
const { flushFrame, emitKeypress } = createChatTestControls(setup);
|
|
31
32
|
|
|
33
|
+
function recentChatTimestamp(offsetMs = 60_000) {
|
|
34
|
+
return new Date(Date.now() - offsetMs).toISOString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeOwnMessage(content = "typo", createdAt = recentChatTimestamp()): ChatMessage {
|
|
38
|
+
return {
|
|
39
|
+
id: "m-own",
|
|
40
|
+
channelId: "everyone",
|
|
41
|
+
content,
|
|
42
|
+
replyToId: null,
|
|
43
|
+
createdAt,
|
|
44
|
+
user: { id: "u0", username: "vince", displayName: "Vince" },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
beforeEach(() => {
|
|
33
49
|
installChatApiTestDefaults();
|
|
34
50
|
});
|
|
@@ -39,6 +55,42 @@ afterEach(async () => {
|
|
|
39
55
|
});
|
|
40
56
|
|
|
41
57
|
describe("ChatContent", () => {
|
|
58
|
+
test("keeps a persisted DM selected while private channels refresh", async () => {
|
|
59
|
+
const controller = createController();
|
|
60
|
+
const dmChannelId = "dm:test";
|
|
61
|
+
installServerChannels(controller, [
|
|
62
|
+
{ id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
|
|
63
|
+
]);
|
|
64
|
+
controller.refreshChannels = async () => {};
|
|
65
|
+
controller.refreshPresence = async () => {};
|
|
66
|
+
controller.refreshSession = async () => {};
|
|
67
|
+
controller.refreshChannelMessages = async () => {};
|
|
68
|
+
const channelChanges: string[] = [];
|
|
69
|
+
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
|
|
70
|
+
|
|
71
|
+
await act(async () => {
|
|
72
|
+
testSetup = await testRender(
|
|
73
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
74
|
+
<PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
|
|
75
|
+
<ChatContent
|
|
76
|
+
controller={controller}
|
|
77
|
+
width={60}
|
|
78
|
+
height={12}
|
|
79
|
+
focused
|
|
80
|
+
channelId={dmChannelId}
|
|
81
|
+
onChannelChange={(nextChannelId) => channelChanges.push(nextChannelId)}
|
|
82
|
+
/>
|
|
83
|
+
</PluginRenderProvider>
|
|
84
|
+
</AppContext>,
|
|
85
|
+
{ width: 60, height: 12 },
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await flushFrame();
|
|
90
|
+
|
|
91
|
+
expect(channelChanges).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
42
94
|
test("focuses the prompt on click and preserves typing order", async () => {
|
|
43
95
|
const controller = createController();
|
|
44
96
|
|
|
@@ -179,6 +231,108 @@ describe("ChatContent", () => {
|
|
|
179
231
|
expect(bodyLine).not.toContain("Reply");
|
|
180
232
|
});
|
|
181
233
|
|
|
234
|
+
test("shows the edit action next to reply for the latest own message", async () => {
|
|
235
|
+
const ownMessage = makeOwnMessage();
|
|
236
|
+
const controller = createController({
|
|
237
|
+
messages: [makeMessage(1), ownMessage],
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await act(async () => {
|
|
241
|
+
testSetup = await testRender(createHarness(controller, {
|
|
242
|
+
width: 72,
|
|
243
|
+
height: 12,
|
|
244
|
+
}), {
|
|
245
|
+
width: 72,
|
|
246
|
+
height: 12,
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await flushFrame();
|
|
251
|
+
|
|
252
|
+
await emitKeypress({ name: "up", sequence: "\u001b[A" });
|
|
253
|
+
await flushFrame();
|
|
254
|
+
|
|
255
|
+
const lines = setup().captureCharFrame().split("\n");
|
|
256
|
+
const headerLine = lines.find((line) => line.includes("vince"));
|
|
257
|
+
|
|
258
|
+
expect(headerLine).toContain("Reply");
|
|
259
|
+
expect(headerLine).toContain("Edit");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("hides the edit action after the edit window expires", async () => {
|
|
263
|
+
const ownMessage = makeOwnMessage("typo", recentChatTimestamp(16 * 60_000));
|
|
264
|
+
const controller = createController({
|
|
265
|
+
messages: [makeMessage(1), ownMessage],
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await act(async () => {
|
|
269
|
+
testSetup = await testRender(createHarness(controller, {
|
|
270
|
+
width: 72,
|
|
271
|
+
height: 12,
|
|
272
|
+
}), {
|
|
273
|
+
width: 72,
|
|
274
|
+
height: 12,
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await flushFrame();
|
|
279
|
+
|
|
280
|
+
await emitKeypress({ name: "up", sequence: "\u001b[A" });
|
|
281
|
+
await flushFrame();
|
|
282
|
+
|
|
283
|
+
const lines = setup().captureCharFrame().split("\n");
|
|
284
|
+
const headerLine = lines.find((line) => line.includes("vince"));
|
|
285
|
+
|
|
286
|
+
expect(headerLine).toContain("Reply");
|
|
287
|
+
expect(headerLine).not.toContain("Edit");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("up arrow from an empty focused composer edits the latest own message", async () => {
|
|
291
|
+
const ownMessage = makeOwnMessage();
|
|
292
|
+
const controller = createController({
|
|
293
|
+
messages: [makeMessage(1), ownMessage],
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await act(async () => {
|
|
297
|
+
testSetup = await testRender(createHarness(controller, {
|
|
298
|
+
width: 72,
|
|
299
|
+
height: 12,
|
|
300
|
+
}), {
|
|
301
|
+
width: 72,
|
|
302
|
+
height: 12,
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await flushFrame();
|
|
307
|
+
|
|
308
|
+
const lines = setup().captureCharFrame().split("\n");
|
|
309
|
+
const inputRow = lines.findIndex((line) => line.includes("Type a message..."));
|
|
310
|
+
const inputCol = lines[inputRow]?.indexOf("Type a message...") ?? -1;
|
|
311
|
+
expect(inputRow).toBeGreaterThanOrEqual(0);
|
|
312
|
+
expect(inputCol).toBeGreaterThanOrEqual(0);
|
|
313
|
+
|
|
314
|
+
await act(async () => {
|
|
315
|
+
await setup().mockMouse.click(inputCol + 1, inputRow);
|
|
316
|
+
await setup().renderOnce();
|
|
317
|
+
await setup().renderOnce();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await emitKeypress({ name: "up", sequence: "\u001b[A" });
|
|
321
|
+
await flushFrame();
|
|
322
|
+
|
|
323
|
+
const frame = setup().captureCharFrame();
|
|
324
|
+
expect(frame).toContain("editing");
|
|
325
|
+
expect(frame).toContain("> typo");
|
|
326
|
+
|
|
327
|
+
await act(async () => {
|
|
328
|
+
await setup().mockInput.typeText(" fix");
|
|
329
|
+
await setup().renderOnce();
|
|
330
|
+
await setup().renderOnce();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
expect(setup().captureCharFrame()).toContain("> typo fix");
|
|
334
|
+
});
|
|
335
|
+
|
|
182
336
|
test("down arrow from the newest selected message returns focus to the composer", async () => {
|
|
183
337
|
const controller = createController({
|
|
184
338
|
messages: [makeMessage(1), makeMessage(2)],
|
|
@@ -867,6 +1021,36 @@ describe("ChatContent", () => {
|
|
|
867
1021
|
expect(frame).toContain("https://example.com/story.");
|
|
868
1022
|
});
|
|
869
1023
|
|
|
1024
|
+
test("keeps long link query strings from spilling into following terminal rows", async () => {
|
|
1025
|
+
const controller = createController({
|
|
1026
|
+
messages: [{
|
|
1027
|
+
id: "m1",
|
|
1028
|
+
channelId: "everyone",
|
|
1029
|
+
content: "again:\nhttps://github.com/houmain/keymapper/issues?weird_one=the_rest_of the query parameters got lost entirely :)",
|
|
1030
|
+
replyToId: null,
|
|
1031
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
1032
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
1033
|
+
}],
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
await act(async () => {
|
|
1037
|
+
testSetup = await testRender(createHarness(controller, {
|
|
1038
|
+
width: 60,
|
|
1039
|
+
height: 12,
|
|
1040
|
+
}), {
|
|
1041
|
+
width: 60,
|
|
1042
|
+
height: 12,
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
await flushFrame();
|
|
1047
|
+
|
|
1048
|
+
const frame = setup().captureCharFrame();
|
|
1049
|
+
expect(frame).toContain("https://github.com/houmain/keymapper/issues?weird_one=");
|
|
1050
|
+
expect(frame).toContain("the_rest_of the query parameters got lost entirely :)");
|
|
1051
|
+
expect(frame).not.toContain("therquery=parametersfgot");
|
|
1052
|
+
});
|
|
1053
|
+
|
|
870
1054
|
test("shows a saved-login read-only footer when a session token is cached", async () => {
|
|
871
1055
|
const controller = createController({
|
|
872
1056
|
sessionToken: "token-123",
|
|
@@ -993,13 +1177,17 @@ describe("ChatContent", () => {
|
|
|
993
1177
|
sessionToken: "token-123",
|
|
994
1178
|
user: { id: "u1", username: "vince", emailVerified: true },
|
|
995
1179
|
});
|
|
996
|
-
const
|
|
1180
|
+
const openedTemplates: Array<{ templateId: string; options?: { arg?: string } }> = [];
|
|
1181
|
+
const focusedPanes: string[] = [];
|
|
997
1182
|
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
|
|
998
1183
|
state.config.disabledPlugins = [];
|
|
999
1184
|
|
|
1000
1185
|
const runtime = createTestPluginRuntime({
|
|
1001
|
-
|
|
1002
|
-
|
|
1186
|
+
createPaneFromTemplate(templateId: string, options?: { arg?: string }) {
|
|
1187
|
+
openedTemplates.push({ templateId, options });
|
|
1188
|
+
},
|
|
1189
|
+
focusPane(paneId: string) {
|
|
1190
|
+
focusedPanes.push(paneId);
|
|
1003
1191
|
},
|
|
1004
1192
|
});
|
|
1005
1193
|
|
|
@@ -1042,7 +1230,78 @@ describe("ChatContent", () => {
|
|
|
1042
1230
|
await setup().renderOnce();
|
|
1043
1231
|
});
|
|
1044
1232
|
|
|
1045
|
-
expect(
|
|
1233
|
+
expect(openedTemplates).toEqual([]);
|
|
1234
|
+
expect(focusedPanes).toHaveLength(1);
|
|
1235
|
+
expect(focusedPanes[0]?.startsWith("chat:")).toBe(true);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
test("opens an unread direct-message channel from the status widget", async () => {
|
|
1239
|
+
const controller = createController({
|
|
1240
|
+
sessionToken: "token-123",
|
|
1241
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
1242
|
+
});
|
|
1243
|
+
const dmChannelId = "dm:test";
|
|
1244
|
+
installServerChannels(controller, [
|
|
1245
|
+
{ id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
|
|
1246
|
+
{
|
|
1247
|
+
id: dmChannelId,
|
|
1248
|
+
name: "@bob",
|
|
1249
|
+
kind: "direct",
|
|
1250
|
+
created_at: "2026-05-27T10:30:03.712Z",
|
|
1251
|
+
dmUser: { id: "u2", username: "bob", displayName: "Bob" },
|
|
1252
|
+
},
|
|
1253
|
+
]);
|
|
1254
|
+
const openedTemplates: Array<{ templateId: string; options?: { arg?: string } }> = [];
|
|
1255
|
+
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
|
|
1256
|
+
state.config.disabledPlugins = [];
|
|
1257
|
+
|
|
1258
|
+
const runtime = createTestPluginRuntime({
|
|
1259
|
+
createPaneFromTemplate(templateId: string, options?: { arg?: string }) {
|
|
1260
|
+
openedTemplates.push({ templateId, options });
|
|
1261
|
+
},
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
await act(async () => {
|
|
1265
|
+
testSetup = await testRender(
|
|
1266
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
1267
|
+
<PluginRenderProvider pluginId="gloomberb-cloud" runtime={runtime}>
|
|
1268
|
+
<ChatStatusWidget controller={controller} />
|
|
1269
|
+
</PluginRenderProvider>
|
|
1270
|
+
</AppContext>,
|
|
1271
|
+
{ width: 40, height: 1 },
|
|
1272
|
+
);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
await flushFrame();
|
|
1276
|
+
|
|
1277
|
+
await act(async () => {
|
|
1278
|
+
(controller as any).mergeMessages(dmChannelId, [{
|
|
1279
|
+
id: "dm-m1",
|
|
1280
|
+
channelId: dmChannelId,
|
|
1281
|
+
content: "private ping",
|
|
1282
|
+
replyToId: null,
|
|
1283
|
+
createdAt: "2026-05-27T10:31:00.000Z",
|
|
1284
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
1285
|
+
} satisfies ChatMessage]);
|
|
1286
|
+
});
|
|
1287
|
+
await flushFrame();
|
|
1288
|
+
|
|
1289
|
+
const frame = setup().captureCharFrame();
|
|
1290
|
+
expect(frame).toContain("vince");
|
|
1291
|
+
expect(frame).toContain("[1]");
|
|
1292
|
+
|
|
1293
|
+
const line = frame.split("\n")[0] ?? "";
|
|
1294
|
+
const badgeCol = line.indexOf("[1]");
|
|
1295
|
+
|
|
1296
|
+
expect(badgeCol).toBeGreaterThanOrEqual(0);
|
|
1297
|
+
|
|
1298
|
+
await act(async () => {
|
|
1299
|
+
await setup().mockMouse.click(badgeCol + 1, 0);
|
|
1300
|
+
await setup().renderOnce();
|
|
1301
|
+
await setup().renderOnce();
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
expect(openedTemplates).toEqual([{ templateId: "new-chat-pane", options: { arg: dmChannelId } }]);
|
|
1046
1305
|
});
|
|
1047
1306
|
|
|
1048
1307
|
});
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
} from "../channels";
|
|
8
8
|
import type { ChatContentController } from "./types";
|
|
9
9
|
|
|
10
|
+
function isConversationChannelId(channelId: string): boolean {
|
|
11
|
+
return channelId.startsWith("dm:") || channelId.startsWith("grp:") || channelId.startsWith("group:");
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
export function useChatChannelNavigation({
|
|
11
15
|
blurInput,
|
|
12
16
|
channelId,
|
|
@@ -83,6 +87,8 @@ export function useChatChannelNavigation({
|
|
|
83
87
|
useEffect(() => {
|
|
84
88
|
if (!onChannelChange || channelsLoading || channels.length === 0) return;
|
|
85
89
|
if (channels.some((channel) => channel.id === channelId)) return;
|
|
90
|
+
// DM/group panes can remount before the private channel catalog is refreshed.
|
|
91
|
+
if (isConversationChannelId(channelId)) return;
|
|
86
92
|
const fallbackChannelId = channels.find((channel) => channel.id === DEFAULT_CHAT_CHANNEL_ID)?.id
|
|
87
93
|
?? channels[0]?.id;
|
|
88
94
|
if (fallbackChannelId) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, useRef } from "react";
|
|
3
|
+
import type { ChatMessage } from "../../../../api-client";
|
|
4
|
+
import { testRender } from "../../../../renderers/opentui/test-utils";
|
|
5
|
+
import { useChatComposerRuntime } from "./composer-runtime";
|
|
6
|
+
|
|
7
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
if (testSetup) {
|
|
11
|
+
await act(async () => {
|
|
12
|
+
testSetup?.renderer.destroy();
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
testSetup = undefined;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("useChatComposerRuntime", () => {
|
|
19
|
+
test("sends to the pending channel ref instead of the stale rendered channel", async () => {
|
|
20
|
+
const sent: Array<{ channelId: string; content: string; replyToId?: string }> = [];
|
|
21
|
+
let sendMessage = () => {};
|
|
22
|
+
|
|
23
|
+
function Harness() {
|
|
24
|
+
const applyingExternalDraftRef = useRef(false);
|
|
25
|
+
const channelIdRef = useRef("dm:test");
|
|
26
|
+
const inputRef = useRef(null);
|
|
27
|
+
const inputValueRef = useRef("PM reply test #2");
|
|
28
|
+
sendMessage = useChatComposerRuntime({
|
|
29
|
+
applyingExternalDraftRef,
|
|
30
|
+
blurInput: () => {},
|
|
31
|
+
canSend: true,
|
|
32
|
+
channelId: "everyone",
|
|
33
|
+
channelIdRef,
|
|
34
|
+
contentWidth: 80,
|
|
35
|
+
controller: {
|
|
36
|
+
send: () => false,
|
|
37
|
+
sendToChannel: (channelId, content, replyToId) => {
|
|
38
|
+
sent.push({ channelId, content, replyToId });
|
|
39
|
+
return true;
|
|
40
|
+
},
|
|
41
|
+
openDirectChannel: async () => { throw new Error("not used"); },
|
|
42
|
+
openGroupChannel: async () => { throw new Error("not used"); },
|
|
43
|
+
setDraft: () => {},
|
|
44
|
+
setChannelDraft: () => {},
|
|
45
|
+
setReplyToId: () => {},
|
|
46
|
+
setChannelReplyToId: () => {},
|
|
47
|
+
} as any,
|
|
48
|
+
focusInput: () => {},
|
|
49
|
+
focused: true,
|
|
50
|
+
inputFocused: true,
|
|
51
|
+
inputRef,
|
|
52
|
+
inputValueRef,
|
|
53
|
+
messages: [],
|
|
54
|
+
onChannelChange: () => {},
|
|
55
|
+
replyTo: null,
|
|
56
|
+
setDirectExpanded: () => {},
|
|
57
|
+
setFollowMessages: () => {},
|
|
58
|
+
setReplyTo: () => {},
|
|
59
|
+
setSelectedIdx: () => {},
|
|
60
|
+
updateComposerRows: () => {},
|
|
61
|
+
useDefaultControllerChannel: false,
|
|
62
|
+
}).sendMessage;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await act(async () => {
|
|
67
|
+
testSetup = await testRender(<Harness />, { width: 1, height: 1 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await act(async () => {
|
|
71
|
+
sendMessage();
|
|
72
|
+
await testSetup?.renderOnce();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(sent).toEqual([{ channelId: "dm:test", content: "PM reply test #2", replyToId: undefined }]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("drops a reply target from a different channel before sending", async () => {
|
|
79
|
+
const sent: Array<{ channelId: string; content: string; replyToId?: string }> = [];
|
|
80
|
+
let sendMessage = () => {};
|
|
81
|
+
const staleReply: ChatMessage = {
|
|
82
|
+
id: "public-reply",
|
|
83
|
+
channelId: "everyone",
|
|
84
|
+
content: "public message",
|
|
85
|
+
replyToId: null,
|
|
86
|
+
createdAt: "2026-05-27T10:26:44.737Z",
|
|
87
|
+
user: { id: "u2", username: "vince", displayName: "Vince" },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function Harness() {
|
|
91
|
+
const applyingExternalDraftRef = useRef(false);
|
|
92
|
+
const channelIdRef = useRef("dm:test");
|
|
93
|
+
const inputRef = useRef(null);
|
|
94
|
+
const inputValueRef = useRef("replying in DM");
|
|
95
|
+
sendMessage = useChatComposerRuntime({
|
|
96
|
+
applyingExternalDraftRef,
|
|
97
|
+
blurInput: () => {},
|
|
98
|
+
canSend: true,
|
|
99
|
+
channelId: "dm:test",
|
|
100
|
+
channelIdRef,
|
|
101
|
+
contentWidth: 80,
|
|
102
|
+
controller: {
|
|
103
|
+
send: () => false,
|
|
104
|
+
sendToChannel: (channelId, content, replyToId) => {
|
|
105
|
+
sent.push({ channelId, content, replyToId });
|
|
106
|
+
return true;
|
|
107
|
+
},
|
|
108
|
+
openDirectChannel: async () => { throw new Error("not used"); },
|
|
109
|
+
openGroupChannel: async () => { throw new Error("not used"); },
|
|
110
|
+
setDraft: () => {},
|
|
111
|
+
setChannelDraft: () => {},
|
|
112
|
+
setReplyToId: () => {},
|
|
113
|
+
setChannelReplyToId: () => {},
|
|
114
|
+
} as any,
|
|
115
|
+
focusInput: () => {},
|
|
116
|
+
focused: true,
|
|
117
|
+
inputFocused: true,
|
|
118
|
+
inputRef,
|
|
119
|
+
inputValueRef,
|
|
120
|
+
messages: [],
|
|
121
|
+
onChannelChange: () => {},
|
|
122
|
+
replyTo: staleReply,
|
|
123
|
+
setDirectExpanded: () => {},
|
|
124
|
+
setFollowMessages: () => {},
|
|
125
|
+
setReplyTo: () => {},
|
|
126
|
+
setSelectedIdx: () => {},
|
|
127
|
+
updateComposerRows: () => {},
|
|
128
|
+
useDefaultControllerChannel: false,
|
|
129
|
+
}).sendMessage;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await act(async () => {
|
|
134
|
+
testSetup = await testRender(<Harness />, { width: 1, height: 1 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await act(async () => {
|
|
138
|
+
sendMessage();
|
|
139
|
+
await testSetup?.renderOnce();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(sent).toEqual([{ channelId: "dm:test", content: "replying in DM", replyToId: undefined }]);
|
|
143
|
+
});
|
|
144
|
+
});
|