gloomberb 0.8.1 → 0.9.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.
Files changed (196) hide show
  1. package/README.md +106 -54
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/api-client/chat.ts +8 -0
  5. package/src/api-client/index.test.ts +30 -0
  6. package/src/api-client/index.ts +4 -0
  7. package/src/api-client/normalizers.ts +1 -0
  8. package/src/api-client/types.ts +1 -0
  9. package/src/app/global-shortcuts.test.tsx +54 -4
  10. package/src/app/global-shortcuts.ts +11 -5
  11. package/src/capabilities/factories.ts +4 -0
  12. package/src/components/chart/comparison/data.test.ts +54 -0
  13. package/src/components/chart/comparison/data.ts +47 -24
  14. package/src/components/chart/core/projection.ts +51 -27
  15. package/src/components/command-bar/action-runtime.ts +1 -0
  16. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  17. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  18. package/src/components/command-bar/list/view.tsx +3 -1
  19. package/src/components/command-bar/pane-templates/items.ts +4 -1
  20. package/src/components/command-bar/panel/body.tsx +1 -0
  21. package/src/components/command-bar/routes/root/results.ts +0 -30
  22. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  23. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  24. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  25. package/src/components/command-bar/routing/launch-request.ts +14 -7
  26. package/src/components/command-bar/routing/list-state.ts +1 -3
  27. package/src/components/command-bar/surface/index.test.tsx +76 -4
  28. package/src/components/command-bar/surface/index.tsx +0 -2
  29. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  30. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  31. package/src/components/command-bar/view-model.ts +3 -3
  32. package/src/components/data-table/stack-view.test.tsx +14 -11
  33. package/src/components/data-table/stack-view.tsx +1 -2
  34. package/src/components/data-table/view.test.tsx +24 -18
  35. package/src/components/data-table/view.tsx +313 -83
  36. package/src/components/feed-data-table/stack-view.tsx +6 -8
  37. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  38. package/src/components/layout/pane/footer/index.tsx +14 -2
  39. package/src/components/layout/pane/title.ts +5 -1
  40. package/src/components/layout/shell/index.test.tsx +120 -2
  41. package/src/components/layout/shell/index.tsx +2 -0
  42. package/src/components/layout/shell/menu.ts +1 -7
  43. package/src/components/layout/shell/pane/actions.ts +8 -0
  44. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  45. package/src/components/layout/shell/shortcuts.ts +14 -6
  46. package/src/components/layout/shell/window-mode/index.ts +8 -3
  47. package/src/components/layout/window-edit/presentation.ts +4 -4
  48. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  49. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  50. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  51. package/src/components/ticker/list-table-view.tsx +17 -25
  52. package/src/components/ui/choice-dialog.tsx +12 -2
  53. package/src/components/ui/multi-select/dialog.tsx +17 -5
  54. package/src/components/ui/remote-image.tsx +1 -14
  55. package/src/components/ui/shortcut-hint.tsx +14 -2
  56. package/src/components/ui/table-layout.test.ts +2 -2
  57. package/src/components/ui/table-layout.ts +8 -2
  58. package/src/components/ui/tabs.tsx +115 -86
  59. package/src/components/ui/ui.test.tsx +83 -4
  60. package/src/core/state/app/types.ts +12 -4
  61. package/src/market-data/coordinator/auxiliary.ts +29 -1
  62. package/src/market-data/coordinator/index.ts +17 -1
  63. package/src/market-data/hooks.tsx +17 -1
  64. package/src/market-data/selectors.ts +4 -0
  65. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  66. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  67. package/src/plugins/builtin/account-management/model.ts +13 -3
  68. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  69. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  70. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  71. package/src/plugins/builtin/analytics/index.tsx +0 -3
  72. package/src/plugins/builtin/analytics/view.tsx +6 -5
  73. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  74. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  75. package/src/plugins/builtin/changelog/index.tsx +40 -27
  76. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  77. package/src/plugins/builtin/changelog/model.ts +98 -0
  78. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  79. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  80. package/src/plugins/builtin/chat/channels.ts +36 -19
  81. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  82. package/src/plugins/builtin/chat/content/channel-navigation.ts +50 -11
  83. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  84. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  85. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  86. package/src/plugins/builtin/chat/content/index.tsx +66 -4
  87. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  88. package/src/plugins/builtin/chat/content/shortcuts.ts +27 -1
  89. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  90. package/src/plugins/builtin/chat/content/types.ts +1 -0
  91. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  92. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  93. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  94. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  95. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  96. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  97. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  98. package/src/plugins/builtin/chat/layout.ts +94 -31
  99. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  100. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  101. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  102. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  103. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  104. package/src/plugins/builtin/chat/message/types.ts +2 -0
  105. package/src/plugins/builtin/chat/pane.tsx +35 -7
  106. package/src/plugins/builtin/chat/sidebar.test.tsx +72 -0
  107. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  108. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  109. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  110. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  111. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  112. package/src/plugins/builtin/earnings/index.tsx +8 -7
  113. package/src/plugins/builtin/econ/index.tsx +6 -8
  114. package/src/plugins/builtin/help/components.tsx +1 -1
  115. package/src/plugins/builtin/help/index.test.tsx +105 -30
  116. package/src/plugins/builtin/help/index.tsx +326 -164
  117. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  118. package/src/plugins/builtin/holders/pane.tsx +7 -6
  119. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  120. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  121. package/src/plugins/builtin/options/view.tsx +16 -8
  122. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  123. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  124. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  125. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  126. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  127. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  128. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  129. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  130. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  131. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  132. package/src/plugins/builtin/research/index.test.ts +30 -1
  133. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  134. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  135. package/src/plugins/builtin/sec/index.tsx +132 -20
  136. package/src/plugins/builtin/sectors/index.tsx +13 -23
  137. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  138. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  139. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  140. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  141. package/src/plugins/builtin/substack/api/store.ts +186 -0
  142. package/src/plugins/builtin/substack/api/types.ts +40 -0
  143. package/src/plugins/builtin/substack/api.test.ts +298 -0
  144. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  145. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  146. package/src/plugins/builtin/substack/content.ts +478 -0
  147. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  148. package/src/plugins/builtin/substack/index.tsx +49 -0
  149. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  150. package/src/plugins/builtin/substack/model.test.ts +155 -0
  151. package/src/plugins/builtin/substack/normalize.ts +343 -0
  152. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  153. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  154. package/src/plugins/builtin/substack/pane.tsx +500 -0
  155. package/src/plugins/builtin/substack/table.ts +111 -0
  156. package/src/plugins/builtin/substack/types.ts +71 -0
  157. package/src/plugins/builtin/substack/utils.ts +131 -0
  158. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  161. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  162. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  163. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  164. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  165. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  166. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  167. package/src/plugins/catalog-ui.ts +2 -0
  168. package/src/plugins/pane-manager/layout-state.ts +11 -0
  169. package/src/plugins/pane-manager.ts +1 -1
  170. package/src/plugins/prediction-markets/pane.tsx +10 -9
  171. package/src/plugins/registry/index.ts +3 -0
  172. package/src/plugins/runtime/context.tsx +1 -0
  173. package/src/plugins/runtime/index.tsx +4 -0
  174. package/src/react/use-throttled-commit-value.ts +116 -0
  175. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  176. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  177. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  178. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  179. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  180. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  181. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  182. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  183. package/src/sources/gloomberb-cloud/index.ts +4 -0
  184. package/src/sources/provider-router/cache.ts +1 -0
  185. package/src/sources/provider-router/document-routes.ts +45 -0
  186. package/src/sources/provider-router/index.ts +4 -0
  187. package/src/sources/sec-edgar/content.ts +9 -3
  188. package/src/sources/sec-edgar.test.ts +108 -1
  189. package/src/sources/sec-edgar.ts +105 -1
  190. package/src/sources/yahoo-finance.ts +4 -0
  191. package/src/state/app/context/index.test.tsx +16 -0
  192. package/src/test-support/plugin-runtime.ts +1 -0
  193. package/src/tickers/search/index.ts +1 -0
  194. package/src/types/data-provider.ts +11 -0
  195. package/src/ui/host.tsx +1 -0
  196. package/src/version.ts +1 -1
@@ -0,0 +1,53 @@
1
+ import type { ChatChannel } from "../../../api-client";
2
+ import { normalizeChannelId } from "./controller/state";
3
+
4
+ function normalizeMentionUsername(username: string | null | undefined): string | null {
5
+ const normalized = username?.trim().replace(/^@+/, "");
6
+ return normalized || null;
7
+ }
8
+
9
+ function formatMentionUsername(username: string | null | undefined): string | null {
10
+ const normalized = normalizeMentionUsername(username);
11
+ return normalized ? `@${normalized}` : null;
12
+ }
13
+
14
+ function formatDirectChannelLabel(channel: ChatChannel, fallbackId: string) {
15
+ const usernameLabel = formatMentionUsername(channel.dmUser?.username);
16
+ if (usernameLabel) return usernameLabel;
17
+
18
+ const displayName = channel.dmUser?.displayName?.trim();
19
+ if (displayName) return displayName;
20
+
21
+ const channelName = channel.name?.trim();
22
+ if (channelName && channelName !== channel.id) {
23
+ return channelName.startsWith("@")
24
+ ? formatMentionUsername(channelName) ?? channelName
25
+ : channelName;
26
+ }
27
+
28
+ return fallbackId.startsWith("dm:") ? "DM" : fallbackId;
29
+ }
30
+
31
+ export function formatChannelLabel(channel: ChatChannel | undefined, fallbackId: string) {
32
+ if (!channel) return fallbackId;
33
+ if (channel.kind === "direct") {
34
+ return formatDirectChannelLabel(channel, fallbackId);
35
+ }
36
+ if (channel.kind === "group") {
37
+ return channel.name?.trim() || "Group";
38
+ }
39
+ return channel.name?.trim() || fallbackId;
40
+ }
41
+
42
+ export function formatChatPaneTitle(channel: ChatChannel | undefined, fallbackId: string) {
43
+ const normalizedFallbackId = normalizeChannelId(fallbackId);
44
+ if (channel?.kind === "direct") {
45
+ return formatChannelLabel(channel, normalizedFallbackId);
46
+ }
47
+ if (channel?.kind === "group") {
48
+ return formatChannelLabel(channel, normalizedFallbackId);
49
+ }
50
+ if (!channel && normalizedFallbackId.startsWith("dm:")) return "DM";
51
+ if (!channel && (normalizedFallbackId.startsWith("grp:") || normalizedFallbackId.startsWith("group:"))) return "Group";
52
+ return `#${formatChannelLabel(channel, normalizedFallbackId)}`;
53
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ChatChannel } from "../../../api-client";
3
+ import {
4
+ channelPrefix,
5
+ formatChannelLabel,
6
+ formatChatPaneTitle,
7
+ } from "./channels";
8
+
9
+ describe("chat channel labels", () => {
10
+ test("formats direct-message labels and titles with one mention prefix", () => {
11
+ const channel: ChatChannel = {
12
+ id: "dm:c7f89ce26f9da83d6be08a4838738074",
13
+ name: "@risto",
14
+ kind: "direct",
15
+ created_at: "2026-05-27T10:30:03.712Z",
16
+ dmUser: { id: "u2", username: "@risto", displayName: "Risto" },
17
+ };
18
+
19
+ expect(formatChannelLabel(channel, channel.id)).toBe("@risto");
20
+ expect(channelPrefix(channel, true)).toBe(" ");
21
+ expect(formatChatPaneTitle(channel, channel.id)).toBe("@risto");
22
+ expect(formatChatPaneTitle(undefined, channel.id)).toBe("DM");
23
+ });
24
+
25
+ test("uses a neutral group title before private channel metadata loads", () => {
26
+ expect(formatChatPaneTitle(undefined, "grp:93f14c6b9827c30f")).toBe("Group");
27
+ });
28
+ });
@@ -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
  });
@@ -1,12 +1,19 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
3
3
  import type { ChatChannel, ChatUserSummary } from "../../../../api-client";
4
+ import { useThrottledCommitValue } from "../../../../react/use-throttled-commit-value";
4
5
  import {
5
6
  DEFAULT_CHAT_CHANNEL_ID,
6
7
  normalizeChannelId,
7
8
  } from "../channels";
8
9
  import type { ChatContentController } from "./types";
9
10
 
11
+ const CHANNEL_NAVIGATION_COMMIT_DELAY_MS = 150;
12
+
13
+ function isConversationChannelId(channelId: string): boolean {
14
+ return channelId.startsWith("dm:") || channelId.startsWith("grp:") || channelId.startsWith("group:");
15
+ }
16
+
10
17
  export function useChatChannelNavigation({
11
18
  blurInput,
12
19
  channelId,
@@ -34,15 +41,16 @@ export function useChatChannelNavigation({
34
41
  resetTranscriptSelection: () => void;
35
42
  showChannelSidebar: boolean;
36
43
  }): {
37
- changeChannel: (nextChannelId: string) => void;
38
44
  cycleChannel: (direction: 1 | -1) => boolean;
39
45
  directExpanded: boolean;
40
46
  focusChannelSidebar: () => boolean;
41
47
  focusChatContent: () => boolean;
42
48
  moveSidebarChannelSelection: (direction: "up" | "down") => boolean;
43
49
  openDirectMessage: (target: ChatUserSummary) => Promise<void>;
50
+ selectSidebarChannel: (channelId: string) => void;
44
51
  setDirectExpanded: Dispatch<SetStateAction<boolean>>;
45
52
  setSidebarFocused: (nextFocused: boolean) => void;
53
+ sidebarCursorChannelId: string;
46
54
  sidebarFocused: boolean;
47
55
  sidebarFocusedRef: MutableRefObject<boolean>;
48
56
  } {
@@ -66,6 +74,13 @@ export function useChatChannelNavigation({
66
74
  resetTranscriptSelection();
67
75
  onChannelChange?.(normalized);
68
76
  }, [channelIdRef, onChannelChange, resetTranscriptSelection]);
77
+ const {
78
+ value: sidebarCursorChannelId,
79
+ valueRef: sidebarCursorChannelIdRef,
80
+ setValue: setSidebarCursorChannelId,
81
+ flushValue: flushSidebarCursorChannelId,
82
+ replaceValue: replaceSidebarCursorChannelId,
83
+ } = useThrottledCommitValue(channelId, changeChannel, CHANNEL_NAVIGATION_COMMIT_DELAY_MS);
69
84
 
70
85
  const openDirectMessage = useCallback(async (target: ChatUserSummary) => {
71
86
  if (!target.id && !target.username) return;
@@ -75,14 +90,16 @@ export function useChatChannelNavigation({
75
90
  username: target.username ?? undefined,
76
91
  });
77
92
  setDirectExpanded(true);
78
- changeChannel(channel.id);
93
+ setSidebarCursorChannelId(channel.id, { immediate: true });
79
94
  closeProfilePopover();
80
95
  } catch {}
81
- }, [changeChannel, closeProfilePopover, controller]);
96
+ }, [closeProfilePopover, controller, setSidebarCursorChannelId]);
82
97
 
83
98
  useEffect(() => {
84
99
  if (!onChannelChange || channelsLoading || channels.length === 0) return;
85
100
  if (channels.some((channel) => channel.id === channelId)) return;
101
+ // DM/group panes can remount before the private channel catalog is refreshed.
102
+ if (isConversationChannelId(channelId)) return;
86
103
  const fallbackChannelId = channels.find((channel) => channel.id === DEFAULT_CHAT_CHANNEL_ID)?.id
87
104
  ?? channels[0]?.id;
88
105
  if (fallbackChannelId) {
@@ -103,9 +120,9 @@ export function useChatChannelNavigation({
103
120
  const nextIndex = (currentIndex + direction + channels.length) % channels.length;
104
121
  const nextChannel = channels[nextIndex];
105
122
  if (!nextChannel) return false;
106
- changeChannel(nextChannel.id);
123
+ setSidebarCursorChannelId(nextChannel.id, { immediate: true });
107
124
  return true;
108
- }, [changeChannel, channelIdRef, channels, onChannelChange]);
125
+ }, [channelIdRef, channels, onChannelChange, setSidebarCursorChannelId]);
109
126
 
110
127
  const focusChannelSidebar = useCallback(() => {
111
128
  if (!showChannelSidebar || !onChannelChange) return false;
@@ -113,28 +130,49 @@ export function useChatChannelNavigation({
113
130
  blurInput();
114
131
  }
115
132
  resetTranscriptSelection();
133
+ replaceSidebarCursorChannelId(channelId);
116
134
  setSidebarFocused(true);
117
135
  return true;
118
- }, [blurInput, inputFocused, onChannelChange, resetTranscriptSelection, setSidebarFocused, showChannelSidebar]);
136
+ }, [
137
+ blurInput,
138
+ channelId,
139
+ inputFocused,
140
+ onChannelChange,
141
+ replaceSidebarCursorChannelId,
142
+ resetTranscriptSelection,
143
+ setSidebarFocused,
144
+ showChannelSidebar,
145
+ ]);
119
146
 
120
147
  const focusChatContent = useCallback(() => {
121
148
  if (!showChannelSidebar) return false;
149
+ flushSidebarCursorChannelId(sidebarCursorChannelIdRef.current);
122
150
  setSidebarFocused(false);
123
151
  return true;
124
- }, [setSidebarFocused, showChannelSidebar]);
152
+ }, [flushSidebarCursorChannelId, setSidebarFocused, showChannelSidebar, sidebarCursorChannelIdRef]);
125
153
 
126
154
  const moveSidebarChannelSelection = useCallback((direction: "up" | "down") => {
127
155
  if (!showChannelSidebar || sidebarNavigationChannels.length <= 1 || !onChannelChange) return false;
128
- const currentIndex = sidebarNavigationChannels.findIndex((channel) => channel.id === channelIdRef.current);
156
+ const currentIndex = sidebarNavigationChannels.findIndex((channel) => channel.id === sidebarCursorChannelIdRef.current);
129
157
  const baseIndex = currentIndex >= 0 ? currentIndex : 0;
130
158
  const nextIndex = direction === "down"
131
159
  ? Math.min(baseIndex + 1, sidebarNavigationChannels.length - 1)
132
160
  : Math.max(baseIndex - 1, 0);
133
161
  const nextChannel = sidebarNavigationChannels[nextIndex];
134
162
  if (!nextChannel || nextIndex === baseIndex) return true;
135
- changeChannel(nextChannel.id);
163
+ setSidebarCursorChannelId(nextChannel.id);
136
164
  return true;
137
- }, [changeChannel, channelIdRef, onChannelChange, showChannelSidebar, sidebarNavigationChannels]);
165
+ }, [
166
+ onChannelChange,
167
+ setSidebarCursorChannelId,
168
+ showChannelSidebar,
169
+ sidebarCursorChannelIdRef,
170
+ sidebarNavigationChannels,
171
+ ]);
172
+
173
+ const selectSidebarChannel = useCallback((nextChannelId: string) => {
174
+ setSidebarCursorChannelId(nextChannelId, { immediate: true });
175
+ }, [setSidebarCursorChannelId]);
138
176
 
139
177
  useEffect(() => {
140
178
  if (focused && showChannelSidebar) return;
@@ -142,15 +180,16 @@ export function useChatChannelNavigation({
142
180
  }, [focused, setSidebarFocused, showChannelSidebar]);
143
181
 
144
182
  return {
145
- changeChannel,
146
183
  cycleChannel,
147
184
  directExpanded,
148
185
  focusChannelSidebar,
149
186
  focusChatContent,
150
187
  moveSidebarChannelSelection,
151
188
  openDirectMessage,
189
+ selectSidebarChannel,
152
190
  setDirectExpanded,
153
191
  setSidebarFocused,
192
+ sidebarCursorChannelId,
154
193
  sidebarFocused,
155
194
  sidebarFocusedRef,
156
195
  };