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.
Files changed (122) hide show
  1. package/README.md +102 -52
  2. package/package.json +1 -1
  3. package/src/api-client/chat.ts +8 -0
  4. package/src/api-client/index.test.ts +30 -0
  5. package/src/api-client/index.ts +4 -0
  6. package/src/api-client/normalizers.ts +1 -0
  7. package/src/api-client/types.ts +1 -0
  8. package/src/capabilities/factories.ts +4 -0
  9. package/src/components/chart/comparison/data.test.ts +54 -0
  10. package/src/components/chart/comparison/data.ts +47 -24
  11. package/src/components/chart/core/projection.ts +51 -27
  12. package/src/components/data-table/stack-view.test.tsx +14 -11
  13. package/src/components/data-table/stack-view.tsx +1 -2
  14. package/src/components/data-table/view.test.tsx +24 -18
  15. package/src/components/data-table/view.tsx +313 -83
  16. package/src/components/feed-data-table/stack-view.tsx +6 -8
  17. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  18. package/src/components/layout/pane/footer/index.tsx +14 -2
  19. package/src/components/layout/pane/title.ts +5 -1
  20. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  21. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  22. package/src/components/ticker/list-table-view.tsx +11 -25
  23. package/src/components/ui/choice-dialog.tsx +12 -2
  24. package/src/components/ui/multi-select/dialog.tsx +17 -5
  25. package/src/components/ui/shortcut-hint.tsx +14 -2
  26. package/src/components/ui/table-layout.test.ts +2 -2
  27. package/src/components/ui/table-layout.ts +8 -2
  28. package/src/components/ui/tabs.tsx +20 -6
  29. package/src/components/ui/ui.test.tsx +30 -5
  30. package/src/market-data/coordinator/auxiliary.ts +29 -1
  31. package/src/market-data/coordinator/index.ts +17 -1
  32. package/src/market-data/hooks.tsx +17 -1
  33. package/src/market-data/selectors.ts +4 -0
  34. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  35. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  36. package/src/plugins/builtin/account-management/model.ts +13 -3
  37. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  38. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  39. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  40. package/src/plugins/builtin/analytics/index.tsx +0 -3
  41. package/src/plugins/builtin/analytics/view.tsx +6 -5
  42. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  43. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  44. package/src/plugins/builtin/changelog/index.tsx +40 -27
  45. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  46. package/src/plugins/builtin/changelog/model.ts +98 -0
  47. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  48. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  49. package/src/plugins/builtin/chat/channels.ts +36 -19
  50. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  51. package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
  52. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  53. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  54. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  55. package/src/plugins/builtin/chat/content/index.tsx +61 -0
  56. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
  58. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  59. package/src/plugins/builtin/chat/content/types.ts +1 -0
  60. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  61. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  62. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  63. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  64. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  65. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  66. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  67. package/src/plugins/builtin/chat/layout.ts +94 -31
  68. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  69. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  70. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  71. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  72. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  73. package/src/plugins/builtin/chat/message/types.ts +2 -0
  74. package/src/plugins/builtin/chat/pane.tsx +35 -7
  75. package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
  76. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  77. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  78. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  79. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  80. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  81. package/src/plugins/builtin/earnings/index.tsx +8 -7
  82. package/src/plugins/builtin/econ/index.tsx +6 -8
  83. package/src/plugins/builtin/help/index.tsx +6 -0
  84. package/src/plugins/builtin/holders/pane.tsx +7 -6
  85. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  86. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  87. package/src/plugins/builtin/options/view.tsx +16 -8
  88. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  89. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  90. package/src/plugins/builtin/research/index.test.ts +30 -1
  91. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  92. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  93. package/src/plugins/builtin/sec/index.tsx +132 -20
  94. package/src/plugins/builtin/sectors/index.tsx +13 -23
  95. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  96. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  97. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  98. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  99. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  100. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  101. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  102. package/src/plugins/prediction-markets/pane.tsx +10 -9
  103. package/src/plugins/registry/index.ts +3 -0
  104. package/src/plugins/runtime/context.tsx +1 -0
  105. package/src/plugins/runtime/index.tsx +4 -0
  106. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  107. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  108. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  109. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  110. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  111. package/src/sources/gloomberb-cloud/index.ts +4 -0
  112. package/src/sources/provider-router/cache.ts +1 -0
  113. package/src/sources/provider-router/document-routes.ts +45 -0
  114. package/src/sources/provider-router/index.ts +4 -0
  115. package/src/sources/sec-edgar/content.ts +9 -3
  116. package/src/sources/sec-edgar.test.ts +108 -1
  117. package/src/sources/sec-edgar.ts +105 -1
  118. package/src/sources/yahoo-finance.ts +4 -0
  119. package/src/test-support/plugin-runtime.ts +1 -0
  120. package/src/types/data-provider.ts +11 -0
  121. package/src/ui/host.tsx +1 -0
  122. 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 formatChannelLabel(channel: ChatChannel | undefined, fallbackId: string) {
25
- if (!channel) return fallbackId;
26
- if (channel.kind === "direct") {
27
- return channel.dmUser?.username ? `@${channel.dmUser.username}` : channel.dmUser?.displayName ?? "DM";
28
- }
29
- if (channel.kind === "group") {
30
- return channel.name?.trim() || "Group";
31
- }
32
- return channel.name?.trim() || fallbackId;
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" | "getConfig" | "showPane">,
93
+ ctx: Pick<GloomPluginContext, "createPaneFromTemplate" | "focusPane" | "getConfig">,
72
94
  ): void {
73
95
  const config = ctx.getConfig();
74
- const hasChatPane = config.layout.instances.some((instance) => instance.paneId === "chat");
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 active ? "@" : " ";
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 openedPanes: string[] = [];
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
- showPane(paneId: string) {
1002
- openedPanes.push(paneId);
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(openedPanes).toEqual(["chat"]);
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
+ });