gloomberb 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,5 +1,6 @@
1
1
  import type { PluginPersistence, PluginResumeState } from "../../types/plugin";
2
2
  import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
3
+ import { toTimestampMillis } from "../../utils/timestamp";
3
4
 
4
5
  const SESSION_STATE_KEY = "session";
5
6
  const CHANNEL_STATE_KEY = "channel:everyone";
@@ -8,12 +9,15 @@ const TRANSCRIPT_SOURCE = "server";
8
9
  const MAX_CACHED_MESSAGES = 50;
9
10
  const SESSION_SCHEMA_VERSION = 1;
10
11
  const CHANNEL_SCHEMA_VERSION = 1;
11
- const TRANSCRIPT_SCHEMA_VERSION = 1;
12
+ const TRANSCRIPT_SCHEMA_VERSION = 2;
12
13
  const TRANSCRIPT_CACHE_POLICY = {
13
14
  staleMs: 30 * 24 * 60 * 60_000,
14
15
  expireMs: 90 * 24 * 60 * 60_000,
15
16
  };
16
17
  const VERIFICATION_POLL_MS = 5_000;
18
+ const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
19
+ const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
20
+ const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
17
21
 
18
22
  interface PersistedSessionState {
19
23
  sessionToken: string | null;
@@ -25,6 +29,7 @@ interface PersistedChannelState {
25
29
  draft: string;
26
30
  replyToId: string | null;
27
31
  lastCursor: string | null;
32
+ lastViewedMessageId?: string | null;
28
33
  }
29
34
 
30
35
  interface PersistedTranscript {
@@ -38,9 +43,45 @@ export interface ChatControllerSnapshot {
38
43
  messages: ChatMessage[];
39
44
  draft: string;
40
45
  replyToId: string | null;
46
+ unreadMentionCount: number;
41
47
  }
42
48
 
43
- type ChatConnection = { send: (content: string, replyToId?: string) => void; close: () => void };
49
+ type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
50
+ type ChatToastOptions = { duration?: number; type?: "info" | "success" | "error" };
51
+
52
+ function normalizeUsername(username: string | null | undefined): string | null {
53
+ const trimmed = username?.trim();
54
+ return trimmed ? trimmed.toLowerCase() : null;
55
+ }
56
+
57
+ function messageMentionsUsername(content: string, username: string): boolean {
58
+ USERNAME_MENTION.lastIndex = 0;
59
+ let match: RegExpExecArray | null;
60
+ while ((match = USERNAME_MENTION.exec(content)) !== null) {
61
+ if ((match[2] ?? "").toLowerCase() === username) {
62
+ return true;
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ function formatMentionToast(message: ChatMessage): string {
69
+ const author = message.user.username || "Someone";
70
+ const normalized = message.content.replace(/\s+/g, " ").trim();
71
+ if (!normalized) {
72
+ return `@${author} mentioned you in chat.`;
73
+ }
74
+ const snippet = normalized.length > 72 ? `${normalized.slice(0, 69)}...` : normalized;
75
+ return `@${author} mentioned you: ${snippet}`;
76
+ }
77
+
78
+ function isLegacyTimestampCursor(cursor: string | null): boolean {
79
+ return !!cursor && ISO_TIMESTAMP_CURSOR.test(cursor);
80
+ }
81
+
82
+ function compareMessages(a: ChatMessage, b: ChatMessage): number {
83
+ return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
84
+ }
44
85
 
45
86
  export class ChatController {
46
87
  private persistence: PluginPersistence | null = null;
@@ -48,14 +89,21 @@ export class ChatController {
48
89
  private appActive = true;
49
90
  private hydrated = false;
50
91
  private sessionChecked = false;
92
+ private messagesLoading = false;
93
+ private refreshMessagesPromise: Promise<void> | null = null;
51
94
  private user: { id: string; username: string; emailVerified: boolean } | null = null;
52
95
  private messages: ChatMessage[] = [];
96
+ private pendingMessages: ChatMessage[] = [];
53
97
  private draft = "";
54
98
  private replyToId: string | null = null;
55
99
  private lastCursor: string | null = null;
100
+ private lastViewedMessageId: string | null = null;
56
101
  private wsConnection: ChatConnection | null = null;
57
102
  private wsConnected = false;
58
103
  private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
104
+ private openViewCount = 0;
105
+ private pendingMessageSeq = 0;
106
+ private toastFn: (message: string, options?: ChatToastOptions) => void = () => {};
59
107
  private listeners = new Set<(snapshot: ChatControllerSnapshot) => void>();
60
108
 
61
109
  attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
@@ -64,6 +112,10 @@ export class ChatController {
64
112
  this.hydrate();
65
113
  }
66
114
 
115
+ setToastNotifier(notify: (message: string, options?: ChatToastOptions) => void): void {
116
+ this.toastFn = notify;
117
+ }
118
+
67
119
  hydrate(): void {
68
120
  if (this.hydrated || !this.persistence) return;
69
121
  this.hydrated = true;
@@ -92,7 +144,8 @@ export class ChatController {
92
144
  });
93
145
  this.draft = channel?.draft ?? "";
94
146
  this.replyToId = channel?.replyToId ?? null;
95
- this.lastCursor = channel?.lastCursor ?? null;
147
+ const persistedCursor = channel?.lastCursor ?? null;
148
+ const persistedViewedMessageId = channel?.lastViewedMessageId ?? null;
96
149
 
97
150
  const transcript = this.persistence.getResource<PersistedTranscript>(TRANSCRIPT_KIND, "everyone", {
98
151
  sourceKey: TRANSCRIPT_SOURCE,
@@ -100,6 +153,12 @@ export class ChatController {
100
153
  allowExpired: true,
101
154
  });
102
155
  this.messages = transcript?.value.messages ?? [];
156
+ this.lastCursor = this.messages.length > 0
157
+ ? persistedCursor ?? this.messages[this.messages.length - 1]?.id ?? null
158
+ : null;
159
+ this.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
160
+ ? persistedViewedMessageId ?? this.messages[this.messages.length - 1]?.id ?? null
161
+ : null;
103
162
  this.syncVerificationPolling();
104
163
  }
105
164
 
@@ -113,12 +172,13 @@ export class ChatController {
113
172
 
114
173
  getSnapshot(): ChatControllerSnapshot {
115
174
  return {
116
- loading: !this.sessionChecked,
175
+ loading: !this.sessionChecked || this.messagesLoading,
117
176
  hasSavedSession: !!apiClient.getSessionToken(),
118
177
  user: this.user,
119
- messages: [...this.messages],
178
+ messages: this.getVisibleMessages(),
120
179
  draft: this.draft,
121
180
  replyToId: this.replyToId,
181
+ unreadMentionCount: this.getUnreadMentionCount(),
122
182
  };
123
183
  }
124
184
 
@@ -126,18 +186,42 @@ export class ChatController {
126
186
  const token = apiClient.getSessionToken();
127
187
  if (!token) {
128
188
  this.stopVerificationPolling();
189
+ this.wsConnection?.close();
190
+ this.wsConnection = null;
191
+ this.wsConnected = false;
129
192
  this.user = null;
130
193
  this.sessionChecked = true;
194
+ this.pendingMessages = [];
195
+ this.lastViewedMessageId = null;
131
196
  this.persistSession();
132
197
  this.emit();
133
198
  return;
134
199
  }
135
200
 
136
201
  const session = await apiClient.getSession();
137
- const nextUser = session
138
- ? { id: session.id, username: session.username ?? session.name, emailVerified: !!session.emailVerified }
139
- : null;
202
+ if (!session) {
203
+ this.stopVerificationPolling();
204
+ this.wsConnection?.close();
205
+ this.wsConnection = null;
206
+ this.wsConnected = false;
207
+ apiClient.setSessionToken(null);
208
+ this.user = null;
209
+ this.sessionChecked = true;
210
+ this.pendingMessages = [];
211
+ this.lastViewedMessageId = null;
212
+ this.persistSession();
213
+ this.emit();
214
+ return;
215
+ }
216
+
217
+ const previousIdentity = `${this.user?.id ?? ""}:${normalizeUsername(this.user?.username) ?? ""}`;
218
+ const nextUser = { id: session.id, username: session.username ?? session.name, emailVerified: !!session.emailVerified };
140
219
  this.user = nextUser;
220
+ const nextIdentity = `${nextUser.id}:${normalizeUsername(nextUser.username) ?? ""}`;
221
+ if (previousIdentity && previousIdentity !== nextIdentity) {
222
+ this.lastViewedMessageId = this.messages[this.messages.length - 1]?.id ?? null;
223
+ this.persistChannelState();
224
+ }
141
225
  this.sessionChecked = true;
142
226
  this.persistSession();
143
227
  this.emit();
@@ -152,10 +236,26 @@ export class ChatController {
152
236
  this.wsConnection?.close();
153
237
  this.wsConnection = null;
154
238
  this.wsConnected = false;
155
- if (!nextUser) {
156
- this.stopVerificationPolling();
157
- this.reset(true);
158
- }
239
+ }
240
+
241
+ async refreshMessages(): Promise<void> {
242
+ if (this.refreshMessagesPromise) return this.refreshMessagesPromise;
243
+
244
+ this.messagesLoading = true;
245
+ this.emit();
246
+
247
+ const request = this.fetchMessages()
248
+ .catch(() => {
249
+ this.persistChannelState();
250
+ })
251
+ .finally(() => {
252
+ this.messagesLoading = false;
253
+ this.refreshMessagesPromise = null;
254
+ this.emit();
255
+ });
256
+
257
+ this.refreshMessagesPromise = request;
258
+ return request;
159
259
  }
160
260
 
161
261
  setDraft(draft: string): void {
@@ -170,6 +270,16 @@ export class ChatController {
170
270
  this.emit();
171
271
  }
172
272
 
273
+ attachView(): () => void {
274
+ this.openViewCount += 1;
275
+ if (this.markViewedThroughLatestMessage()) {
276
+ this.emit();
277
+ }
278
+ return () => {
279
+ this.openViewCount = Math.max(0, this.openViewCount - 1);
280
+ };
281
+ }
282
+
173
283
  clearSession(): void {
174
284
  this.stopVerificationPolling();
175
285
  this.wsConnection?.close();
@@ -177,6 +287,8 @@ export class ChatController {
177
287
  this.wsConnected = false;
178
288
  this.user = null;
179
289
  this.sessionChecked = false;
290
+ this.pendingMessages = [];
291
+ this.lastViewedMessageId = null;
180
292
  this.emit();
181
293
  }
182
294
 
@@ -196,9 +308,12 @@ export class ChatController {
196
308
  this.wsConnection = null;
197
309
  this.wsConnected = false;
198
310
  this.messages = [];
311
+ this.pendingMessages = [];
199
312
  this.draft = "";
200
313
  this.replyToId = null;
201
314
  this.lastCursor = null;
315
+ this.lastViewedMessageId = null;
316
+ this.openViewCount = 0;
202
317
  if (clearSession) {
203
318
  this.user = null;
204
319
  apiClient.setSessionToken(null);
@@ -212,32 +327,43 @@ export class ChatController {
212
327
  }
213
328
 
214
329
  send(content: string, replyToId?: string): void {
215
- this.wsConnection?.send(content, replyToId);
216
- this.setDraft("");
217
- this.setReplyToId(null);
330
+ if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
331
+ if (!this.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
332
+ this.ensureConnection();
333
+ }
334
+ const connection = this.wsConnection;
335
+ if (!connection) {
336
+ this.toastFn("Unable to send message right now.", { type: "error" });
337
+ return;
338
+ }
339
+
340
+ const pendingMessage = this.createPendingMessage(content, replyToId);
341
+ this.pendingMessages = [...this.pendingMessages, pendingMessage];
342
+ this.draft = "";
343
+ this.replyToId = null;
344
+ this.persistChannelState();
345
+ this.emit();
346
+
347
+ void connection.send(content, replyToId).then((message) => {
348
+ this.pendingMessages = this.pendingMessages.filter((entry) => entry.id !== pendingMessage.id);
349
+ this.mergeMessages([message]);
350
+ }).catch((error) => {
351
+ const errorMessage = error instanceof Error && error.message ? error.message : "Failed to send message.";
352
+ this.pendingMessages = this.pendingMessages.map((entry) => (
353
+ entry.id === pendingMessage.id
354
+ ? { ...entry, clientStatus: "failed", clientError: errorMessage }
355
+ : entry
356
+ ));
357
+ this.emit();
358
+ this.toastFn(errorMessage, { type: "error" });
359
+ });
218
360
  }
219
361
 
220
362
  ensureConnection(): void {
221
363
  if (this.wsConnected || !this.user?.emailVerified || !apiClient.getSessionToken()) return;
222
364
  this.wsConnected = true;
223
365
 
224
- apiClient.getMessages("everyone", {
225
- limit: MAX_CACHED_MESSAGES,
226
- after: this.lastCursor ?? undefined,
227
- }).then((messages) => {
228
- if (messages.length > 0) {
229
- this.mergeMessages(messages);
230
- return;
231
- }
232
- this.persistChannelState();
233
- }).catch(async () => {
234
- try {
235
- const messages = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
236
- this.mergeMessages(messages);
237
- } catch {
238
- // keep local transcript
239
- }
240
- });
366
+ void this.refreshMessages().catch(() => {});
241
367
 
242
368
  this.wsConnection = apiClient.connectChannel(
243
369
  "everyone",
@@ -279,14 +405,57 @@ export class ChatController {
279
405
  this.verificationPollTimer = null;
280
406
  }
281
407
 
408
+ private async fetchMessages(): Promise<void> {
409
+ const legacyTimestampCursor = isLegacyTimestampCursor(this.lastCursor);
410
+
411
+ try {
412
+ const messages = await apiClient.getMessages("everyone", {
413
+ limit: MAX_CACHED_MESSAGES,
414
+ after: this.lastCursor ?? undefined,
415
+ });
416
+ if (messages.length > 0) {
417
+ this.mergeMessages(messages);
418
+ return;
419
+ }
420
+ if (legacyTimestampCursor) {
421
+ const fullRefresh = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
422
+ if (fullRefresh.length > 0) {
423
+ this.mergeMessages(fullRefresh);
424
+ return;
425
+ }
426
+ this.lastCursor = null;
427
+ }
428
+ this.persistChannelState();
429
+ return;
430
+ } catch {
431
+ const messages = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
432
+ if (messages.length > 0) {
433
+ this.mergeMessages(messages);
434
+ return;
435
+ }
436
+ this.persistChannelState();
437
+ }
438
+ }
439
+
282
440
  private mergeMessages(messages: ChatMessage[]): void {
441
+ this.reconcilePendingMessages(messages);
283
442
  const merged = new Map<string, ChatMessage>();
443
+ const incoming = new Map<string, ChatMessage>();
284
444
  for (const message of this.messages) merged.set(message.id, message);
285
- for (const message of messages) merged.set(message.id, message);
445
+ for (const message of messages) {
446
+ if (!merged.has(message.id)) {
447
+ incoming.set(message.id, message);
448
+ }
449
+ merged.set(message.id, message);
450
+ }
286
451
  this.messages = [...merged.values()]
287
- .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
452
+ .sort(compareMessages)
288
453
  .slice(-MAX_CACHED_MESSAGES);
289
- this.lastCursor = this.messages[this.messages.length - 1]?.createdAt ?? this.lastCursor;
454
+ this.lastCursor = this.messages[this.messages.length - 1]?.id ?? this.lastCursor;
455
+ if (this.openViewCount > 0) {
456
+ this.markViewedThroughLatestMessage(false);
457
+ }
458
+ this.trackUnreadMentions([...incoming.values()]);
290
459
  this.persistTranscript();
291
460
  this.emit();
292
461
  }
@@ -310,6 +479,7 @@ export class ChatController {
310
479
  draft: this.draft,
311
480
  replyToId: this.replyToId,
312
481
  lastCursor: this.lastCursor,
482
+ lastViewedMessageId: this.lastViewedMessageId,
313
483
  } satisfies PersistedChannelState;
314
484
  this.resume?.setState(CHANNEL_STATE_KEY, value, {
315
485
  schemaVersion: CHANNEL_SCHEMA_VERSION,
@@ -335,6 +505,111 @@ export class ChatController {
335
505
  });
336
506
  this.persistChannelState();
337
507
  }
508
+
509
+ private trackUnreadMentions(messages: ChatMessage[]): void {
510
+ if (this.openViewCount > 0) {
511
+ return;
512
+ }
513
+
514
+ const freshMentions = this.getMentionMessages(messages);
515
+ if (freshMentions.length === 0) return;
516
+ if (!this.appActive) return;
517
+
518
+ if (freshMentions.length === 1) {
519
+ this.toastFn(formatMentionToast(freshMentions[0]!), { type: "info" });
520
+ return;
521
+ }
522
+ this.toastFn(`${freshMentions.length} new mentions in #everyone.`, { type: "info" });
523
+ }
524
+
525
+ private getUnreadMentionCount(): number {
526
+ return this.getUnreadMentionMessages().length;
527
+ }
528
+
529
+ private getUnreadMentionMessages(): ChatMessage[] {
530
+ if (!this.lastViewedMessageId) {
531
+ return this.getMentionMessages(this.messages);
532
+ }
533
+
534
+ const viewedIndex = this.messages.findIndex((message) => message.id === this.lastViewedMessageId);
535
+ const unseenMessages = viewedIndex >= 0
536
+ ? this.messages.slice(viewedIndex + 1)
537
+ : this.messages;
538
+ return this.getMentionMessages(unseenMessages);
539
+ }
540
+
541
+ private getMentionMessages(messages: ChatMessage[]): ChatMessage[] {
542
+ const normalizedUsername = normalizeUsername(this.user?.username);
543
+ if (!normalizedUsername || messages.length === 0) return [];
544
+
545
+ return messages.filter((message) => (
546
+ message.user.id !== this.user?.id && messageMentionsUsername(message.content, normalizedUsername)
547
+ ));
548
+ }
549
+
550
+ private markViewedThroughLatestMessage(persist = true): boolean {
551
+ const latestMessageId = this.messages[this.messages.length - 1]?.id ?? null;
552
+ if (this.lastViewedMessageId === latestMessageId) {
553
+ return false;
554
+ }
555
+ this.lastViewedMessageId = latestMessageId;
556
+ if (persist) {
557
+ this.persistChannelState();
558
+ }
559
+ return true;
560
+ }
561
+
562
+ private getVisibleMessages(): ChatMessage[] {
563
+ return [...this.messages, ...this.pendingMessages]
564
+ .sort(compareMessages)
565
+ .slice(-MAX_CACHED_MESSAGES);
566
+ }
567
+
568
+ private createPendingMessage(content: string, replyToId?: string): ChatMessage {
569
+ const replyToMessage = replyToId
570
+ ? this.getVisibleMessages().find((message) => message.id === replyToId) ?? null
571
+ : null;
572
+ const pendingId = `local:${Date.now()}:${this.pendingMessageSeq += 1}`;
573
+ return {
574
+ id: pendingId,
575
+ channelId: "everyone",
576
+ content,
577
+ replyToId: replyToId ?? null,
578
+ createdAt: new Date().toISOString(),
579
+ user: {
580
+ id: this.user?.id ?? "local",
581
+ username: this.user?.username ?? "you",
582
+ displayName: this.user?.username ?? "You",
583
+ },
584
+ replyTo: replyToMessage
585
+ ? {
586
+ content: replyToMessage.content,
587
+ user: { username: replyToMessage.user.username },
588
+ }
589
+ : null,
590
+ clientStatus: "sending",
591
+ clientError: null,
592
+ };
593
+ }
594
+
595
+ private reconcilePendingMessages(messages: ChatMessage[]): void {
596
+ if (this.pendingMessages.length === 0 || messages.length === 0) return;
597
+
598
+ const remaining = [...this.pendingMessages];
599
+ for (const incoming of messages) {
600
+ const incomingMs = toTimestampMillis(incoming.createdAt);
601
+ const pendingIndex = remaining.findIndex((pending) => (
602
+ pending.user.id === incoming.user.id
603
+ && pending.content === incoming.content
604
+ && pending.replyToId === incoming.replyToId
605
+ && Math.abs(toTimestampMillis(pending.createdAt) - incomingMs) <= PENDING_RECONCILE_WINDOW_MS
606
+ ));
607
+ if (pendingIndex >= 0) {
608
+ remaining.splice(pendingIndex, 1);
609
+ }
610
+ }
611
+ this.pendingMessages = remaining;
612
+ }
338
613
  }
339
614
 
340
615
  export const chatController = new ChatController();
@@ -13,6 +13,8 @@ import { ChatController } from "./chat-controller";
13
13
  const TRANSCRIPT_KIND = "channel-transcript";
14
14
  const TRANSCRIPT_KEY = "everyone";
15
15
  const TRANSCRIPT_SOURCE = "server";
16
+ const TRANSCRIPT_SCHEMA_VERSION = 2;
17
+ const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
16
18
 
17
19
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
18
20
 
@@ -115,17 +117,18 @@ function createController(options: {
115
117
  persistence.setState("channel:everyone", {
116
118
  draft: "",
117
119
  replyToId: null,
118
- lastCursor: messages[messages.length - 1]?.createdAt ?? null,
120
+ lastCursor: messages[messages.length - 1]?.id ?? null,
119
121
  }, { schemaVersion: 1 });
120
122
  if (messages.length > 0) {
121
123
  persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages }, {
122
124
  sourceKey: TRANSCRIPT_SOURCE,
123
- schemaVersion: 1,
125
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
124
126
  cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
125
127
  });
126
128
  }
127
129
  controller.attachPersistence(persistence);
128
130
  controller.refreshSession = async () => {};
131
+ controller.refreshMessages = async () => {};
129
132
  return controller;
130
133
  }
131
134
 
@@ -164,6 +167,7 @@ async function flushFrame() {
164
167
  afterEach(async () => {
165
168
  setSharedRegistryForTests(undefined);
166
169
  setSharedDataProviderForTests(undefined);
170
+ apiClient.connectChannel = originalConnectChannel;
167
171
  apiClient.setSessionToken(null);
168
172
 
169
173
  if (testSetup) {
@@ -211,6 +215,34 @@ describe("ChatContent", () => {
211
215
  expect(frameAfterType).not.toContain("> FCD");
212
216
  });
213
217
 
218
+ test("renders optimistic sends with a sending status", async () => {
219
+ const controller = createController({
220
+ messages: [{
221
+ id: "local:1",
222
+ channelId: "everyone",
223
+ content: "hello",
224
+ replyToId: null,
225
+ createdAt: "2026-03-28T00:00:00.000Z",
226
+ user: { id: "u0", username: "vince", displayName: "Vince" },
227
+ clientStatus: "sending",
228
+ clientError: null,
229
+ }],
230
+ });
231
+
232
+ await act(async () => {
233
+ testSetup = await testRender(createHarness(controller), {
234
+ width: 60,
235
+ height: 12,
236
+ });
237
+ });
238
+
239
+ await flushFrame();
240
+
241
+ const frameAfterSubmit = testSetup.captureCharFrame();
242
+ expect(frameAfterSubmit).toContain("hello");
243
+ expect(frameAfterSubmit).toContain("sending...");
244
+ });
245
+
214
246
  test("auto-scrolls to newly appended messages while following the latest transcript", async () => {
215
247
  const controller = createController({
216
248
  messages: [
@@ -323,7 +355,7 @@ describe("ChatContent", () => {
323
355
  expect(opened).toEqual(["TSLA"]);
324
356
  });
325
357
 
326
- test("shows a saved-login message instead of logged out when a session token is cached", async () => {
358
+ test("shows a saved-login read-only footer when a session token is cached", async () => {
327
359
  const controller = createController({
328
360
  sessionToken: "token-123",
329
361
  user: null,
@@ -340,8 +372,33 @@ describe("ChatContent", () => {
340
372
 
341
373
  const frame = testSetup.captureCharFrame();
342
374
  expect(frame).toContain("Saved login found.");
343
- expect(frame).toContain("Reconnect to refresh");
344
- expect(frame).not.toContain("Not logged in.");
375
+ expect(frame).toContain("Log in again to send.");
376
+ expect(frame).toContain("No messages yet.");
377
+ expect(frame).not.toContain("Type a message...");
378
+ });
379
+
380
+ test("keeps the transcript visible for logged-out users and blocks the composer", async () => {
381
+ const controller = createController({
382
+ sessionToken: null,
383
+ user: null,
384
+ messages: [makeMessage(1)],
385
+ });
386
+
387
+ await act(async () => {
388
+ testSetup = await testRender(createHarness(controller), {
389
+ width: 60,
390
+ height: 12,
391
+ });
392
+ });
393
+
394
+ await flushFrame();
395
+
396
+ const frame = testSetup.captureCharFrame();
397
+ expect(frame).toContain("message 1");
398
+ expect(frame).toContain("Read-only chat.");
399
+ expect(frame).toContain("Login");
400
+ expect(frame).toContain("Sign Up");
401
+ expect(frame).not.toContain("Type a message...");
345
402
  });
346
403
 
347
404
  test("shows a logged-in icon in the cloud status widget for cached sessions", async () => {
@@ -365,7 +422,7 @@ describe("ChatContent", () => {
365
422
 
366
423
  const frame = testSetup.captureCharFrame();
367
424
  expect(frame).toContain("@");
368
- expect(frame).toContain("Shift+C");
425
+ expect(frame).not.toContain("Shift+C");
369
426
  expect(frame).not.toContain("vince");
370
427
  });
371
428
 
@@ -415,6 +472,61 @@ describe("ChatContent", () => {
415
472
  expect(openedQueries).toEqual(["Sign Up"]);
416
473
  });
417
474
 
475
+ test("shows an unread mention badge and opens chat from the status widget", async () => {
476
+ const controller = createController({
477
+ sessionToken: "token-123",
478
+ user: { id: "u1", username: "vince", emailVerified: true },
479
+ });
480
+ const openedWidgets: string[] = [];
481
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
482
+ state.config.disabledPlugins = [];
483
+
484
+ setSharedRegistryForTests({
485
+ showWidget(paneId: string) {
486
+ openedWidgets.push(paneId);
487
+ },
488
+ } as any);
489
+
490
+ await act(async () => {
491
+ (controller as any).mergeMessages([{
492
+ id: "m1",
493
+ channelId: "everyone",
494
+ content: "pinging @vince before the bell",
495
+ replyToId: null,
496
+ createdAt: "2026-03-28T00:00:00.000Z",
497
+ user: { id: "u2", username: "bob", displayName: "Bob" },
498
+ } satisfies ChatMessage]);
499
+ });
500
+
501
+ await act(async () => {
502
+ testSetup = await testRender(
503
+ <AppContext value={{ state, dispatch: () => {} }}>
504
+ <ChatStatusWidget controller={controller} />
505
+ </AppContext>,
506
+ { width: 40, height: 1 },
507
+ );
508
+ });
509
+
510
+ await flushFrame();
511
+
512
+ const frame = testSetup.captureCharFrame();
513
+ expect(frame).toContain("vince");
514
+ expect(frame).toContain("[1]");
515
+
516
+ const line = frame.split("\n")[0] ?? "";
517
+ const badgeCol = line.indexOf("[1]");
518
+
519
+ expect(badgeCol).toBeGreaterThanOrEqual(0);
520
+
521
+ await act(async () => {
522
+ await testSetup!.mockMouse.click(badgeCol + 1, 0);
523
+ await testSetup!.renderOnce();
524
+ await testSetup!.renderOnce();
525
+ });
526
+
527
+ expect(openedWidgets).toEqual(["chat"]);
528
+ });
529
+
418
530
  test("auth commands use a single-form layout and signup no longer prompts for display name", () => {
419
531
  const registeredCommands: Array<{ id: string; wizardLayout?: string; wizard?: Array<{ key: string }> }> = [];
420
532