gloomberb 0.7.0 → 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 (125) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +63 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +6 -0
  118. package/src/utils/api-client.test.ts +32 -0
  119. package/src/utils/api-client.ts +119 -18
  120. package/src/utils/double-escape-close.test.ts +33 -0
  121. package/src/utils/double-escape-close.ts +40 -0
  122. package/src/utils/throttled-fetch.test.ts +22 -0
  123. package/src/utils/throttled-fetch.ts +3 -1
  124. package/src/utils/ticker-search.ts +53 -9
  125. package/src/version.ts +1 -1
@@ -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,17 +348,552 @@ 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);
225
887
  const { nativePaneChrome } = useUiCapabilities();
226
- const contentWidth = Math.max(width - 2, 1);
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);
227
897
  const composerPrefixWidth = nativePaneChrome ? 0 : 3;
228
898
  const composerTextWidth = Math.max(contentWidth - composerPrefixWidth, 1);
229
899
  const inputValueRef = useRef(initialSnapshot.draft);
@@ -237,12 +907,19 @@ export function ChatContent({
237
907
  const [composerRows, setComposerRows] = useState(() => estimateComposerHeight(initialSnapshot.draft, composerTextWidth));
238
908
  const [selectedIdx, setSelectedIdx] = useState(-1);
239
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
+ }, []);
240
916
  const [followMessages, setFollowMessages] = useState(true);
241
917
  const [replyTo, setReplyTo] = useState<ChatMessage | null>(() => (
242
918
  initialSnapshot.replyToId ? initialSnapshot.messages.find((message) => message.id === initialSnapshot.replyToId) ?? null : null
243
919
  ));
244
920
  const inputRef = useRef<TextareaRenderable>(null);
245
921
  const scrollRef = useRef<ScrollBoxRenderable>(null);
922
+ const messageElementsRef = useRef(new Map<string, unknown>());
246
923
  const applyingExternalDraftRef = useRef(false);
247
924
  const prependAnchorRef = useRef<{
248
925
  oldestMessageId: string | null;
@@ -251,7 +928,7 @@ export function ChatContent({
251
928
  selectedMessageId: string | null;
252
929
  } | null>(null);
253
930
  const canSend = !!user?.emailVerified;
254
- const nativeComposerTopGap = nativePaneChrome && canSend ? NATIVE_CHAT_COMPOSER_TOP_GAP_PX : 0;
931
+ const useDefaultControllerChannel = channelId === DEFAULT_CHAT_CHANNEL_ID && !onChannelChange;
255
932
  const messageBodyWidth = Math.max(contentWidth - 4, 1);
256
933
  const composerHeight = canSend
257
934
  ? nativePaneChrome
@@ -266,16 +943,51 @@ export function ChatContent({
266
943
  }, [composerTextWidth]);
267
944
 
268
945
  useEffect(() => {
269
- return controller.attachView();
270
- }, [controller]);
946
+ return useDefaultControllerChannel ? controller.attachView() : controller.attachChannelView(channelId);
947
+ }, [channelId, controller, useDefaultControllerChannel]);
271
948
 
272
949
  useEffect(() => {
273
950
  updateComposerRows(inputValueRef.current);
274
951
  }, [updateComposerRows]);
275
952
 
276
953
  useEffect(() => {
277
- const unsubscribe = controller.subscribe((snapshot) => {
954
+ void controller.refreshChannels().catch(() => {});
955
+ void controller.refreshSession().catch(() => {});
956
+ }, [controller]);
957
+
958
+ useEffect(() => {
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"]>) {
278
988
  setMessages(snapshot.messages);
989
+ setChannels(snapshot.channels);
990
+ setChannelsLoading(snapshot.channelsLoading);
279
991
  setHasSavedSession(snapshot.hasSavedSession);
280
992
  setUser(snapshot.user);
281
993
  setLoading(snapshot.loading);
@@ -294,23 +1006,33 @@ export function ChatContent({
294
1006
  setReplyTo(snapshot.replyToId
295
1007
  ? snapshot.messages.find((message) => message.id === snapshot.replyToId) ?? null
296
1008
  : null);
297
- });
1009
+ }
298
1010
 
299
- void controller.refreshSession().catch(() => {});
300
- void controller.refreshMessages().catch(() => {});
1011
+ void (useDefaultControllerChannel ? controller.refreshMessages() : controller.refreshChannelMessages(channelId)).catch(() => {});
301
1012
  return unsubscribe;
302
- }, [controller, updateComposerRows]);
1013
+ }, [channelId, controller, updateComposerRows, useDefaultControllerChannel]);
303
1014
 
304
- 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);
305
1017
 
306
1018
  const replyToRef = useRef(replyTo);
307
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
+ }, []);
308
1029
 
309
1030
  const focusInput = useCallback(() => {
1031
+ setSidebarFocused(false);
310
1032
  setInputFocused(true);
311
1033
  dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
312
- inputRef.current?.focus();
313
- }, [dispatch]);
1034
+ inputRef.current?.focus?.();
1035
+ }, [dispatch, setSidebarFocused]);
314
1036
 
315
1037
  const focusComposer = useCallback(() => {
316
1038
  setSelectedIdx(-1);
@@ -325,8 +1047,12 @@ export function ChatContent({
325
1047
 
326
1048
  const clearReplyTarget = useCallback(() => {
327
1049
  setReplyTo(null);
328
- controller.setReplyToId(null);
329
- }, [controller]);
1050
+ if (useDefaultControllerChannel) {
1051
+ controller.setReplyToId(null);
1052
+ } else {
1053
+ controller.setChannelReplyToId(channelId, null);
1054
+ }
1055
+ }, [channelId, controller, useDefaultControllerChannel]);
330
1056
 
331
1057
  const beginReplyTo = useCallback((index: number, options?: { deferFocus?: boolean }) => {
332
1058
  if (!canSend || index < 0 || index >= messages.length) return;
@@ -335,13 +1061,17 @@ export function ChatContent({
335
1061
  setSelectedIdx(index);
336
1062
  setFollowMessages(index === messages.length - 1);
337
1063
  setReplyTo(nextReplyTo);
338
- controller.setReplyToId(nextReplyTo.id);
1064
+ if (useDefaultControllerChannel) {
1065
+ controller.setReplyToId(nextReplyTo.id);
1066
+ } else {
1067
+ controller.setChannelReplyToId(channelId, nextReplyTo.id);
1068
+ }
339
1069
  if (options?.deferFocus) {
340
1070
  queueMicrotask(() => focusInput());
341
1071
  } else {
342
1072
  focusInput();
343
1073
  }
344
- }, [canSend, controller, focusInput, messages]);
1074
+ }, [canSend, channelId, controller, focusInput, messages, useDefaultControllerChannel]);
345
1075
 
346
1076
  const returnToComposer = useCallback(() => {
347
1077
  setSelectedIdx(-1);
@@ -384,26 +1114,34 @@ export function ChatContent({
384
1114
  const sendMessage = useCallback(() => {
385
1115
  const content = inputValueRef.current.trim();
386
1116
  if (!content) return;
387
- 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
+ }
388
1122
  setSelectedIdx(-1);
389
1123
  setFollowMessages(true);
390
- }, [controller]);
1124
+ }, [channelId, controller, useDefaultControllerChannel]);
391
1125
 
392
1126
  const requestOlderMessages = useCallback(() => {
393
1127
  const scrollBox = scrollRef.current;
394
1128
  if (!scrollBox || loadingOlderMessages || !hasOlderMessages || messages.length === 0) return;
1129
+ const exactPixels = nativePaneChrome === true && hasPixelScrollMetrics(scrollBox);
395
1130
 
396
1131
  prependAnchorRef.current = {
397
1132
  oldestMessageId: messages[0]?.id ?? null,
398
- scrollHeight: scrollBox.scrollHeight,
399
- scrollTop: scrollBox.scrollTop,
1133
+ scrollHeight: getScrollHeight(scrollBox, exactPixels),
1134
+ scrollTop: getScrollTop(scrollBox, exactPixels),
400
1135
  selectedMessageId: selectedIdx >= 0 ? messages[selectedIdx]?.id ?? null : null,
401
1136
  };
402
1137
  setFollowMessages(false);
403
- void controller.loadOlderMessages().catch(() => {
1138
+ const request = useDefaultControllerChannel
1139
+ ? controller.loadOlderMessages()
1140
+ : controller.loadOlderChannelMessages(channelId);
1141
+ void request.catch(() => {
404
1142
  prependAnchorRef.current = null;
405
1143
  });
406
- }, [controller, hasOlderMessages, loadingOlderMessages, messages, selectedIdx]);
1144
+ }, [channelId, controller, hasOlderMessages, loadingOlderMessages, messages, nativePaneChrome, selectedIdx, useDefaultControllerChannel]);
407
1145
 
408
1146
  const requestOlderMessagesIfNeeded = useCallback(() => {
409
1147
  const scrollBox = scrollRef.current;
@@ -411,6 +1149,110 @@ export function ChatContent({
411
1149
  requestOlderMessages();
412
1150
  }, [requestOlderMessages]);
413
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
+
414
1256
  useEffect(() => {
415
1257
  if (!canSend && inputFocused) {
416
1258
  blurInput();
@@ -425,7 +1267,7 @@ export function ChatContent({
425
1267
 
426
1268
  useEffect(() => {
427
1269
  if (focused && inputFocused) {
428
- inputRef.current?.focus();
1270
+ inputRef.current?.focus?.();
429
1271
  }
430
1272
  }, [focused, inputFocused]);
431
1273
 
@@ -433,8 +1275,12 @@ export function ChatContent({
433
1275
  if (applyingExternalDraftRef.current) return;
434
1276
  inputValueRef.current = draft;
435
1277
  updateComposerRows(draft);
436
- controller.setDraft(draft);
437
- }, [controller, updateComposerRows]);
1278
+ if (useDefaultControllerChannel) {
1279
+ controller.setDraft(draft);
1280
+ } else {
1281
+ controller.setChannelDraft(channelId, draft);
1282
+ }
1283
+ }, [channelId, controller, updateComposerRows, useDefaultControllerChannel]);
438
1284
 
439
1285
  useEffect(() => {
440
1286
  const textarea = inputRef.current;
@@ -460,6 +1306,39 @@ export function ChatContent({
460
1306
  if (!focused) return;
461
1307
  const isEnterKey = event.name === "return" || event.name === "enter";
462
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
+
463
1342
  if (inputFocused) {
464
1343
  if (event.name === "escape") {
465
1344
  event.preventDefault?.();
@@ -485,6 +1364,17 @@ export function ChatContent({
485
1364
  return;
486
1365
  }
487
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
+
488
1378
  if (canSend && isEnterKey && selectedIdx >= 0 && selectedIdx < messages.length) {
489
1379
  event.preventDefault?.();
490
1380
  event.stopPropagation?.();
@@ -551,28 +1441,10 @@ export function ChatContent({
551
1441
  event.stopPropagation?.();
552
1442
  setSelectedIdx(messages.length - 1);
553
1443
  setFollowMessages(true);
554
- queueMicrotask(() => scrollToBottom(scrollRef.current));
1444
+ queueMicrotask(() => scrollToBottom(scrollRef.current, nativePaneChrome));
555
1445
  return;
556
1446
  }
557
- }, [
558
- beginReplyTo,
559
- blurInput,
560
- canSend,
561
- clearReplyTarget,
562
- focusComposer,
563
- focused,
564
- inputFocused,
565
- hasOlderMessages,
566
- loadingOlderMessages,
567
- messages.length,
568
- moveMessageSelection,
569
- replyTo,
570
- requestOlderMessages,
571
- requestOlderMessagesIfNeeded,
572
- returnToComposer,
573
- selectedIdx,
574
- shouldLeaveComposerForSelection,
575
- ]);
1447
+ });
576
1448
 
577
1449
  const inputMetaHeight = canSend && replyTo ? 1 : 0;
578
1450
  const composerBlockHeight = canSend
@@ -581,8 +1453,8 @@ export function ChatContent({
581
1453
  const inputAreaHeight = canSend ? composerBlockHeight + inputMetaHeight : 2;
582
1454
  const topSeparatorHeight = nativePaneChrome ? 0 : 1;
583
1455
  const footerSeparatorHeight = !nativePaneChrome && !canSend ? 1 : 0;
584
- const messageAreaHeight = Math.max(1, height - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
585
- const composerWidth = nativePaneChrome ? width : contentWidth;
1456
+ const messageAreaHeight = Math.max(1, height - compactChannelHeaderHeight - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
1457
+ const composerWidth = nativePaneChrome ? chatWidth : contentWidth;
586
1458
 
587
1459
  useEffect(() => {
588
1460
  if (selectedIdx < messages.length) return;
@@ -591,8 +1463,8 @@ export function ChatContent({
591
1463
 
592
1464
  useEffect(() => {
593
1465
  if (!stickyTranscript) return;
594
- queueMicrotask(() => scrollToBottom(scrollRef.current));
595
- }, [contentWidth, height, messages, messageAreaHeight, stickyTranscript]);
1466
+ queueMicrotask(() => scrollToBottom(scrollRef.current, nativePaneChrome));
1467
+ }, [contentWidth, height, messages, messageAreaHeight, nativePaneChrome, stickyTranscript]);
596
1468
 
597
1469
  useEffect(() => {
598
1470
  const anchor = prependAnchorRef.current;
@@ -602,14 +1474,19 @@ export function ChatContent({
602
1474
  const currentAnchor = prependAnchorRef.current;
603
1475
  const scrollBox = scrollRef.current;
604
1476
  if (!currentAnchor || !scrollBox) return;
1477
+ if (pendingJumpMessageIdRef.current) {
1478
+ prependAnchorRef.current = null;
1479
+ return;
1480
+ }
605
1481
 
606
1482
  const previousOldestIndex = currentAnchor.oldestMessageId
607
1483
  ? messages.findIndex((message) => message.id === currentAnchor.oldestMessageId)
608
1484
  : -1;
609
- const addedRows = previousOldestIndex > 0
1485
+ const exactPixels = nativePaneChrome === true && hasPixelScrollMetrics(scrollBox);
1486
+ const addedRows = !exactPixels && previousOldestIndex > 0
610
1487
  ? getMessageTopOffset(messages, previousOldestIndex, contentWidth)
611
- : Math.max(0, scrollBox.scrollHeight - currentAnchor.scrollHeight);
612
- scrollBox.scrollTo(currentAnchor.scrollTop + addedRows);
1488
+ : Math.max(0, getScrollHeight(scrollBox, exactPixels) - currentAnchor.scrollHeight);
1489
+ scrollToPosition(scrollBox, currentAnchor.scrollTop + addedRows, exactPixels);
613
1490
  if (currentAnchor.selectedMessageId) {
614
1491
  const selectedMessageIndex = messages.findIndex((message) => message.id === currentAnchor.selectedMessageId);
615
1492
  if (selectedMessageIndex >= 0) {
@@ -618,29 +1495,34 @@ export function ChatContent({
618
1495
  }
619
1496
  prependAnchorRef.current = null;
620
1497
  });
621
- }, [contentWidth, loadingOlderMessages, messages]);
1498
+ }, [contentWidth, loadingOlderMessages, messages, nativePaneChrome]);
622
1499
 
623
1500
  useEffect(() => {
624
1501
  if (!focused || !stickyTranscript) return;
625
- queueMicrotask(() => scrollToBottom(scrollRef.current));
626
- }, [focused, stickyTranscript]);
1502
+ queueMicrotask(() => scrollToBottom(scrollRef.current, nativePaneChrome));
1503
+ }, [focused, nativePaneChrome, stickyTranscript]);
627
1504
 
628
1505
  useEffect(() => {
629
1506
  const sb = scrollRef.current;
630
1507
  if (!sb) return;
631
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
+ }
632
1514
  const top = getMessageTopOffset(messages, selectedIdx, contentWidth);
633
1515
  const rowHeight = estimateMessageHeight(messages[selectedIdx]!, contentWidth, isGroupedWithPrevious(messages, selectedIdx));
634
1516
  const nextScrollTop = getSelectedMessageScrollTop({
635
1517
  scrollTop: sb.scrollTop,
636
- viewportHeight: sb.viewport.height,
1518
+ viewportHeight: sb.viewport?.height ?? 0,
637
1519
  top,
638
1520
  rowHeight,
639
1521
  });
640
1522
  if (nextScrollTop !== sb.scrollTop) {
641
1523
  sb.scrollTo(nextScrollTop);
642
1524
  }
643
- }, [contentWidth, messages, selectedIdx, selectionActive]);
1525
+ }, [contentWidth, messages, nativePaneChrome, selectedIdx, selectionActive]);
644
1526
 
645
1527
  const replyPreview = replyTo
646
1528
  ? formatInlinePreview(
@@ -649,17 +1531,54 @@ export function ChatContent({
649
1531
  )
650
1532
  : "";
651
1533
  const inputPlaceholder = replyTo ? `Reply to @${replyTo.user.username}...` : "Type a message...";
652
-
653
- if (loading && messages.length === 0) {
654
- return (
655
- <Box flexGrow={1} alignItems="center" justifyContent="center">
656
- <Text fg={colors.textDim}>Loading...</Text>
657
- </Box>
658
- );
659
- }
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;
660
1541
 
661
1542
  return (
662
- <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
+
663
1582
  {!nativePaneChrome && (
664
1583
  <Box height={1} width={contentWidth}>
665
1584
  <Text fg={colors.border}>{"-".repeat(contentWidth)}</Text>
@@ -668,155 +1587,64 @@ export function ChatContent({
668
1587
 
669
1588
  <ScrollBox
670
1589
  ref={scrollRef}
671
- height={messageAreaHeight}
1590
+ height={nativePaneChrome ? undefined : messageAreaHeight}
1591
+ flexGrow={nativePaneChrome ? 1 : undefined}
672
1592
  scrollY
673
1593
  focusable={false}
674
1594
  stickyScroll={stickyTranscript}
675
1595
  stickyStart="bottom"
676
1596
  onMouseScroll={() => queueMicrotask(requestOlderMessagesIfNeeded)}
677
- style={nativeComposerTopGap > 0 ? { paddingBottom: nativeComposerTopGap } : undefined}
1597
+ style={nativePaneChrome ? { minHeight: 0 } : undefined}
678
1598
  >
679
1599
  {loadingOlderMessages && (
680
1600
  <Box alignItems="center" justifyContent="center" height={1} width={contentWidth}>
681
1601
  <Text fg={colors.textDim}>Loading earlier messages...</Text>
682
1602
  </Box>
683
1603
  )}
684
- {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 && (
685
1609
  <Box alignItems="center" justifyContent="center" flexGrow={1}>
686
1610
  <Text fg={colors.textDim}>No messages yet. Be the first to say something!</Text>
687
1611
  </Box>
688
1612
  )}
689
- {messages.map((msg, index) => {
690
- const isSelected = index === selectedIdx;
691
- const isHovered = index === hoveredIdx && !isSelected;
692
- const showReplyAction = canSend && (isSelected || hoveredIdx === index);
693
- const bgColor = isSelected ? colors.selected : isHovered ? hoverBg() : undefined;
694
- const grouped = isGroupedWithPrevious(messages, index);
695
- const isSending = msg.clientStatus === "sending";
696
- const hasFailed = msg.clientStatus === "failed";
697
- const selectedTextColor = hasFailed ? colors.negative : colors.selectedText;
698
- const replyMetaColor = isSelected ? selectedTextColor : colors.textMuted;
699
- const replyAuthorColor = isSelected ? selectedTextColor : colors.textDim;
700
- const headerStatus = isSending ? "sending..." : hasFailed ? "failed" : formatTimeAgo(msg.createdAt);
701
- const headerStatusColor = isSelected
702
- ? selectedTextColor
703
- : isSending
704
- ? colors.textDim
705
- : hasFailed
706
- ? colors.negative
707
- : colors.textMuted;
708
- const authorColor = isSelected ? selectedTextColor : hasFailed ? colors.negative : colors.positive;
709
- const authorAttributes = (isSending ? TextAttributes.DIM : 0) | TextAttributes.BOLD;
710
- const bodyColor = isSelected ? selectedTextColor : hasFailed ? colors.negative : isSending ? colors.textDim : colors.text;
711
- const bodyLines = getMessageBodyLines(msg, contentWidth);
712
- const showInlineReplyAction = !grouped && (nativePaneChrome ? canSend : showReplyAction);
713
- const showGroupedReplyAction = grouped && (nativePaneChrome ? canSend : showReplyAction);
714
- const setHovered = () => setHoveredIdx((current) => (current === index ? current : index));
715
- const clearHovered = () => setHoveredIdx((current) => (current === index ? null : current));
716
- const messageRowProps = {
717
- width: contentWidth,
718
- backgroundColor: bgColor,
719
- "data-gloom-role": nativePaneChrome ? "chat-message-row" : undefined,
720
- "data-selected": nativePaneChrome ? (isSelected ? "true" : "false") : undefined,
721
- onMouseMove: nativePaneChrome ? undefined : setHovered,
722
- onMouseOut: nativePaneChrome ? undefined : clearHovered,
723
- };
724
- return (
725
- <Box
1613
+ {messages.map((msg, index) => (
1614
+ nativePaneChrome ? (
1615
+ <DesktopChatMessage
726
1616
  key={msg.id}
727
- width={contentWidth}
728
- flexDirection="column"
729
- data-gloom-role={nativePaneChrome ? "chat-message" : undefined}
730
- data-selected={nativePaneChrome ? (isSelected ? "true" : "false") : undefined}
731
- style={nativePaneChrome ? { "--chat-hover-bg": hoverBg() } : undefined}
732
- >
733
- {msg.replyTo && (
734
- <Box
735
- {...messageRowProps}
736
- flexDirection="row"
737
- height={1}
738
- paddingLeft={2}
739
- >
740
- <Text fg={replyMetaColor}>reply </Text>
741
- <Text fg={replyAuthorColor}>{msg.replyTo.user.username}: </Text>
742
- <Text fg={replyMetaColor}>
743
- {formatInlinePreview(
744
- msg.replyTo.content,
745
- Math.max(messageBodyWidth - `reply ${msg.replyTo.user.username}: `.length, 0),
746
- )}
747
- </Text>
748
- </Box>
749
- )}
750
- {!grouped && (
751
- <Box
752
- {...messageRowProps}
753
- flexDirection="row"
754
- height={1}
755
- paddingLeft={1}
756
- >
757
- <Text fg={authorColor} attributes={authorAttributes}>
758
- {msg.user.username ?? "anon"}
759
- </Text>
760
- <Text fg={headerStatusColor}> {headerStatus}</Text>
761
- {showInlineReplyAction && (
762
- <>
763
- <Text fg={headerStatusColor}> </Text>
764
- <Box
765
- width={MESSAGE_ACTION_WIDTH}
766
- height={1}
767
- data-gloom-role={nativePaneChrome ? "chat-message-reply-action" : undefined}
768
- >
769
- <ChatActionChip
770
- label="Reply"
771
- width={MESSAGE_ACTION_WIDTH}
772
- emphasized={isSelected}
773
- onPress={() => beginReplyTo(index)}
774
- />
775
- </Box>
776
- </>
777
- )}
778
- </Box>
779
- )}
780
- {bodyLines.map((line, lineIndex) => (
781
- <Box
782
- key={`${msg.id}:body:${lineIndex}`}
783
- {...messageRowProps}
784
- paddingLeft={3}
785
- height={1}
786
- flexDirection="row"
787
- position={grouped ? "relative" : undefined}
788
- >
789
- <Box width={messageBodyWidth} height={1}>
790
- <TickerBadgeText
791
- text={line}
792
- lineWidth={messageBodyWidth}
793
- catalog={catalog}
794
- textColor={bodyColor}
795
- openTicker={openTicker}
796
- />
797
- </Box>
798
- {lineIndex === 0 && showGroupedReplyAction && (
799
- <Box
800
- position="absolute"
801
- top={0}
802
- right={0}
803
- width={MESSAGE_ACTION_WIDTH}
804
- height={1}
805
- data-gloom-role={nativePaneChrome ? "chat-message-reply-action" : undefined}
806
- >
807
- <ChatActionChip
808
- label="Reply"
809
- width={MESSAGE_ACTION_WIDTH}
810
- emphasized={isSelected}
811
- onPress={() => beginReplyTo(index)}
812
- />
813
- </Box>
814
- )}
815
- </Box>
816
- ))}
817
- </Box>
818
- );
819
- })}
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
+ ))}
820
1648
  </ScrollBox>
821
1649
 
822
1650
  {!nativePaneChrome && !canSend && (
@@ -887,17 +1715,79 @@ export function ChatContent({
887
1715
  )}
888
1716
  </Box>
889
1717
  )}
1718
+ </Box>
890
1719
  </Box>
891
1720
  );
892
1721
  }
893
1722
 
894
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
+
895
1783
  return (
896
1784
  <ChatContent
897
1785
  width={width}
898
1786
  height={height}
899
1787
  focused={focused}
900
1788
  close={close}
1789
+ channelId={channelId}
1790
+ onChannelChange={setChannelId}
901
1791
  />
902
1792
  );
903
1793
  }
@@ -977,8 +1867,16 @@ export const gloomberbCloudPlugin: GloomPlugin = {
977
1867
  label: "New Chat Pane",
978
1868
  description: "Open another floating chat window",
979
1869
  keywords: ["new", "chat", "pane", "message"],
980
- shortcut: { prefix: "CHAT" },
981
- 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
+ },
982
1880
  },
983
1881
  ],
984
1882