gloomberb 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -1,17 +1,21 @@
1
1
  import { Box, ScrollBox, Span, Text, useUiCapabilities } from "../../ui";
2
- import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { memo, useState, useEffect, useRef, useCallback, useMemo } from "react";
3
3
  import { useShortcut } from "../../react/input";
4
4
  import { TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "../../ui";
5
5
  import type { GloomPlugin, PaneProps } from "../../types/plugin";
6
- import { useAppDispatch, useAppSelector } from "../../state/app-context";
7
- import { useInlineTickers } from "../../state/use-inline-tickers";
8
- import { getMessageComposerBlockHeight, MessageComposer } from "../../components/ui";
6
+ import { useAppDispatch, useAppSelector, useAppStateRef, usePaneInstance, usePaneInstanceId } from "../../state/app-context";
7
+ import { useInlineTickers, type InlineTickerCatalogEntry } from "../../state/use-inline-tickers";
8
+ import { ExternalLinkText, getMessageComposerBlockHeight, MessageComposer } from "../../components/ui";
9
9
  import { TickerBadgeText } from "../../components/ticker-badge-text";
10
- import { colors, hoverBg } from "../../theme/colors";
11
- import { apiClient, type ChatMessage } from "../../utils/api-client";
10
+ import { TickerBadge } from "../../components/ticker-badge";
11
+ import { blendHex, colors, hoverBg } from "../../theme/colors";
12
+ import { apiClient, type ChatChannel, type ChatMessage } from "../../utils/api-client";
12
13
  import { formatTimeAgo } from "../../utils/format";
14
+ import { tokenizeInlineContent } from "../../utils/inline-content-tokenizer";
13
15
  import { getSharedRegistry } from "../../plugins/registry";
14
16
  import { usePluginAppActions } from "../../plugins/plugin-runtime";
17
+ import { setPaneSetting } from "../../pane-settings";
18
+ import { scheduleConfigSave } from "../../state/config-save-scheduler";
15
19
  import { chatController, type ChatController } from "./chat-controller";
16
20
  import { createGloomberbCloudCapabilities, createGloomberbCloudProvider } from "../../sources/gloomberb-cloud";
17
21
  import { InlineAuthActions } from "./cloud-auth-actions";
@@ -22,16 +26,25 @@ interface ChatContentProps {
22
26
  height: number;
23
27
  focused: boolean;
24
28
  close?: () => void;
29
+ channelId?: string;
30
+ onChannelChange?: (channelId: string) => void;
25
31
  controller?: Pick<
26
32
  ChatController,
27
33
  | "attachView"
34
+ | "attachChannelView"
28
35
  | "getSnapshot"
36
+ | "refreshChannels"
29
37
  | "loadOlderMessages"
38
+ | "loadOlderChannelMessages"
30
39
  | "refreshMessages"
40
+ | "refreshChannelMessages"
31
41
  | "refreshSession"
32
42
  | "send"
43
+ | "sendToChannel"
33
44
  | "setDraft"
45
+ | "setChannelDraft"
34
46
  | "setReplyToId"
47
+ | "setChannelReplyToId"
35
48
  | "subscribe"
36
49
  >;
37
50
  }
@@ -42,10 +55,16 @@ interface ChatStatusWidgetProps {
42
55
 
43
56
  const MESSAGE_GROUP_THRESHOLD_MS = 5 * 60 * 1000;
44
57
  const CHAT_COMPOSER_MAX_ROWS = 5;
45
- const NATIVE_CHAT_COMPOSER_TOP_GAP_PX = 12;
46
58
  const MESSAGE_ACTION_WIDTH = 9;
47
59
  const COMPOSER_ACTION_WIDTH = 10;
48
60
  const MESSAGE_SELECTION_BOTTOM_INSET = 1;
61
+ const DESKTOP_MESSAGE_RIGHT_PADDING = 2;
62
+ const CHANNEL_SIDEBAR_MIN_WIDTH = 18;
63
+ const CHANNEL_SIDEBAR_MAX_WIDTH = 24;
64
+ const CHANNEL_SIDEBAR_BREAKPOINT = 72;
65
+ const DEFAULT_CHAT_CHANNEL_ID = "everyone";
66
+ const LAST_VISITED_CHAT_CHANNEL_KEY = "lastChatChannelId";
67
+ const CHAT_CHANNEL_MOUSE_HANDLED = "__gloomberbChatChannelHandled";
49
68
 
50
69
  function isGroupedWithPrevious(messages: ChatMessage[], index: number) {
51
70
  if (index === 0) return false;
@@ -117,9 +136,125 @@ function getMessageTopOffset(messages: ChatMessage[], index: number, width: numb
117
136
  return offset;
118
137
  }
119
138
 
120
- function scrollToBottom(scrollBox: ScrollBoxRenderable | null) {
139
+ function hasPixelScrollMetrics(scrollBox: ScrollBoxRenderable | null) {
140
+ return !!scrollBox?.scrollToPixels && typeof scrollBox.scrollHeightPx === "number" && !!scrollBox.viewportPx;
141
+ }
142
+
143
+ function getScrollTop(scrollBox: ScrollBoxRenderable, preferPixels: boolean) {
144
+ return preferPixels && typeof scrollBox.scrollTopPx === "number" ? scrollBox.scrollTopPx : scrollBox.scrollTop;
145
+ }
146
+
147
+ function getScrollHeight(scrollBox: ScrollBoxRenderable, preferPixels: boolean) {
148
+ return preferPixels && typeof scrollBox.scrollHeightPx === "number" ? scrollBox.scrollHeightPx : scrollBox.scrollHeight;
149
+ }
150
+
151
+ function getViewportHeight(scrollBox: ScrollBoxRenderable, preferPixels: boolean) {
152
+ return preferPixels && scrollBox.viewportPx ? scrollBox.viewportPx.height : scrollBox.viewport?.height ?? 0;
153
+ }
154
+
155
+ function scrollToPosition(scrollBox: ScrollBoxRenderable, target: number, preferPixels: boolean) {
156
+ if (preferPixels && scrollBox.scrollToPixels) {
157
+ scrollBox.scrollToPixels(target);
158
+ return;
159
+ }
160
+ scrollBox.scrollTo(target);
161
+ }
162
+
163
+ function scrollToBottom(scrollBox: ScrollBoxRenderable | null, preferPixels = false) {
121
164
  if (!scrollBox) return;
122
- scrollBox.scrollTo(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height));
165
+ const exactPixels = preferPixels && hasPixelScrollMetrics(scrollBox);
166
+ scrollToPosition(
167
+ scrollBox,
168
+ Math.max(0, getScrollHeight(scrollBox, exactPixels) - getViewportHeight(scrollBox, exactPixels)),
169
+ exactPixels,
170
+ );
171
+ }
172
+
173
+ function runAfterLayout(callback: () => void) {
174
+ if (typeof requestAnimationFrame === "function") {
175
+ requestAnimationFrame(callback);
176
+ return;
177
+ }
178
+ queueMicrotask(callback);
179
+ }
180
+
181
+ export function getScrollTopForElementIntoView({
182
+ scrollTop,
183
+ viewportHeight,
184
+ elementTop,
185
+ elementHeight,
186
+ bottomInset = 0,
187
+ }: {
188
+ scrollTop: number;
189
+ viewportHeight: number;
190
+ elementTop: number;
191
+ elementHeight: number;
192
+ bottomInset?: number;
193
+ }) {
194
+ const visibleHeight = Math.max(viewportHeight - bottomInset, 1);
195
+ const elementBottom = elementTop + elementHeight;
196
+ if (elementTop < scrollTop || elementHeight >= visibleHeight) return elementTop;
197
+ if (elementBottom > scrollTop + visibleHeight) return elementBottom - visibleHeight;
198
+ return scrollTop;
199
+ }
200
+
201
+ function scrollElementIntoScrollBoxView(scrollBox: ScrollBoxRenderable | null, node: unknown) {
202
+ const nodeRect = (node as { getBoundingClientRect?: () => { x?: number; y?: number; top?: number; width?: number; height?: number } } | null)
203
+ ?.getBoundingClientRect?.();
204
+ const scrollRect = scrollBox?.getBoundingClientRect?.() as { x?: number; y?: number; top?: number; width?: number; height?: number } | undefined;
205
+ if (
206
+ scrollBox &&
207
+ nodeRect &&
208
+ scrollRect &&
209
+ scrollBox.scrollToPixels &&
210
+ typeof scrollBox.scrollTopPx === "number" &&
211
+ scrollBox.viewportPx
212
+ ) {
213
+ const nodeY = nodeRect.y ?? nodeRect.top ?? 0;
214
+ const scrollY = scrollRect.y ?? scrollRect.top ?? 0;
215
+ const elementTop = scrollBox.scrollTopPx + nodeY - scrollY;
216
+ const nextScrollTop = getScrollTopForElementIntoView({
217
+ scrollTop: scrollBox.scrollTopPx,
218
+ viewportHeight: scrollBox.viewportPx.height,
219
+ elementTop,
220
+ elementHeight: Math.max(nodeRect.height ?? 0, 1),
221
+ });
222
+ if (nextScrollTop !== scrollBox.scrollTopPx) {
223
+ scrollBox.scrollToPixels(nextScrollTop);
224
+ }
225
+ return true;
226
+ }
227
+
228
+ const scrollIntoView = (node as { scrollIntoView?: (options?: unknown) => void } | null)?.scrollIntoView;
229
+ if (typeof scrollIntoView !== "function") return false;
230
+ scrollIntoView.call(node, { block: "nearest", inline: "nearest" });
231
+ return true;
232
+ }
233
+
234
+ function normalizeChannelId(channelId: string | null | undefined) {
235
+ const trimmed = channelId?.trim();
236
+ return trimmed || DEFAULT_CHAT_CHANNEL_ID;
237
+ }
238
+
239
+ function normalizeShortcutChannelId(channelId: string | null | undefined) {
240
+ const trimmed = channelId?.trim().replace(/^#+/, "");
241
+ return normalizeChannelId(trimmed?.toLowerCase());
242
+ }
243
+
244
+ function getLastVisitedChatChannelId(config: { pluginConfig: Record<string, Record<string, unknown>> }) {
245
+ return normalizeChannelId(config.pluginConfig["gloomberb-cloud"]?.[LAST_VISITED_CHAT_CHANNEL_KEY] as string | undefined);
246
+ }
247
+
248
+ function formatChannelLabel(channel: ChatChannel | undefined, fallbackId: string) {
249
+ return channel?.name?.trim() || fallbackId;
250
+ }
251
+
252
+ function truncateChannelLabel(label: string, width: number) {
253
+ if (width <= 0) return "";
254
+ if (label.length <= width) return label;
255
+ if (width <= 1) return label.slice(0, width);
256
+ if (width <= 3) return label.slice(0, width);
257
+ return `${label.slice(0, width - 3)}...`;
123
258
  }
124
259
 
125
260
  export function getSelectedMessageScrollTop({
@@ -213,15 +348,555 @@ function ChatActionChip({
213
348
  );
214
349
  }
215
350
 
351
+ interface ChatMessageRenderState {
352
+ isSelected: boolean;
353
+ isHovered: boolean;
354
+ grouped: boolean;
355
+ showReplyAction: boolean;
356
+ bgColor: string | undefined;
357
+ selectedTextColor: string;
358
+ replyMetaColor: string;
359
+ replyAuthorColor: string;
360
+ headerStatus: string;
361
+ headerStatusColor: string;
362
+ authorColor: string;
363
+ authorAttributes: number;
364
+ bodyColor: string;
365
+ }
366
+
367
+ function getChatMessageRenderState({
368
+ msg,
369
+ index,
370
+ messages,
371
+ selectedIdx,
372
+ hoveredIdx,
373
+ canSend,
374
+ }: {
375
+ msg: ChatMessage;
376
+ index: number;
377
+ messages: ChatMessage[];
378
+ selectedIdx: number;
379
+ hoveredIdx: number | null;
380
+ canSend: boolean;
381
+ }): ChatMessageRenderState {
382
+ const isSelected = index === selectedIdx;
383
+ const isHovered = index === hoveredIdx && !isSelected;
384
+ const grouped = isGroupedWithPrevious(messages, index);
385
+ const isSending = msg.clientStatus === "sending";
386
+ const hasFailed = msg.clientStatus === "failed";
387
+ const selectedTextColor = hasFailed ? colors.negative : colors.selectedText;
388
+ const headerStatus = isSending ? "sending..." : hasFailed ? "failed" : formatTimeAgo(msg.createdAt);
389
+
390
+ return {
391
+ isSelected,
392
+ isHovered,
393
+ grouped,
394
+ showReplyAction: canSend && (isSelected || hoveredIdx === index),
395
+ bgColor: isSelected ? colors.selected : isHovered ? hoverBg() : undefined,
396
+ selectedTextColor,
397
+ replyMetaColor: isSelected ? selectedTextColor : colors.textMuted,
398
+ replyAuthorColor: isSelected ? selectedTextColor : colors.textDim,
399
+ headerStatus,
400
+ headerStatusColor: isSelected
401
+ ? selectedTextColor
402
+ : isSending
403
+ ? colors.textDim
404
+ : hasFailed
405
+ ? colors.negative
406
+ : colors.textMuted,
407
+ authorColor: isSelected ? selectedTextColor : hasFailed ? colors.negative : colors.positive,
408
+ authorAttributes: (isSending ? TextAttributes.DIM : 0) | TextAttributes.BOLD,
409
+ bodyColor: isSelected ? selectedTextColor : hasFailed ? colors.negative : isSending ? colors.textDim : colors.text,
410
+ };
411
+ }
412
+
413
+ function ResponsiveTickerBadgeText({
414
+ text,
415
+ catalog,
416
+ textColor,
417
+ openTicker,
418
+ }: {
419
+ text: string;
420
+ catalog: Record<string, InlineTickerCatalogEntry>;
421
+ textColor: string;
422
+ openTicker: (symbol: string) => void;
423
+ }) {
424
+ const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
425
+ const tokens = useMemo(() => tokenizeInlineContent(text), [text]);
426
+
427
+ return (
428
+ <Box
429
+ flexDirection="row"
430
+ flexWrap="wrap"
431
+ flexGrow={1}
432
+ style={{ minWidth: 0, width: "100%" }}
433
+ >
434
+ {tokens.map((token, index) => {
435
+ if (token.kind === "text") {
436
+ if (!token.value) return null;
437
+ return (
438
+ <Text
439
+ key={`text:${index}`}
440
+ fg={textColor}
441
+ wrapText
442
+ style={{
443
+ minWidth: 0,
444
+ whiteSpace: "pre-wrap",
445
+ overflowWrap: "anywhere",
446
+ }}
447
+ >
448
+ {token.value}
449
+ </Text>
450
+ );
451
+ }
452
+
453
+ if (token.kind === "link") {
454
+ return (
455
+ <ExternalLinkText
456
+ key={`link:${index}`}
457
+ url={token.url}
458
+ label={token.value}
459
+ color={textColor}
460
+ />
461
+ );
462
+ }
463
+
464
+ const entry = catalog[token.symbol];
465
+ if (!entry || entry.status === "missing") {
466
+ return <Text key={`raw:${index}`} fg={textColor}>{token.value}</Text>;
467
+ }
468
+
469
+ return (
470
+ <TickerBadge
471
+ key={`badge:${index}:${token.symbol}`}
472
+ symbol={token.symbol}
473
+ status={entry.status}
474
+ quote={entry.quote}
475
+ hovered={hoveredSymbol === token.symbol}
476
+ onHoverStart={() => setHoveredSymbol(token.symbol)}
477
+ onHoverEnd={() => {
478
+ setHoveredSymbol((current) => (current === token.symbol ? null : current));
479
+ }}
480
+ onOpen={openTicker}
481
+ />
482
+ );
483
+ })}
484
+ </Box>
485
+ );
486
+ }
487
+
488
+ interface ChatMessageBaseProps {
489
+ msg: ChatMessage;
490
+ index: number;
491
+ messages: ChatMessage[];
492
+ selectedIdx: number;
493
+ hoveredIdx: number | null;
494
+ canSend: boolean;
495
+ catalog: Record<string, InlineTickerCatalogEntry>;
496
+ openTicker: (symbol: string) => void;
497
+ beginReplyTo: (index: number, options?: { deferFocus?: boolean }) => void;
498
+ jumpToMessage: (messageId: string) => void;
499
+ }
500
+
501
+ interface TerminalChatMessageProps extends ChatMessageBaseProps {
502
+ contentWidth: number;
503
+ messageBodyWidth: number;
504
+ setHoveredIdx: (updater: (current: number | null) => number | null) => void;
505
+ }
506
+
507
+ function TerminalChatMessage({
508
+ msg,
509
+ index,
510
+ messages,
511
+ selectedIdx,
512
+ hoveredIdx,
513
+ canSend,
514
+ contentWidth,
515
+ messageBodyWidth,
516
+ catalog,
517
+ openTicker,
518
+ beginReplyTo,
519
+ jumpToMessage,
520
+ setHoveredIdx,
521
+ }: TerminalChatMessageProps) {
522
+ const state = getChatMessageRenderState({ msg, index, messages, selectedIdx, hoveredIdx, canSend });
523
+ const bodyLines = getMessageBodyLines(msg, contentWidth);
524
+ const showInlineReplyAction = !state.grouped && state.showReplyAction;
525
+ const showGroupedReplyAction = state.grouped && state.showReplyAction;
526
+ const setHovered = () => setHoveredIdx((current) => (current === index ? current : index));
527
+ const clearHovered = () => setHoveredIdx((current) => (current === index ? null : current));
528
+ const messageRowProps = {
529
+ width: contentWidth,
530
+ backgroundColor: state.bgColor,
531
+ onMouseMove: setHovered,
532
+ onMouseOut: clearHovered,
533
+ };
534
+
535
+ return (
536
+ <Box key={msg.id} width={contentWidth} flexDirection="column">
537
+ {msg.replyTo && (
538
+ <Box
539
+ {...messageRowProps}
540
+ flexDirection="row"
541
+ height={1}
542
+ paddingLeft={2}
543
+ onMouseDown={() => {
544
+ if (msg.replyToId) jumpToMessage(msg.replyToId);
545
+ }}
546
+ >
547
+ <Text fg={state.replyMetaColor}>reply </Text>
548
+ <Text fg={state.replyAuthorColor}>{msg.replyTo.user.username}: </Text>
549
+ <Text fg={state.replyMetaColor}>
550
+ {formatInlinePreview(
551
+ msg.replyTo.content,
552
+ Math.max(messageBodyWidth - `reply ${msg.replyTo.user.username}: `.length, 0),
553
+ )}
554
+ </Text>
555
+ </Box>
556
+ )}
557
+ {!state.grouped && (
558
+ <Box
559
+ {...messageRowProps}
560
+ flexDirection="row"
561
+ height={1}
562
+ paddingLeft={1}
563
+ >
564
+ <Text fg={state.authorColor} attributes={state.authorAttributes}>
565
+ {msg.user.username ?? "anon"}
566
+ </Text>
567
+ <Text fg={state.headerStatusColor}> {state.headerStatus}</Text>
568
+ {showInlineReplyAction && (
569
+ <>
570
+ <Text fg={state.headerStatusColor}> </Text>
571
+ <Box width={MESSAGE_ACTION_WIDTH} height={1}>
572
+ <ChatActionChip
573
+ label="Reply"
574
+ width={MESSAGE_ACTION_WIDTH}
575
+ emphasized={state.isSelected}
576
+ onPress={() => beginReplyTo(index)}
577
+ />
578
+ </Box>
579
+ </>
580
+ )}
581
+ </Box>
582
+ )}
583
+ {bodyLines.map((line, lineIndex) => (
584
+ <Box
585
+ key={`${msg.id}:body:${lineIndex}`}
586
+ {...messageRowProps}
587
+ paddingLeft={3}
588
+ height={1}
589
+ flexDirection="row"
590
+ position={state.grouped ? "relative" : undefined}
591
+ >
592
+ <Box width={messageBodyWidth} height={1}>
593
+ <TickerBadgeText
594
+ text={line}
595
+ lineWidth={messageBodyWidth}
596
+ catalog={catalog}
597
+ textColor={state.bodyColor}
598
+ openTicker={openTicker}
599
+ />
600
+ </Box>
601
+ {lineIndex === 0 && showGroupedReplyAction && (
602
+ <Box position="absolute" top={0} right={0} width={MESSAGE_ACTION_WIDTH} height={1}>
603
+ <ChatActionChip
604
+ label="Reply"
605
+ width={MESSAGE_ACTION_WIDTH}
606
+ emphasized={state.isSelected}
607
+ onPress={() => beginReplyTo(index)}
608
+ />
609
+ </Box>
610
+ )}
611
+ </Box>
612
+ ))}
613
+ </Box>
614
+ );
615
+ }
616
+
617
+ const DesktopChatMessage = memo(function DesktopChatMessage({
618
+ msg,
619
+ index,
620
+ messages,
621
+ selectedIdx,
622
+ hoveredIdx,
623
+ canSend,
624
+ catalog,
625
+ openTicker,
626
+ beginReplyTo,
627
+ jumpToMessage,
628
+ registerMessageElement,
629
+ }: ChatMessageBaseProps & {
630
+ registerMessageElement: (messageId: string, node: unknown | null) => void;
631
+ }) {
632
+ const state = getChatMessageRenderState({ msg, index, messages, selectedIdx, hoveredIdx, canSend });
633
+ const showInlineReplyAction = !state.grouped && canSend;
634
+ const showGroupedReplyAction = state.grouped && canSend;
635
+ const rowProps = {
636
+ width: "100%",
637
+ paddingRight: DESKTOP_MESSAGE_RIGHT_PADDING,
638
+ backgroundColor: state.bgColor,
639
+ "data-gloom-role": "chat-message-row",
640
+ "data-selected": state.isSelected ? "true" : "false",
641
+ style: { minWidth: 0 },
642
+ };
643
+
644
+ return (
645
+ <Box
646
+ key={msg.id}
647
+ ref={(node: unknown | null) => registerMessageElement(msg.id, node)}
648
+ width="100%"
649
+ flexDirection="column"
650
+ data-gloom-role="chat-message"
651
+ data-gloom-chat-message-id={msg.id}
652
+ data-selected={state.isSelected ? "true" : "false"}
653
+ style={{ "--chat-hover-bg": hoverBg(), minWidth: 0 }}
654
+ >
655
+ {msg.replyTo && (
656
+ <Box
657
+ {...rowProps}
658
+ flexDirection="row"
659
+ paddingLeft={2}
660
+ onMouseDown={() => {
661
+ if (msg.replyToId) jumpToMessage(msg.replyToId);
662
+ }}
663
+ style={{ minWidth: 0, alignItems: "flex-start", cursor: "pointer" }}
664
+ >
665
+ <Text
666
+ fg={state.replyMetaColor}
667
+ wrapText
668
+ style={{
669
+ minWidth: 0,
670
+ width: "100%",
671
+ display: "-webkit-box",
672
+ WebkitBoxOrient: "vertical",
673
+ WebkitLineClamp: 2,
674
+ overflow: "hidden",
675
+ textOverflow: "ellipsis",
676
+ whiteSpace: "normal",
677
+ overflowWrap: "anywhere",
678
+ }}
679
+ >
680
+ <Span fg={state.replyMetaColor}>reply </Span>
681
+ <Span fg={state.replyAuthorColor}>{msg.replyTo.user.username}: </Span>
682
+ <Span fg={state.replyMetaColor}>{normalizeInlinePreview(msg.replyTo.content)}</Span>
683
+ </Text>
684
+ </Box>
685
+ )}
686
+ {!state.grouped && (
687
+ <Box
688
+ {...rowProps}
689
+ flexDirection="row"
690
+ height={1}
691
+ paddingLeft={1}
692
+ >
693
+ <Text fg={state.authorColor} attributes={state.authorAttributes}>
694
+ {msg.user.username ?? "anon"}
695
+ </Text>
696
+ <Text fg={state.headerStatusColor}> {state.headerStatus}</Text>
697
+ {showInlineReplyAction && (
698
+ <>
699
+ <Text fg={state.headerStatusColor}> </Text>
700
+ <Box
701
+ width={MESSAGE_ACTION_WIDTH}
702
+ height={1}
703
+ data-gloom-role="chat-message-reply-action"
704
+ >
705
+ <ChatActionChip
706
+ label="Reply"
707
+ width={MESSAGE_ACTION_WIDTH}
708
+ emphasized={state.isSelected}
709
+ onPress={() => beginReplyTo(index)}
710
+ />
711
+ </Box>
712
+ </>
713
+ )}
714
+ </Box>
715
+ )}
716
+ <Box
717
+ {...rowProps}
718
+ paddingLeft={3}
719
+ flexDirection="row"
720
+ position={state.grouped ? "relative" : undefined}
721
+ style={{ minWidth: 0, alignItems: "flex-start" }}
722
+ >
723
+ <Box flexGrow={1} style={{ minWidth: 0 }}>
724
+ <ResponsiveTickerBadgeText
725
+ text={msg.content}
726
+ catalog={catalog}
727
+ textColor={state.bodyColor}
728
+ openTicker={openTicker}
729
+ />
730
+ </Box>
731
+ {showGroupedReplyAction && (
732
+ <Box
733
+ position="absolute"
734
+ top={0}
735
+ right={0}
736
+ width={MESSAGE_ACTION_WIDTH}
737
+ height={1}
738
+ data-gloom-role="chat-message-reply-action"
739
+ >
740
+ <ChatActionChip
741
+ label="Reply"
742
+ width={MESSAGE_ACTION_WIDTH}
743
+ emphasized={state.isSelected}
744
+ onPress={() => beginReplyTo(index)}
745
+ />
746
+ </Box>
747
+ )}
748
+ </Box>
749
+ </Box>
750
+ );
751
+ });
752
+
753
+ function ChannelSidebar({
754
+ channels,
755
+ activeChannelId,
756
+ width,
757
+ height,
758
+ focused,
759
+ keyboardFocused,
760
+ loading,
761
+ onSelect,
762
+ onFocusRequest,
763
+ }: {
764
+ channels: ChatChannel[];
765
+ activeChannelId: string;
766
+ width: number;
767
+ height: number;
768
+ focused: boolean;
769
+ keyboardFocused: boolean;
770
+ loading: boolean;
771
+ onSelect?: (channelId: string) => void;
772
+ onFocusRequest?: () => void;
773
+ }) {
774
+ const { nativePaneChrome } = useUiCapabilities();
775
+ const borderWidth = nativePaneChrome ? 0 : width > 1 ? 1 : 0;
776
+ const listWidth = Math.max(width - borderWidth, 1);
777
+ const labelWidth = Math.max(listWidth - 3, 1);
778
+ const dividerColor = focused ? colors.borderFocused : colors.border;
779
+ const sidebarBg = keyboardFocused ? blendHex(colors.panel, colors.borderFocused, 0.18) : colors.panel;
780
+ const activeBg = keyboardFocused
781
+ ? blendHex(colors.selected, colors.borderFocused, 0.32)
782
+ : blendHex(colors.panel, colors.selected, 0.35);
783
+ const [hoveredChannelId, setHoveredChannelId] = useState<string | null>(null);
784
+ const sidebarLayoutHeight = nativePaneChrome ? "100%" : height;
785
+ const nativeFillStyle = nativePaneChrome ? { minHeight: 0 } : undefined;
786
+ const sidebarBorder = borderWidth > 0
787
+ ? (
788
+ <Box width={1} height={height} flexDirection="column">
789
+ {Array.from({ length: height }, (_, index) => (
790
+ <Text key={index} fg={dividerColor} selectable={false}>│</Text>
791
+ ))}
792
+ </Box>
793
+ )
794
+ : null;
795
+
796
+ return (
797
+ <Box
798
+ width={width}
799
+ height={sidebarLayoutHeight}
800
+ flexDirection="row"
801
+ position="relative"
802
+ style={nativeFillStyle}
803
+ >
804
+ <Box
805
+ width={listWidth}
806
+ height={sidebarLayoutHeight}
807
+ flexDirection="column"
808
+ backgroundColor={sidebarBg}
809
+ style={nativeFillStyle}
810
+ >
811
+ {channels.map((channel) => {
812
+ const active = channel.id === activeChannelId;
813
+ const hovered = hoveredChannelId === channel.id && !active;
814
+ const fg = active ? colors.selectedText : keyboardFocused ? colors.text : colors.textDim;
815
+ const bg = active ? activeBg : hovered ? hoverBg() : sidebarBg;
816
+ const label = formatChannelLabel(channel, channel.id);
817
+ const selectChannel = (event: any) => {
818
+ if (event) {
819
+ event.preventDefault?.();
820
+ event.stopPropagation?.();
821
+ if (event[CHAT_CHANNEL_MOUSE_HANDLED]) return;
822
+ event[CHAT_CHANNEL_MOUSE_HANDLED] = true;
823
+ }
824
+ onFocusRequest?.();
825
+ onSelect?.(channel.id);
826
+ };
827
+ return (
828
+ <Box
829
+ key={channel.id}
830
+ height={1}
831
+ width={listWidth}
832
+ flexDirection="row"
833
+ backgroundColor={bg}
834
+ onMouseDown={selectChannel}
835
+ onMouseMove={() => setHoveredChannelId((current) => (current === channel.id ? current : channel.id))}
836
+ onMouseOut={() => setHoveredChannelId((current) => (current === channel.id ? null : current))}
837
+ style={{ cursor: "pointer" }}
838
+ >
839
+ <Text fg={fg} selectable={false} onMouseDown={selectChannel}> </Text>
840
+ <Text fg={fg} selectable={false} onMouseDown={selectChannel}>{active ? "#" : " "}</Text>
841
+ <Text fg={fg} selectable={false} onMouseDown={selectChannel}>{truncateChannelLabel(label, labelWidth)}</Text>
842
+ <Box flexGrow={1} onMouseDown={selectChannel} />
843
+ </Box>
844
+ );
845
+ })}
846
+ <Box flexGrow={1} />
847
+ {loading && focused && (
848
+ <Box height={1} width={listWidth} flexDirection="row">
849
+ <Text fg={colors.textDim}> syncing</Text>
850
+ </Box>
851
+ )}
852
+ </Box>
853
+ {sidebarBorder}
854
+ {nativePaneChrome && (
855
+ <Box
856
+ position="absolute"
857
+ top={0}
858
+ right={0}
859
+ width={1}
860
+ height={sidebarLayoutHeight}
861
+ style={{
862
+ width: 1,
863
+ height: "100%",
864
+ backgroundColor: dividerColor,
865
+ pointerEvents: "none",
866
+ }}
867
+ />
868
+ )}
869
+ </Box>
870
+ );
871
+ }
872
+
216
873
  export function ChatContent({
217
874
  width,
218
875
  height,
219
876
  focused,
220
877
  close,
878
+ channelId: rawChannelId,
879
+ onChannelChange,
221
880
  controller = chatController,
222
881
  }: ChatContentProps) {
223
882
  const dispatch = useAppDispatch();
224
- const initialSnapshot = controller.getSnapshot();
883
+ const channelId = normalizeChannelId(rawChannelId);
884
+ const channelIdRef = useRef(channelId);
885
+ channelIdRef.current = channelId;
886
+ const initialSnapshot = controller.getSnapshot(channelId);
887
+ const { nativePaneChrome } = useUiCapabilities();
888
+ const [channels, setChannels] = useState<ChatChannel[]>(initialSnapshot.channels);
889
+ const [channelsLoading, setChannelsLoading] = useState(initialSnapshot.channelsLoading);
890
+ const showChannelSidebar = channels.length > 1 && width >= CHANNEL_SIDEBAR_BREAKPOINT && height >= 8;
891
+ const channelSidebarWidth = showChannelSidebar
892
+ ? Math.min(CHANNEL_SIDEBAR_MAX_WIDTH, Math.max(CHANNEL_SIDEBAR_MIN_WIDTH, Math.floor(width * 0.24)))
893
+ : 0;
894
+ const compactChannelHeaderHeight = nativePaneChrome && !showChannelSidebar && channels.length > 1 ? 1 : 0;
895
+ const chatWidth = Math.max(width - channelSidebarWidth, 1);
896
+ const contentWidth = Math.max(chatWidth - 2, 1);
897
+ const composerPrefixWidth = nativePaneChrome ? 0 : 3;
898
+ const composerTextWidth = Math.max(contentWidth - composerPrefixWidth, 1);
899
+ const inputValueRef = useRef(initialSnapshot.draft);
225
900
  const [messages, setMessages] = useState<ChatMessage[]>(initialSnapshot.messages);
226
901
  const [hasSavedSession, setHasSavedSession] = useState(initialSnapshot.hasSavedSession);
227
902
  const [user, setUser] = useState<{ id: string; username: string; emailVerified: boolean } | null>(initialSnapshot.user);
@@ -229,17 +904,22 @@ export function ChatContent({
229
904
  const [loadingOlderMessages, setLoadingOlderMessages] = useState(initialSnapshot.loadingOlderMessages);
230
905
  const [hasOlderMessages, setHasOlderMessages] = useState(initialSnapshot.hasOlderMessages);
231
906
  const [inputFocused, setInputFocused] = useState(false);
232
- const [inputValue, setInputValue] = useState(initialSnapshot.draft);
907
+ const [composerRows, setComposerRows] = useState(() => estimateComposerHeight(initialSnapshot.draft, composerTextWidth));
233
908
  const [selectedIdx, setSelectedIdx] = useState(-1);
234
909
  const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
910
+ const [sidebarFocused, setSidebarFocusedState] = useState(false);
911
+ const sidebarFocusedRef = useRef(false);
912
+ const setSidebarFocused = useCallback((nextFocused: boolean) => {
913
+ sidebarFocusedRef.current = nextFocused;
914
+ setSidebarFocusedState((current) => (current === nextFocused ? current : nextFocused));
915
+ }, []);
235
916
  const [followMessages, setFollowMessages] = useState(true);
236
- const contentWidth = Math.max(width - 2, 1);
237
917
  const [replyTo, setReplyTo] = useState<ChatMessage | null>(() => (
238
918
  initialSnapshot.replyToId ? initialSnapshot.messages.find((message) => message.id === initialSnapshot.replyToId) ?? null : null
239
919
  ));
240
- const { nativePaneChrome } = useUiCapabilities();
241
920
  const inputRef = useRef<TextareaRenderable>(null);
242
921
  const scrollRef = useRef<ScrollBoxRenderable>(null);
922
+ const messageElementsRef = useRef(new Map<string, unknown>());
243
923
  const applyingExternalDraftRef = useRef(false);
244
924
  const prependAnchorRef = useRef<{
245
925
  oldestMessageId: string | null;
@@ -248,11 +928,8 @@ export function ChatContent({
248
928
  selectedMessageId: string | null;
249
929
  } | null>(null);
250
930
  const canSend = !!user?.emailVerified;
251
- const nativeComposerTopGap = nativePaneChrome && canSend ? NATIVE_CHAT_COMPOSER_TOP_GAP_PX : 0;
931
+ const useDefaultControllerChannel = channelId === DEFAULT_CHAT_CHANNEL_ID && !onChannelChange;
252
932
  const messageBodyWidth = Math.max(contentWidth - 4, 1);
253
- const composerPrefixWidth = nativePaneChrome ? 0 : 3;
254
- const composerTextWidth = Math.max(contentWidth - composerPrefixWidth, 1);
255
- const composerRows = estimateComposerHeight(inputValue, composerTextWidth);
256
933
  const composerHeight = canSend
257
934
  ? nativePaneChrome
258
935
  ? Math.min(CHAT_COMPOSER_MAX_ROWS + 1, Math.max(2, composerRows + 1))
@@ -260,20 +937,66 @@ export function ChatContent({
260
937
  : 0;
261
938
  const selectionActive = selectedIdx >= 0 && selectedIdx < messages.length;
262
939
  const stickyTranscript = followMessages && !selectionActive;
940
+ const updateComposerRows = useCallback((draft: string) => {
941
+ const nextRows = estimateComposerHeight(draft, composerTextWidth);
942
+ setComposerRows((current) => (current === nextRows ? current : nextRows));
943
+ }, [composerTextWidth]);
263
944
 
264
945
  useEffect(() => {
265
- return controller.attachView();
946
+ return useDefaultControllerChannel ? controller.attachView() : controller.attachChannelView(channelId);
947
+ }, [channelId, controller, useDefaultControllerChannel]);
948
+
949
+ useEffect(() => {
950
+ updateComposerRows(inputValueRef.current);
951
+ }, [updateComposerRows]);
952
+
953
+ useEffect(() => {
954
+ void controller.refreshChannels().catch(() => {});
955
+ void controller.refreshSession().catch(() => {});
266
956
  }, [controller]);
267
957
 
268
958
  useEffect(() => {
269
- const unsubscribe = controller.subscribe((snapshot) => {
959
+ setSelectedIdx(-1);
960
+ setFollowMessages(true);
961
+ prependAnchorRef.current = null;
962
+ const snapshot = controller.getSnapshot(channelId);
963
+ setMessages(snapshot.messages);
964
+ setChannels(snapshot.channels);
965
+ setChannelsLoading(snapshot.channelsLoading);
966
+ setHasSavedSession(snapshot.hasSavedSession);
967
+ setUser(snapshot.user);
968
+ setLoading(snapshot.loading);
969
+ setLoadingOlderMessages(snapshot.loadingOlderMessages);
970
+ setHasOlderMessages(snapshot.hasOlderMessages);
971
+ inputValueRef.current = snapshot.draft;
972
+ updateComposerRows(snapshot.draft);
973
+ const textarea = inputRef.current;
974
+ if (textarea && textarea.editBuffer.getText() !== snapshot.draft) {
975
+ applyingExternalDraftRef.current = true;
976
+ textarea.setText(snapshot.draft);
977
+ applyingExternalDraftRef.current = false;
978
+ }
979
+ setReplyTo(snapshot.replyToId
980
+ ? snapshot.messages.find((message) => message.id === snapshot.replyToId) ?? null
981
+ : null);
982
+
983
+ const unsubscribe = useDefaultControllerChannel
984
+ ? controller.subscribe(handleSnapshot)
985
+ : controller.subscribe(channelId, handleSnapshot);
986
+
987
+ function handleSnapshot(snapshot: ReturnType<ChatController["getSnapshot"]>) {
270
988
  setMessages(snapshot.messages);
989
+ setChannels(snapshot.channels);
990
+ setChannelsLoading(snapshot.channelsLoading);
271
991
  setHasSavedSession(snapshot.hasSavedSession);
272
992
  setUser(snapshot.user);
273
993
  setLoading(snapshot.loading);
274
994
  setLoadingOlderMessages(snapshot.loadingOlderMessages);
275
995
  setHasOlderMessages(snapshot.hasOlderMessages);
276
- setInputValue((current) => (current === snapshot.draft ? current : snapshot.draft));
996
+ if (inputValueRef.current !== snapshot.draft) {
997
+ inputValueRef.current = snapshot.draft;
998
+ updateComposerRows(snapshot.draft);
999
+ }
277
1000
  const textarea = inputRef.current;
278
1001
  if (textarea && textarea.editBuffer.getText() !== snapshot.draft) {
279
1002
  applyingExternalDraftRef.current = true;
@@ -283,25 +1006,33 @@ export function ChatContent({
283
1006
  setReplyTo(snapshot.replyToId
284
1007
  ? snapshot.messages.find((message) => message.id === snapshot.replyToId) ?? null
285
1008
  : null);
286
- });
1009
+ }
287
1010
 
288
- void controller.refreshSession().catch(() => {});
289
- void controller.refreshMessages().catch(() => {});
1011
+ void (useDefaultControllerChannel ? controller.refreshMessages() : controller.refreshChannelMessages(channelId)).catch(() => {});
290
1012
  return unsubscribe;
291
- }, [controller]);
1013
+ }, [channelId, controller, updateComposerRows, useDefaultControllerChannel]);
292
1014
 
293
- const { catalog, openTicker } = useInlineTickers(messages.map((message) => message.content));
1015
+ const messageContents = useMemo(() => messages.map((message) => message.content), [messages]);
1016
+ const { catalog, openTicker } = useInlineTickers(messageContents);
294
1017
 
295
- const inputValueRef = useRef(inputValue);
296
- inputValueRef.current = inputValue;
297
1018
  const replyToRef = useRef(replyTo);
298
1019
  replyToRef.current = replyTo;
1020
+ const pendingJumpMessageIdRef = useRef<string | null>(null);
1021
+
1022
+ const registerMessageElement = useCallback((messageId: string, node: unknown | null) => {
1023
+ if (node) {
1024
+ messageElementsRef.current.set(messageId, node);
1025
+ } else {
1026
+ messageElementsRef.current.delete(messageId);
1027
+ }
1028
+ }, []);
299
1029
 
300
1030
  const focusInput = useCallback(() => {
1031
+ setSidebarFocused(false);
301
1032
  setInputFocused(true);
302
1033
  dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
303
- inputRef.current?.focus();
304
- }, [dispatch]);
1034
+ inputRef.current?.focus?.();
1035
+ }, [dispatch, setSidebarFocused]);
305
1036
 
306
1037
  const focusComposer = useCallback(() => {
307
1038
  setSelectedIdx(-1);
@@ -316,8 +1047,12 @@ export function ChatContent({
316
1047
 
317
1048
  const clearReplyTarget = useCallback(() => {
318
1049
  setReplyTo(null);
319
- controller.setReplyToId(null);
320
- }, [controller]);
1050
+ if (useDefaultControllerChannel) {
1051
+ controller.setReplyToId(null);
1052
+ } else {
1053
+ controller.setChannelReplyToId(channelId, null);
1054
+ }
1055
+ }, [channelId, controller, useDefaultControllerChannel]);
321
1056
 
322
1057
  const beginReplyTo = useCallback((index: number, options?: { deferFocus?: boolean }) => {
323
1058
  if (!canSend || index < 0 || index >= messages.length) return;
@@ -326,13 +1061,17 @@ export function ChatContent({
326
1061
  setSelectedIdx(index);
327
1062
  setFollowMessages(index === messages.length - 1);
328
1063
  setReplyTo(nextReplyTo);
329
- controller.setReplyToId(nextReplyTo.id);
1064
+ if (useDefaultControllerChannel) {
1065
+ controller.setReplyToId(nextReplyTo.id);
1066
+ } else {
1067
+ controller.setChannelReplyToId(channelId, nextReplyTo.id);
1068
+ }
330
1069
  if (options?.deferFocus) {
331
1070
  queueMicrotask(() => focusInput());
332
1071
  } else {
333
1072
  focusInput();
334
1073
  }
335
- }, [canSend, controller, focusInput, messages]);
1074
+ }, [canSend, channelId, controller, focusInput, messages, useDefaultControllerChannel]);
336
1075
 
337
1076
  const returnToComposer = useCallback(() => {
338
1077
  setSelectedIdx(-1);
@@ -375,26 +1114,34 @@ export function ChatContent({
375
1114
  const sendMessage = useCallback(() => {
376
1115
  const content = inputValueRef.current.trim();
377
1116
  if (!content) return;
378
- controller.send(content, replyToRef.current?.id);
1117
+ if (useDefaultControllerChannel) {
1118
+ controller.send(content, replyToRef.current?.id);
1119
+ } else {
1120
+ controller.sendToChannel(channelId, content, replyToRef.current?.id);
1121
+ }
379
1122
  setSelectedIdx(-1);
380
1123
  setFollowMessages(true);
381
- }, [controller]);
1124
+ }, [channelId, controller, useDefaultControllerChannel]);
382
1125
 
383
1126
  const requestOlderMessages = useCallback(() => {
384
1127
  const scrollBox = scrollRef.current;
385
1128
  if (!scrollBox || loadingOlderMessages || !hasOlderMessages || messages.length === 0) return;
1129
+ const exactPixels = nativePaneChrome === true && hasPixelScrollMetrics(scrollBox);
386
1130
 
387
1131
  prependAnchorRef.current = {
388
1132
  oldestMessageId: messages[0]?.id ?? null,
389
- scrollHeight: scrollBox.scrollHeight,
390
- scrollTop: scrollBox.scrollTop,
1133
+ scrollHeight: getScrollHeight(scrollBox, exactPixels),
1134
+ scrollTop: getScrollTop(scrollBox, exactPixels),
391
1135
  selectedMessageId: selectedIdx >= 0 ? messages[selectedIdx]?.id ?? null : null,
392
1136
  };
393
1137
  setFollowMessages(false);
394
- void controller.loadOlderMessages().catch(() => {
1138
+ const request = useDefaultControllerChannel
1139
+ ? controller.loadOlderMessages()
1140
+ : controller.loadOlderChannelMessages(channelId);
1141
+ void request.catch(() => {
395
1142
  prependAnchorRef.current = null;
396
1143
  });
397
- }, [controller, hasOlderMessages, loadingOlderMessages, messages, selectedIdx]);
1144
+ }, [channelId, controller, hasOlderMessages, loadingOlderMessages, messages, nativePaneChrome, selectedIdx, useDefaultControllerChannel]);
398
1145
 
399
1146
  const requestOlderMessagesIfNeeded = useCallback(() => {
400
1147
  const scrollBox = scrollRef.current;
@@ -402,6 +1149,110 @@ export function ChatContent({
402
1149
  requestOlderMessages();
403
1150
  }, [requestOlderMessages]);
404
1151
 
1152
+ const scrollToLoadedMessage = useCallback((messageId: string) => {
1153
+ const targetIndex = messages.findIndex((message) => message.id === messageId);
1154
+ if (targetIndex < 0) return false;
1155
+
1156
+ setSelectedIdx(targetIndex);
1157
+ setFollowMessages(false);
1158
+ prependAnchorRef.current = null;
1159
+ runAfterLayout(() => {
1160
+ if (nativePaneChrome && scrollElementIntoScrollBoxView(scrollRef.current, messageElementsRef.current.get(messageId))) return;
1161
+ const scrollBox = scrollRef.current;
1162
+ if (!scrollBox) return;
1163
+ scrollBox.scrollTo(getMessageTopOffset(messages, targetIndex, contentWidth));
1164
+ });
1165
+ return true;
1166
+ }, [contentWidth, messages, nativePaneChrome]);
1167
+
1168
+ const jumpToMessage = useCallback((messageId: string) => {
1169
+ if (scrollToLoadedMessage(messageId)) return;
1170
+ pendingJumpMessageIdRef.current = messageId;
1171
+ if (hasOlderMessages && !loadingOlderMessages) {
1172
+ requestOlderMessages();
1173
+ } else {
1174
+ pendingJumpMessageIdRef.current = null;
1175
+ }
1176
+ }, [hasOlderMessages, loadingOlderMessages, requestOlderMessages, scrollToLoadedMessage]);
1177
+
1178
+ useEffect(() => {
1179
+ const pendingId = pendingJumpMessageIdRef.current;
1180
+ if (!pendingId) return;
1181
+ if (scrollToLoadedMessage(pendingId)) {
1182
+ pendingJumpMessageIdRef.current = null;
1183
+ prependAnchorRef.current = null;
1184
+ return;
1185
+ }
1186
+ if (hasOlderMessages && !loadingOlderMessages) {
1187
+ requestOlderMessages();
1188
+ } else {
1189
+ pendingJumpMessageIdRef.current = null;
1190
+ }
1191
+ }, [hasOlderMessages, loadingOlderMessages, messages, requestOlderMessages, scrollToLoadedMessage]);
1192
+
1193
+ const changeChannel = useCallback((nextChannelId: string) => {
1194
+ const normalized = normalizeChannelId(nextChannelId);
1195
+ if (normalized === channelIdRef.current) return;
1196
+ channelIdRef.current = normalized;
1197
+ setSelectedIdx(-1);
1198
+ setFollowMessages(true);
1199
+ onChannelChange?.(normalized);
1200
+ }, [onChannelChange]);
1201
+
1202
+ useEffect(() => {
1203
+ if (!onChannelChange || channelsLoading || channels.length === 0) return;
1204
+ if (channels.some((channel) => channel.id === channelId)) return;
1205
+ const fallbackChannelId = channels.find((channel) => channel.id === DEFAULT_CHAT_CHANNEL_ID)?.id
1206
+ ?? channels[0]?.id;
1207
+ if (fallbackChannelId) {
1208
+ changeChannel(fallbackChannelId);
1209
+ }
1210
+ }, [changeChannel, channelId, channels, channelsLoading, onChannelChange]);
1211
+
1212
+ const cycleChannel = useCallback((direction: 1 | -1) => {
1213
+ if (channels.length <= 1 || !onChannelChange) return false;
1214
+ const currentIndex = Math.max(0, channels.findIndex((channel) => channel.id === channelIdRef.current));
1215
+ const nextIndex = (currentIndex + direction + channels.length) % channels.length;
1216
+ const nextChannel = channels[nextIndex];
1217
+ if (!nextChannel) return false;
1218
+ changeChannel(nextChannel.id);
1219
+ return true;
1220
+ }, [changeChannel, channels, onChannelChange]);
1221
+
1222
+ const focusChannelSidebar = useCallback(() => {
1223
+ if (!showChannelSidebar || !onChannelChange) return false;
1224
+ if (inputFocused) {
1225
+ blurInput();
1226
+ }
1227
+ setSelectedIdx(-1);
1228
+ setSidebarFocused(true);
1229
+ return true;
1230
+ }, [blurInput, inputFocused, onChannelChange, setSidebarFocused, showChannelSidebar]);
1231
+
1232
+ const focusChatContent = useCallback(() => {
1233
+ if (!showChannelSidebar) return false;
1234
+ setSidebarFocused(false);
1235
+ return true;
1236
+ }, [setSidebarFocused, showChannelSidebar]);
1237
+
1238
+ const moveSidebarChannelSelection = useCallback((direction: "up" | "down") => {
1239
+ if (!showChannelSidebar || channels.length <= 1 || !onChannelChange) return false;
1240
+ const currentIndex = channels.findIndex((channel) => channel.id === channelIdRef.current);
1241
+ const baseIndex = currentIndex >= 0 ? currentIndex : 0;
1242
+ const nextIndex = direction === "down"
1243
+ ? Math.min(baseIndex + 1, channels.length - 1)
1244
+ : Math.max(baseIndex - 1, 0);
1245
+ const nextChannel = channels[nextIndex];
1246
+ if (!nextChannel || nextIndex === baseIndex) return true;
1247
+ changeChannel(nextChannel.id);
1248
+ return true;
1249
+ }, [changeChannel, channels, onChannelChange, showChannelSidebar]);
1250
+
1251
+ useEffect(() => {
1252
+ if (focused && showChannelSidebar) return;
1253
+ setSidebarFocused(false);
1254
+ }, [focused, setSidebarFocused, showChannelSidebar]);
1255
+
405
1256
  useEffect(() => {
406
1257
  if (!canSend && inputFocused) {
407
1258
  blurInput();
@@ -416,16 +1267,20 @@ export function ChatContent({
416
1267
 
417
1268
  useEffect(() => {
418
1269
  if (focused && inputFocused) {
419
- inputRef.current?.focus();
1270
+ inputRef.current?.focus?.();
420
1271
  }
421
1272
  }, [focused, inputFocused]);
422
1273
 
423
1274
  const commitLocalDraft = useCallback((draft: string) => {
424
1275
  if (applyingExternalDraftRef.current) return;
425
1276
  inputValueRef.current = draft;
426
- setInputValue((current) => (current === draft ? current : draft));
427
- controller.setDraft(draft);
428
- }, [controller]);
1277
+ updateComposerRows(draft);
1278
+ if (useDefaultControllerChannel) {
1279
+ controller.setDraft(draft);
1280
+ } else {
1281
+ controller.setChannelDraft(channelId, draft);
1282
+ }
1283
+ }, [channelId, controller, updateComposerRows, useDefaultControllerChannel]);
429
1284
 
430
1285
  useEffect(() => {
431
1286
  const textarea = inputRef.current;
@@ -451,6 +1306,39 @@ export function ChatContent({
451
1306
  if (!focused) return;
452
1307
  const isEnterKey = event.name === "return" || event.name === "enter";
453
1308
 
1309
+ if (sidebarFocusedRef.current && showChannelSidebar) {
1310
+ if (event.name === "left") {
1311
+ event.preventDefault?.();
1312
+ event.stopPropagation?.();
1313
+ return;
1314
+ }
1315
+
1316
+ if (event.name === "right") {
1317
+ event.preventDefault?.();
1318
+ event.stopPropagation?.();
1319
+ focusChatContent();
1320
+ return;
1321
+ }
1322
+
1323
+ if (event.name === "up" || event.name === "down") {
1324
+ event.preventDefault?.();
1325
+ event.stopPropagation?.();
1326
+ moveSidebarChannelSelection(event.name);
1327
+ return;
1328
+ }
1329
+ }
1330
+
1331
+ if (
1332
+ event.name === "left" &&
1333
+ showChannelSidebar &&
1334
+ (!inputFocused || inputValueRef.current.length === 0) &&
1335
+ focusChannelSidebar()
1336
+ ) {
1337
+ event.preventDefault?.();
1338
+ event.stopPropagation?.();
1339
+ return;
1340
+ }
1341
+
454
1342
  if (inputFocused) {
455
1343
  if (event.name === "escape") {
456
1344
  event.preventDefault?.();
@@ -476,6 +1364,17 @@ export function ChatContent({
476
1364
  return;
477
1365
  }
478
1366
 
1367
+ if ((event.name === "]" || event.sequence === "]") && cycleChannel(1)) {
1368
+ event.preventDefault?.();
1369
+ event.stopPropagation?.();
1370
+ return;
1371
+ }
1372
+ if ((event.name === "[" || event.sequence === "[") && cycleChannel(-1)) {
1373
+ event.preventDefault?.();
1374
+ event.stopPropagation?.();
1375
+ return;
1376
+ }
1377
+
479
1378
  if (canSend && isEnterKey && selectedIdx >= 0 && selectedIdx < messages.length) {
480
1379
  event.preventDefault?.();
481
1380
  event.stopPropagation?.();
@@ -542,28 +1441,10 @@ export function ChatContent({
542
1441
  event.stopPropagation?.();
543
1442
  setSelectedIdx(messages.length - 1);
544
1443
  setFollowMessages(true);
545
- queueMicrotask(() => scrollToBottom(scrollRef.current));
1444
+ queueMicrotask(() => scrollToBottom(scrollRef.current, nativePaneChrome));
546
1445
  return;
547
1446
  }
548
- }, [
549
- beginReplyTo,
550
- blurInput,
551
- canSend,
552
- clearReplyTarget,
553
- focusComposer,
554
- focused,
555
- inputFocused,
556
- hasOlderMessages,
557
- loadingOlderMessages,
558
- messages.length,
559
- moveMessageSelection,
560
- replyTo,
561
- requestOlderMessages,
562
- requestOlderMessagesIfNeeded,
563
- returnToComposer,
564
- selectedIdx,
565
- shouldLeaveComposerForSelection,
566
- ]);
1447
+ });
567
1448
 
568
1449
  const inputMetaHeight = canSend && replyTo ? 1 : 0;
569
1450
  const composerBlockHeight = canSend
@@ -572,8 +1453,8 @@ export function ChatContent({
572
1453
  const inputAreaHeight = canSend ? composerBlockHeight + inputMetaHeight : 2;
573
1454
  const topSeparatorHeight = nativePaneChrome ? 0 : 1;
574
1455
  const footerSeparatorHeight = !nativePaneChrome && !canSend ? 1 : 0;
575
- const messageAreaHeight = Math.max(1, height - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
576
- const composerWidth = nativePaneChrome ? width : contentWidth;
1456
+ const messageAreaHeight = Math.max(1, height - compactChannelHeaderHeight - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
1457
+ const composerWidth = nativePaneChrome ? chatWidth : contentWidth;
577
1458
 
578
1459
  useEffect(() => {
579
1460
  if (selectedIdx < messages.length) return;
@@ -582,8 +1463,8 @@ export function ChatContent({
582
1463
 
583
1464
  useEffect(() => {
584
1465
  if (!stickyTranscript) return;
585
- queueMicrotask(() => scrollToBottom(scrollRef.current));
586
- }, [contentWidth, height, messages, messageAreaHeight, stickyTranscript]);
1466
+ queueMicrotask(() => scrollToBottom(scrollRef.current, nativePaneChrome));
1467
+ }, [contentWidth, height, messages, messageAreaHeight, nativePaneChrome, stickyTranscript]);
587
1468
 
588
1469
  useEffect(() => {
589
1470
  const anchor = prependAnchorRef.current;
@@ -593,14 +1474,19 @@ export function ChatContent({
593
1474
  const currentAnchor = prependAnchorRef.current;
594
1475
  const scrollBox = scrollRef.current;
595
1476
  if (!currentAnchor || !scrollBox) return;
1477
+ if (pendingJumpMessageIdRef.current) {
1478
+ prependAnchorRef.current = null;
1479
+ return;
1480
+ }
596
1481
 
597
1482
  const previousOldestIndex = currentAnchor.oldestMessageId
598
1483
  ? messages.findIndex((message) => message.id === currentAnchor.oldestMessageId)
599
1484
  : -1;
600
- const addedRows = previousOldestIndex > 0
1485
+ const exactPixels = nativePaneChrome === true && hasPixelScrollMetrics(scrollBox);
1486
+ const addedRows = !exactPixels && previousOldestIndex > 0
601
1487
  ? getMessageTopOffset(messages, previousOldestIndex, contentWidth)
602
- : Math.max(0, scrollBox.scrollHeight - currentAnchor.scrollHeight);
603
- scrollBox.scrollTo(currentAnchor.scrollTop + addedRows);
1488
+ : Math.max(0, getScrollHeight(scrollBox, exactPixels) - currentAnchor.scrollHeight);
1489
+ scrollToPosition(scrollBox, currentAnchor.scrollTop + addedRows, exactPixels);
604
1490
  if (currentAnchor.selectedMessageId) {
605
1491
  const selectedMessageIndex = messages.findIndex((message) => message.id === currentAnchor.selectedMessageId);
606
1492
  if (selectedMessageIndex >= 0) {
@@ -609,29 +1495,34 @@ export function ChatContent({
609
1495
  }
610
1496
  prependAnchorRef.current = null;
611
1497
  });
612
- }, [contentWidth, loadingOlderMessages, messages]);
1498
+ }, [contentWidth, loadingOlderMessages, messages, nativePaneChrome]);
613
1499
 
614
1500
  useEffect(() => {
615
1501
  if (!focused || !stickyTranscript) return;
616
- queueMicrotask(() => scrollToBottom(scrollRef.current));
617
- }, [focused, stickyTranscript]);
1502
+ queueMicrotask(() => scrollToBottom(scrollRef.current, nativePaneChrome));
1503
+ }, [focused, nativePaneChrome, stickyTranscript]);
618
1504
 
619
1505
  useEffect(() => {
620
1506
  const sb = scrollRef.current;
621
1507
  if (!sb) return;
622
1508
  if (!selectionActive) return;
1509
+ if (nativePaneChrome) {
1510
+ const selectedMessageId = messages[selectedIdx]?.id ?? "";
1511
+ runAfterLayout(() => scrollElementIntoScrollBoxView(scrollRef.current, messageElementsRef.current.get(selectedMessageId)));
1512
+ return;
1513
+ }
623
1514
  const top = getMessageTopOffset(messages, selectedIdx, contentWidth);
624
1515
  const rowHeight = estimateMessageHeight(messages[selectedIdx]!, contentWidth, isGroupedWithPrevious(messages, selectedIdx));
625
1516
  const nextScrollTop = getSelectedMessageScrollTop({
626
1517
  scrollTop: sb.scrollTop,
627
- viewportHeight: sb.viewport.height,
1518
+ viewportHeight: sb.viewport?.height ?? 0,
628
1519
  top,
629
1520
  rowHeight,
630
1521
  });
631
1522
  if (nextScrollTop !== sb.scrollTop) {
632
1523
  sb.scrollTo(nextScrollTop);
633
1524
  }
634
- }, [contentWidth, messages, selectedIdx, selectionActive]);
1525
+ }, [contentWidth, messages, nativePaneChrome, selectedIdx, selectionActive]);
635
1526
 
636
1527
  const replyPreview = replyTo
637
1528
  ? formatInlinePreview(
@@ -640,17 +1531,54 @@ export function ChatContent({
640
1531
  )
641
1532
  : "";
642
1533
  const inputPlaceholder = replyTo ? `Reply to @${replyTo.user.username}...` : "Type a message...";
643
-
644
- if (loading && messages.length === 0) {
645
- return (
646
- <Box flexGrow={1} alignItems="center" justifyContent="center">
647
- <Text fg={colors.textDim}>Loading...</Text>
648
- </Box>
649
- );
650
- }
1534
+ const activeChannel = channels.find((channel) => channel.id === channelId);
1535
+ const activeChannelLabel = formatChannelLabel(activeChannel, channelId);
1536
+ const chatContentBg = focused && showChannelSidebar && !sidebarFocused
1537
+ ? blendHex(colors.bg, colors.borderFocused, 0.08)
1538
+ : undefined;
1539
+ const chatLayoutHeight = nativePaneChrome ? "100%" : height;
1540
+ const nativeFillStyle = nativePaneChrome ? { minHeight: 0 } : undefined;
651
1541
 
652
1542
  return (
653
- <Box flexDirection="column" width={width} height={height}>
1543
+ <Box
1544
+ flexDirection="row"
1545
+ width={width}
1546
+ height={chatLayoutHeight}
1547
+ flexGrow={nativePaneChrome ? 1 : undefined}
1548
+ style={nativeFillStyle}
1549
+ >
1550
+ {showChannelSidebar && (
1551
+ <ChannelSidebar
1552
+ channels={channels}
1553
+ activeChannelId={channelId}
1554
+ width={channelSidebarWidth}
1555
+ height={height}
1556
+ focused={focused}
1557
+ keyboardFocused={sidebarFocused}
1558
+ loading={channelsLoading}
1559
+ onSelect={changeChannel}
1560
+ onFocusRequest={() => setSidebarFocused(true)}
1561
+ />
1562
+ )}
1563
+
1564
+ <Box
1565
+ flexDirection="column"
1566
+ width={chatWidth}
1567
+ height={chatLayoutHeight}
1568
+ flexGrow={nativePaneChrome ? 1 : undefined}
1569
+ backgroundColor={chatContentBg}
1570
+ onMouseDown={() => setSidebarFocused(false)}
1571
+ style={nativeFillStyle}
1572
+ >
1573
+ {compactChannelHeaderHeight > 0 && (
1574
+ <Box height={1} width={contentWidth} flexDirection="row">
1575
+ <Text fg={colors.textDim}>#</Text>
1576
+ <Text fg={colors.positive} attributes={TextAttributes.BOLD}>
1577
+ {truncateChannelLabel(activeChannelLabel, Math.max(contentWidth - 1, 1))}
1578
+ </Text>
1579
+ </Box>
1580
+ )}
1581
+
654
1582
  {!nativePaneChrome && (
655
1583
  <Box height={1} width={contentWidth}>
656
1584
  <Text fg={colors.border}>{"-".repeat(contentWidth)}</Text>
@@ -659,155 +1587,64 @@ export function ChatContent({
659
1587
 
660
1588
  <ScrollBox
661
1589
  ref={scrollRef}
662
- height={messageAreaHeight}
1590
+ height={nativePaneChrome ? undefined : messageAreaHeight}
1591
+ flexGrow={nativePaneChrome ? 1 : undefined}
663
1592
  scrollY
664
1593
  focusable={false}
665
1594
  stickyScroll={stickyTranscript}
666
1595
  stickyStart="bottom"
667
1596
  onMouseScroll={() => queueMicrotask(requestOlderMessagesIfNeeded)}
668
- style={nativeComposerTopGap > 0 ? { paddingBottom: nativeComposerTopGap } : undefined}
1597
+ style={nativePaneChrome ? { minHeight: 0 } : undefined}
669
1598
  >
670
1599
  {loadingOlderMessages && (
671
1600
  <Box alignItems="center" justifyContent="center" height={1} width={contentWidth}>
672
1601
  <Text fg={colors.textDim}>Loading earlier messages...</Text>
673
1602
  </Box>
674
1603
  )}
675
- {messages.length === 0 && (
1604
+ {loading && messages.length === 0 ? (
1605
+ <Box alignItems="center" justifyContent="center" flexGrow={1}>
1606
+ <Text fg={colors.textDim}>Loading...</Text>
1607
+ </Box>
1608
+ ) : messages.length === 0 && (
676
1609
  <Box alignItems="center" justifyContent="center" flexGrow={1}>
677
1610
  <Text fg={colors.textDim}>No messages yet. Be the first to say something!</Text>
678
1611
  </Box>
679
1612
  )}
680
- {messages.map((msg, index) => {
681
- const isSelected = index === selectedIdx;
682
- const isHovered = index === hoveredIdx && !isSelected;
683
- const showReplyAction = canSend && (isSelected || hoveredIdx === index);
684
- const bgColor = isSelected ? colors.selected : isHovered ? hoverBg() : undefined;
685
- const grouped = isGroupedWithPrevious(messages, index);
686
- const isSending = msg.clientStatus === "sending";
687
- const hasFailed = msg.clientStatus === "failed";
688
- const selectedTextColor = hasFailed ? colors.negative : colors.selectedText;
689
- const replyMetaColor = isSelected ? selectedTextColor : colors.textMuted;
690
- const replyAuthorColor = isSelected ? selectedTextColor : colors.textDim;
691
- const headerStatus = isSending ? "sending..." : hasFailed ? "failed" : formatTimeAgo(msg.createdAt);
692
- const headerStatusColor = isSelected
693
- ? selectedTextColor
694
- : isSending
695
- ? colors.textDim
696
- : hasFailed
697
- ? colors.negative
698
- : colors.textMuted;
699
- const authorColor = isSelected ? selectedTextColor : hasFailed ? colors.negative : colors.positive;
700
- const authorAttributes = (isSending ? TextAttributes.DIM : 0) | TextAttributes.BOLD;
701
- const bodyColor = isSelected ? selectedTextColor : hasFailed ? colors.negative : isSending ? colors.textDim : colors.text;
702
- const bodyLines = getMessageBodyLines(msg, contentWidth);
703
- const showInlineReplyAction = !grouped && (nativePaneChrome ? canSend : showReplyAction);
704
- const showGroupedReplyAction = grouped && (nativePaneChrome ? canSend : showReplyAction);
705
- const setHovered = () => setHoveredIdx((current) => (current === index ? current : index));
706
- const clearHovered = () => setHoveredIdx((current) => (current === index ? null : current));
707
- const messageRowProps = {
708
- width: contentWidth,
709
- backgroundColor: bgColor,
710
- "data-gloom-role": nativePaneChrome ? "chat-message-row" : undefined,
711
- "data-selected": nativePaneChrome ? (isSelected ? "true" : "false") : undefined,
712
- onMouseMove: nativePaneChrome ? undefined : setHovered,
713
- onMouseOut: nativePaneChrome ? undefined : clearHovered,
714
- };
715
- return (
716
- <Box
1613
+ {messages.map((msg, index) => (
1614
+ nativePaneChrome ? (
1615
+ <DesktopChatMessage
717
1616
  key={msg.id}
718
- width={contentWidth}
719
- flexDirection="column"
720
- data-gloom-role={nativePaneChrome ? "chat-message" : undefined}
721
- data-selected={nativePaneChrome ? (isSelected ? "true" : "false") : undefined}
722
- style={nativePaneChrome ? { "--chat-hover-bg": hoverBg() } : undefined}
723
- >
724
- {msg.replyTo && (
725
- <Box
726
- {...messageRowProps}
727
- flexDirection="row"
728
- height={1}
729
- paddingLeft={2}
730
- >
731
- <Text fg={replyMetaColor}>reply </Text>
732
- <Text fg={replyAuthorColor}>{msg.replyTo.user.username}: </Text>
733
- <Text fg={replyMetaColor}>
734
- {formatInlinePreview(
735
- msg.replyTo.content,
736
- Math.max(messageBodyWidth - `reply ${msg.replyTo.user.username}: `.length, 0),
737
- )}
738
- </Text>
739
- </Box>
740
- )}
741
- {!grouped && (
742
- <Box
743
- {...messageRowProps}
744
- flexDirection="row"
745
- height={1}
746
- paddingLeft={1}
747
- >
748
- <Text fg={authorColor} attributes={authorAttributes}>
749
- {msg.user.username ?? "anon"}
750
- </Text>
751
- <Text fg={headerStatusColor}> {headerStatus}</Text>
752
- {showInlineReplyAction && (
753
- <>
754
- <Text fg={headerStatusColor}> </Text>
755
- <Box
756
- width={MESSAGE_ACTION_WIDTH}
757
- height={1}
758
- data-gloom-role={nativePaneChrome ? "chat-message-reply-action" : undefined}
759
- >
760
- <ChatActionChip
761
- label="Reply"
762
- width={MESSAGE_ACTION_WIDTH}
763
- emphasized={isSelected}
764
- onPress={() => beginReplyTo(index)}
765
- />
766
- </Box>
767
- </>
768
- )}
769
- </Box>
770
- )}
771
- {bodyLines.map((line, lineIndex) => (
772
- <Box
773
- key={`${msg.id}:body:${lineIndex}`}
774
- {...messageRowProps}
775
- paddingLeft={3}
776
- height={1}
777
- flexDirection="row"
778
- position={grouped ? "relative" : undefined}
779
- >
780
- <Box width={messageBodyWidth} height={1}>
781
- <TickerBadgeText
782
- text={line}
783
- lineWidth={messageBodyWidth}
784
- catalog={catalog}
785
- textColor={bodyColor}
786
- openTicker={openTicker}
787
- />
788
- </Box>
789
- {lineIndex === 0 && showGroupedReplyAction && (
790
- <Box
791
- position="absolute"
792
- top={0}
793
- right={0}
794
- width={MESSAGE_ACTION_WIDTH}
795
- height={1}
796
- data-gloom-role={nativePaneChrome ? "chat-message-reply-action" : undefined}
797
- >
798
- <ChatActionChip
799
- label="Reply"
800
- width={MESSAGE_ACTION_WIDTH}
801
- emphasized={isSelected}
802
- onPress={() => beginReplyTo(index)}
803
- />
804
- </Box>
805
- )}
806
- </Box>
807
- ))}
808
- </Box>
809
- );
810
- })}
1617
+ msg={msg}
1618
+ index={index}
1619
+ messages={messages}
1620
+ selectedIdx={selectedIdx}
1621
+ hoveredIdx={hoveredIdx}
1622
+ canSend={canSend}
1623
+ catalog={catalog}
1624
+ openTicker={openTicker}
1625
+ beginReplyTo={beginReplyTo}
1626
+ jumpToMessage={jumpToMessage}
1627
+ registerMessageElement={registerMessageElement}
1628
+ />
1629
+ ) : (
1630
+ <TerminalChatMessage
1631
+ key={msg.id}
1632
+ msg={msg}
1633
+ index={index}
1634
+ messages={messages}
1635
+ selectedIdx={selectedIdx}
1636
+ hoveredIdx={hoveredIdx}
1637
+ canSend={canSend}
1638
+ contentWidth={contentWidth}
1639
+ messageBodyWidth={messageBodyWidth}
1640
+ catalog={catalog}
1641
+ openTicker={openTicker}
1642
+ beginReplyTo={beginReplyTo}
1643
+ jumpToMessage={jumpToMessage}
1644
+ setHoveredIdx={setHoveredIdx}
1645
+ />
1646
+ )
1647
+ ))}
811
1648
  </ScrollBox>
812
1649
 
813
1650
  {!nativePaneChrome && !canSend && (
@@ -834,7 +1671,7 @@ export function ChatContent({
834
1671
 
835
1672
  <MessageComposer
836
1673
  inputRef={inputRef}
837
- initialValue={inputValue}
1674
+ initialValue={inputValueRef.current}
838
1675
  focused={inputFocused && focused}
839
1676
  placeholder={inputPlaceholder}
840
1677
  terminalPrefix=" > "
@@ -878,23 +1715,85 @@ export function ChatContent({
878
1715
  )}
879
1716
  </Box>
880
1717
  )}
1718
+ </Box>
881
1719
  </Box>
882
1720
  );
883
1721
  }
884
1722
 
885
- export function ChatPane({ focused, width, height, close }: PaneProps) {
1723
+ function ChatPane({ focused, width, height, close }: PaneProps) {
1724
+ const dispatch = useAppDispatch();
1725
+ const stateRef = useAppStateRef();
1726
+ const paneId = usePaneInstanceId();
1727
+ const pane = usePaneInstance();
1728
+ const rawPaneChannelId = typeof pane?.settings?.channelId === "string" ? pane.settings.channelId : null;
1729
+ const lastVisitedChannelId = useAppSelector((state) => (
1730
+ (state.config.pluginConfig["gloomberb-cloud"]?.[LAST_VISITED_CHAT_CHANNEL_KEY] as string | undefined) ??
1731
+ DEFAULT_CHAT_CHANNEL_ID
1732
+ ));
1733
+ const initialChannelIdRef = useRef(normalizeChannelId(rawPaneChannelId ?? lastVisitedChannelId));
1734
+ const persistedChannelId = normalizeChannelId(rawPaneChannelId ?? initialChannelIdRef.current);
1735
+ const persistChannelId = useCallback((nextChannelId: string) => {
1736
+ const currentState = stateRef.current;
1737
+ const layout = setPaneSetting(currentState.config.layout, paneId, "channelId", nextChannelId);
1738
+ const layouts = currentState.config.layouts.map((savedLayout, index) => (
1739
+ index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
1740
+ ));
1741
+ const pluginConfig = {
1742
+ ...currentState.config.pluginConfig,
1743
+ "gloomberb-cloud": {
1744
+ ...(currentState.config.pluginConfig["gloomberb-cloud"] ?? {}),
1745
+ [LAST_VISITED_CHAT_CHANNEL_KEY]: nextChannelId,
1746
+ },
1747
+ };
1748
+ const nextConfig = {
1749
+ ...currentState.config,
1750
+ layout,
1751
+ layouts,
1752
+ pluginConfig,
1753
+ };
1754
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
1755
+ scheduleConfigSave(nextConfig);
1756
+ }, [dispatch, paneId, stateRef]);
1757
+ const [channelId, setLocalChannelId] = useState(initialChannelIdRef.current);
1758
+ const pendingChannelIdRef = useRef<string | null>(null);
1759
+
1760
+ useEffect(() => {
1761
+ if (rawPaneChannelId) return;
1762
+ persistChannelId(initialChannelIdRef.current);
1763
+ }, [persistChannelId, rawPaneChannelId]);
1764
+
1765
+ useEffect(() => {
1766
+ const normalizedPersisted = normalizeChannelId(persistedChannelId);
1767
+ if (pendingChannelIdRef.current) {
1768
+ if (pendingChannelIdRef.current === normalizedPersisted) {
1769
+ pendingChannelIdRef.current = null;
1770
+ }
1771
+ return;
1772
+ }
1773
+ setLocalChannelId((current) => (current === normalizedPersisted ? current : normalizedPersisted));
1774
+ }, [persistedChannelId]);
1775
+
1776
+ const setChannelId = useCallback((nextChannelId: string) => {
1777
+ const normalized = normalizeChannelId(nextChannelId);
1778
+ pendingChannelIdRef.current = normalized;
1779
+ setLocalChannelId((current) => (current === normalized ? current : normalized));
1780
+ persistChannelId(normalized);
1781
+ }, [persistChannelId]);
1782
+
886
1783
  return (
887
1784
  <ChatContent
888
1785
  width={width}
889
1786
  height={height}
890
1787
  focused={focused}
891
1788
  close={close}
1789
+ channelId={channelId}
1790
+ onChannelChange={setChannelId}
892
1791
  />
893
1792
  );
894
1793
  }
895
1794
 
896
1795
  export function ChatStatusWidget({ controller = chatController }: ChatStatusWidgetProps) {
897
- const { showWidget } = usePluginAppActions();
1796
+ const { showPane } = usePluginAppActions();
898
1797
  const cloudPluginDisabled = useAppSelector((state) => state.config.disabledPlugins.includes("gloomberb-cloud"));
899
1798
  const initialSnapshot = controller.getSnapshot();
900
1799
  const [username, setUsername] = useState<string | null>(initialSnapshot.user?.username ?? null);
@@ -905,7 +1804,7 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
905
1804
  const openChat = (event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
906
1805
  event?.preventDefault?.();
907
1806
  event?.stopPropagation?.();
908
- showWidget("chat");
1807
+ showPane("chat");
909
1808
  };
910
1809
 
911
1810
  useEffect(() => {
@@ -968,8 +1867,16 @@ export const gloomberbCloudPlugin: GloomPlugin = {
968
1867
  label: "New Chat Pane",
969
1868
  description: "Open another floating chat window",
970
1869
  keywords: ["new", "chat", "pane", "message"],
971
- shortcut: { prefix: "CHAT" },
972
- createInstance: () => ({ placement: "floating" }),
1870
+ shortcut: { prefix: "CHAT", argPlaceholder: "channel", argKind: "text" },
1871
+ createInstance: async (context, options) => {
1872
+ const channelId = options?.arg
1873
+ ? await chatController.resolveRequiredChannelId(normalizeShortcutChannelId(options.arg))
1874
+ : await chatController.resolvePreferredChannelId(getLastVisitedChatChannelId(context.config));
1875
+ return {
1876
+ placement: "floating",
1877
+ settings: { channelId },
1878
+ };
1879
+ },
973
1880
  },
974
1881
  ],
975
1882
 
@@ -1056,9 +1963,9 @@ export const gloomberbCloudPlugin: GloomPlugin = {
1056
1963
  execute: () => {
1057
1964
  const registry = getSharedRegistry();
1058
1965
  if (registry?.isPaneFloating("chat")) {
1059
- ctx.hideWidget("chat");
1966
+ ctx.hidePane("chat");
1060
1967
  } else {
1061
- ctx.showWidget("chat");
1968
+ ctx.showPane("chat");
1062
1969
  }
1063
1970
  },
1064
1971
  });
@@ -1086,7 +1993,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
1086
1993
  }
1087
1994
  chatController.clearSession();
1088
1995
  await chatController.refreshSession();
1089
- ctx.showWidget("chat");
1996
+ ctx.showPane("chat");
1090
1997
  },
1091
1998
  });
1092
1999
 
@@ -1122,7 +2029,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
1122
2029
  await apiClient.sendVerification();
1123
2030
  chatController.clearSession();
1124
2031
  await chatController.refreshSession();
1125
- ctx.showWidget("chat");
2032
+ ctx.showPane("chat");
1126
2033
  },
1127
2034
  });
1128
2035