gloomberb 0.3.7 → 0.4.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 (143) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +45 -69
  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 +363 -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 +4 -2
  37. package/src/components/layout/header.tsx +3 -3
  38. package/src/components/layout/pane-header.tsx +26 -2
  39. package/src/components/layout/pane-sizing.ts +6 -0
  40. package/src/components/layout/pane.tsx +5 -1
  41. package/src/components/layout/shell.test.tsx +77 -5
  42. package/src/components/layout/shell.tsx +67 -52
  43. package/src/components/layout/status-bar.test.tsx +66 -0
  44. package/src/components/layout/status-bar.tsx +26 -5
  45. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  46. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  47. package/src/components/ticker-list-table.test.tsx +83 -0
  48. package/src/components/ticker-list-table.tsx +181 -97
  49. package/src/components/ui/data-table.tsx +50 -5
  50. package/src/components/ui/index.ts +1 -0
  51. package/src/components/ui/page-stack-view.tsx +60 -0
  52. package/src/components/use-double-click-activation.ts +31 -0
  53. package/src/index.tsx +10 -10
  54. package/src/market-data/coordinator.test.ts +92 -4
  55. package/src/market-data/coordinator.ts +32 -5
  56. package/src/market-data/hooks.test.tsx +120 -0
  57. package/src/market-data/hooks.tsx +53 -16
  58. package/src/market-data/request-types.ts +4 -2
  59. package/src/market-data/selectors.ts +24 -58
  60. package/src/plugins/builtin/chat-controller.test.ts +400 -3
  61. package/src/plugins/builtin/chat-controller.ts +328 -35
  62. package/src/plugins/builtin/chat.test.tsx +118 -6
  63. package/src/plugins/builtin/chat.tsx +180 -116
  64. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  65. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  66. package/src/plugins/builtin/layout-manager.tsx +9 -0
  67. package/src/plugins/builtin/news.tsx +1 -11
  68. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  69. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  70. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  71. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  72. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  73. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  74. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  75. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  76. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  77. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  78. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  79. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  80. package/src/plugins/builtin/sec.tsx +1 -11
  81. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  82. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  83. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  84. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  85. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  86. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  87. package/src/plugins/catalog.ts +65 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  89. package/src/plugins/ibkr/gateway-service.test.ts +67 -1
  90. package/src/plugins/ibkr/gateway-service.ts +147 -7
  91. package/src/plugins/ibkr/index.tsx +7 -1
  92. package/src/plugins/pane-manager.test.ts +1 -1
  93. package/src/plugins/plugin-runtime.tsx +27 -7
  94. package/src/plugins/prediction-markets/cli.ts +28 -0
  95. package/src/plugins/prediction-markets/controller-data.ts +132 -17
  96. package/src/plugins/prediction-markets/controller.ts +142 -122
  97. package/src/plugins/prediction-markets/index.tsx +7 -1
  98. package/src/plugins/prediction-markets/launch.ts +51 -16
  99. package/src/plugins/prediction-markets/metrics.ts +9 -5
  100. package/src/plugins/prediction-markets/pane.test.tsx +197 -57
  101. package/src/plugins/prediction-markets/pane.tsx +80 -155
  102. package/src/plugins/prediction-markets/services/fetch.ts +4 -0
  103. package/src/plugins/prediction-markets/table.tsx +6 -3
  104. package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/plugins/registry.ts +23 -0
  107. package/src/sources/gloomberb-cloud.test.ts +29 -0
  108. package/src/sources/gloomberb-cloud.ts +89 -1
  109. package/src/sources/provider-router.test.ts +265 -3
  110. package/src/sources/provider-router.ts +239 -11
  111. package/src/sources/yahoo-finance.test.ts +26 -0
  112. package/src/sources/yahoo-finance.ts +54 -3
  113. package/src/state/app-context-selector.test.tsx +99 -0
  114. package/src/state/app-context.test.ts +18 -0
  115. package/src/state/app-context.tsx +170 -65
  116. package/src/state/selectors.ts +25 -0
  117. package/src/theme/colors.ts +8 -8
  118. package/src/theme/themes.test.ts +2 -0
  119. package/src/theme/themes.ts +22 -10
  120. package/src/types/broker.ts +21 -0
  121. package/src/types/config.ts +17 -1
  122. package/src/types/data-provider.ts +18 -0
  123. package/src/types/financials.ts +31 -1
  124. package/src/types/plugin.ts +70 -0
  125. package/src/updater.test.ts +2 -0
  126. package/src/utils/api-client.test.ts +57 -0
  127. package/src/utils/api-client.ts +47 -12
  128. package/src/utils/back-navigation.ts +27 -0
  129. package/src/utils/format.test.ts +14 -0
  130. package/src/utils/format.ts +3 -3
  131. package/src/utils/market-status.ts +46 -1
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -4,6 +4,7 @@ import type { InstrumentRef, NewsRequest, OptionsRequest, SecFilingsRequest, Cha
4
4
  import type { QueryEntry } from "./result-types";
5
5
  import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
6
6
  import { normalizePriceHistory } from "../utils/price-history";
7
+ import { resolveTickerFinancialsQuoteState } from "../utils/quote-resolution";
7
8
 
8
9
  export function buildInstrumentKey(instrument: InstrumentRef): string {
9
10
  const contractKey = instrument.instrument?.conId
@@ -51,8 +52,9 @@ export function buildChartKey(request: ChartRequest): string {
51
52
  return [
52
53
  "chart",
53
54
  buildInstrumentKey(request.instrument),
54
- request.range,
55
- request.granularity ?? "daily",
55
+ request.bufferRange,
56
+ request.granularity ?? "range",
57
+ request.resolution ?? "",
56
58
  request.startDate ? request.startDate.toISOString() : "",
57
59
  request.endDate ? request.endDate.toISOString() : "",
58
60
  request.barSize ?? "",
@@ -95,54 +97,6 @@ export function hasLikelyPriceUnitMismatch(
95
97
  return hasLikelyQuoteUnitMismatch(left, right);
96
98
  }
97
99
 
98
- function mergeDefinedQuoteFields(
99
- preferred: Quote | null | undefined,
100
- fallback: Quote | null | undefined,
101
- ): Quote | undefined {
102
- const mergedEntries: Array<[string, unknown]> = [];
103
- for (const source of [fallback, preferred]) {
104
- if (!source) continue;
105
- for (const [key, value] of Object.entries(source)) {
106
- if (value !== undefined) {
107
- mergedEntries.push([key, value]);
108
- }
109
- }
110
- }
111
- if (mergedEntries.length === 0) return undefined;
112
- return Object.fromEntries(mergedEntries) as Quote;
113
- }
114
-
115
- function projectExtendedHoursQuote(
116
- snapshotQuote: Quote | null | undefined,
117
- liveQuote: Quote | null | undefined,
118
- ): Quote | undefined {
119
- const merged = mergeDefinedQuoteFields(liveQuote, snapshotQuote);
120
- if (!merged) return undefined;
121
- if (!snapshotQuote || !liveQuote) return merged;
122
-
123
- const snapshotState = snapshotQuote.marketState;
124
- if (snapshotState !== "PRE" && snapshotState !== "POST") return merged;
125
- if (liveQuote.marketState === "PRE" || liveQuote.marketState === "POST") return merged;
126
-
127
- if (snapshotState === "PRE") {
128
- return {
129
- ...merged,
130
- marketState: "PRE",
131
- preMarketPrice: liveQuote.price,
132
- preMarketChange: liveQuote.change,
133
- preMarketChangePercent: liveQuote.changePercent,
134
- };
135
- }
136
-
137
- return {
138
- ...merged,
139
- marketState: "POST",
140
- postMarketPrice: liveQuote.price,
141
- postMarketChange: liveQuote.change,
142
- postMarketChangePercent: liveQuote.changePercent,
143
- };
144
- }
145
-
146
100
  export function buildTickerFinancialsSnapshot(
147
101
  snapshotEntry: QueryEntry<TickerFinancials>,
148
102
  quoteEntry?: QueryEntry<TickerFinancials["quote"]>,
@@ -152,15 +106,27 @@ export function buildTickerFinancialsSnapshot(
152
106
  const quote = resolveEntryData(quoteEntry);
153
107
  const priceHistory = resolveEntryData(chartEntry);
154
108
  if (!snapshot && !quote && !priceHistory) return null;
155
- const resolvedQuote = snapshot?.quote && quote && hasLikelyPriceUnitMismatch(snapshot.quote, quote)
156
- ? snapshot.quote
157
- : (projectExtendedHoursQuote(snapshot?.quote, quote) ?? snapshot?.quote);
109
+ if (snapshot?.quote && quote && hasLikelyPriceUnitMismatch(snapshot.quote, quote)) {
110
+ return {
111
+ ...(resolveTickerFinancialsQuoteState(snapshot) ?? {
112
+ annualStatements: snapshot?.annualStatements ?? [],
113
+ quarterlyStatements: snapshot?.quarterlyStatements ?? [],
114
+ priceHistory: snapshot?.priceHistory ?? [],
115
+ }),
116
+ priceHistory: normalizePriceHistory(priceHistory ?? snapshot?.priceHistory ?? []),
117
+ };
118
+ }
119
+
120
+ const resolved = resolveTickerFinancialsQuoteState(snapshot ?? {
121
+ annualStatements: [],
122
+ quarterlyStatements: [],
123
+ priceHistory: [],
124
+ }, quote);
125
+
126
+ if (!resolved) return null;
127
+
158
128
  return {
159
- quote: resolvedQuote,
160
- fundamentals: snapshot?.fundamentals,
161
- profile: snapshot?.profile,
162
- annualStatements: snapshot?.annualStatements ?? [],
163
- quarterlyStatements: snapshot?.quarterlyStatements ?? [],
129
+ ...resolved,
164
130
  priceHistory: normalizePriceHistory(priceHistory ?? snapshot?.priceHistory ?? []),
165
131
  };
166
132
  }
@@ -7,7 +7,15 @@ import { ChatController } from "./chat-controller";
7
7
  const TRANSCRIPT_KIND = "channel-transcript";
8
8
  const TRANSCRIPT_KEY = "everyone";
9
9
  const TRANSCRIPT_SOURCE = "server";
10
+ const TRANSCRIPT_SCHEMA_VERSION = 2;
11
+ const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
10
12
  const originalGetSession = apiClient.getSession.bind(apiClient);
13
+ const originalGetMessages = apiClient.getMessages.bind(apiClient);
14
+
15
+ async function flushMicrotasks() {
16
+ await Promise.resolve();
17
+ await Promise.resolve();
18
+ }
11
19
 
12
20
  class MemoryPersistence implements PluginPersistence {
13
21
  private readonly state = new Map<string, { schemaVersion: number; value: unknown }>();
@@ -76,8 +84,11 @@ class MemoryPersistence implements PluginPersistence {
76
84
  }
77
85
 
78
86
  afterEach(() => {
87
+ apiClient.dispose();
79
88
  apiClient.setSessionToken(null);
89
+ apiClient.connectChannel = originalConnectChannel;
80
90
  apiClient.getSession = originalGetSession;
91
+ apiClient.getMessages = originalGetMessages;
81
92
  });
82
93
 
83
94
  describe("ChatController", () => {
@@ -101,12 +112,13 @@ describe("ChatController", () => {
101
112
  draft: "cached draft",
102
113
  replyToId: "m1",
103
114
  lastCursor: "2026-03-28T00:00:00.000Z",
115
+ lastViewedMessageId: "m1",
104
116
  }, { schemaVersion: 1 });
105
117
  persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
106
118
  messages: [message],
107
119
  }, {
108
120
  sourceKey: TRANSCRIPT_SOURCE,
109
- schemaVersion: 1,
121
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
110
122
  cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
111
123
  });
112
124
 
@@ -155,12 +167,13 @@ describe("ChatController", () => {
155
167
  draft: "cached draft",
156
168
  replyToId: null,
157
169
  lastCursor: null,
170
+ lastViewedMessageId: null,
158
171
  }, { schemaVersion: 1 });
159
172
  persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
160
173
  messages: [],
161
174
  }, {
162
175
  sourceKey: TRANSCRIPT_SOURCE,
163
- schemaVersion: 1,
176
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
164
177
  cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
165
178
  });
166
179
 
@@ -169,7 +182,10 @@ describe("ChatController", () => {
169
182
 
170
183
  expect(apiClient.getSessionToken()).toBeNull();
171
184
  expect(persistence.getState("session", { schemaVersion: 1 })).toBeNull();
172
- expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: 1 })).toBeNull();
185
+ expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
186
+ sourceKey: TRANSCRIPT_SOURCE,
187
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
188
+ })).toBeNull();
173
189
  });
174
190
 
175
191
  test("pauses verification polling while the app is backgrounded", () => {
@@ -188,6 +204,35 @@ describe("ChatController", () => {
188
204
  controller.reset(true);
189
205
  });
190
206
 
207
+ test("dispose stops verification polling and closes the live connection", () => {
208
+ const controller = new ChatController();
209
+ let closed = false;
210
+
211
+ apiClient.setSessionToken("token-123");
212
+ (controller as any).user = { id: "u1", username: "vince", emailVerified: false };
213
+ (controller as any).syncVerificationPolling();
214
+ expect((controller as any).verificationPollTimer).not.toBeNull();
215
+
216
+ (controller as any).user = { id: "u1", username: "vince", emailVerified: true };
217
+ apiClient.connectChannel = () => ({
218
+ send: async () => {
219
+ throw new Error("not implemented");
220
+ },
221
+ close: () => {
222
+ closed = true;
223
+ },
224
+ });
225
+
226
+ controller.ensureConnection();
227
+ expect((controller as any).wsConnection).not.toBeNull();
228
+
229
+ controller.dispose();
230
+
231
+ expect(closed).toBe(true);
232
+ expect((controller as any).verificationPollTimer).toBeNull();
233
+ expect((controller as any).wsConnection).toBeNull();
234
+ });
235
+
191
236
  test("keeps the cached session when session refresh fails transiently", async () => {
192
237
  const persistence = new MemoryPersistence();
193
238
  const controller = new ChatController();
@@ -214,4 +259,356 @@ describe("ChatController", () => {
214
259
  emailVerified: true,
215
260
  });
216
261
  });
262
+
263
+ test("refreshes the public transcript without requiring a session", async () => {
264
+ const persistence = new MemoryPersistence();
265
+ const controller = new ChatController();
266
+ const message: ChatMessage = {
267
+ id: "m1",
268
+ channelId: "everyone",
269
+ content: "hello from the lobby",
270
+ replyToId: null,
271
+ createdAt: "2026-03-28T00:00:00.000Z",
272
+ user: { id: "u1", username: "vince", displayName: "Vince" },
273
+ };
274
+
275
+ controller.attachPersistence(persistence);
276
+ apiClient.getMessages = async () => [message];
277
+
278
+ await controller.refreshMessages();
279
+
280
+ expect(controller.getSnapshot().messages).toEqual([message]);
281
+ expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
282
+ sourceKey: TRANSCRIPT_SOURCE,
283
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
284
+ })?.value).toEqual({
285
+ messages: [message],
286
+ });
287
+ });
288
+
289
+ test("stores the latest message id as the incremental cursor", async () => {
290
+ const persistence = new MemoryPersistence();
291
+ const controller = new ChatController();
292
+ const initial: ChatMessage = {
293
+ id: "m1",
294
+ channelId: "everyone",
295
+ content: "hello",
296
+ replyToId: null,
297
+ createdAt: "2026-03-28T00:00:00.000Z",
298
+ user: { id: "u1", username: "vince", displayName: "Vince" },
299
+ };
300
+ const next: ChatMessage = {
301
+ id: "m2",
302
+ channelId: "everyone",
303
+ content: "new message",
304
+ replyToId: null,
305
+ createdAt: "2026-03-28T00:01:00.000Z",
306
+ user: { id: "u2", username: "bob", displayName: "Bob" },
307
+ };
308
+
309
+ persistence.setState("channel:everyone", {
310
+ draft: "",
311
+ replyToId: null,
312
+ lastCursor: "m1",
313
+ lastViewedMessageId: "m1",
314
+ }, { schemaVersion: 1 });
315
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
316
+ messages: [initial],
317
+ }, {
318
+ sourceKey: TRANSCRIPT_SOURCE,
319
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
320
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
321
+ });
322
+
323
+ controller.attachPersistence(persistence);
324
+
325
+ const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
326
+ apiClient.getMessages = async (channelId, opts) => {
327
+ calls.push({ channelId, opts });
328
+ return opts?.after === "m1" ? [next] : [];
329
+ };
330
+
331
+ await controller.refreshMessages();
332
+
333
+ expect(calls).toEqual([{
334
+ channelId: "everyone",
335
+ opts: { limit: 50, after: "m1" },
336
+ }]);
337
+ expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]);
338
+ expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
339
+ lastCursor: "m2",
340
+ });
341
+ });
342
+
343
+ test("shows a pending message immediately and replaces it when the send succeeds", async () => {
344
+ const persistence = new MemoryPersistence();
345
+ const controller = new ChatController();
346
+ const replyTarget: ChatMessage = {
347
+ id: "m1",
348
+ channelId: "everyone",
349
+ content: "first",
350
+ replyToId: null,
351
+ createdAt: "2026-03-28T00:00:00.000Z",
352
+ user: { id: "u2", username: "bob", displayName: "Bob" },
353
+ };
354
+ const sentMessage: ChatMessage = {
355
+ id: "m2",
356
+ channelId: "everyone",
357
+ content: "hello",
358
+ replyToId: "m1",
359
+ createdAt: "2026-03-28T00:01:00.000Z",
360
+ user: { id: "u1", username: "vince", displayName: "Vince" },
361
+ replyTo: { content: "first", user: { username: "bob" } },
362
+ };
363
+
364
+ persistence.setState("session", {
365
+ sessionToken: "token-123",
366
+ user: { id: "u1", username: "vince", emailVerified: true },
367
+ }, { schemaVersion: 1 });
368
+ persistence.setState("channel:everyone", {
369
+ draft: "hello",
370
+ replyToId: "m1",
371
+ lastCursor: "m1",
372
+ lastViewedMessageId: "m1",
373
+ }, { schemaVersion: 1 });
374
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
375
+ messages: [replyTarget],
376
+ }, {
377
+ sourceKey: TRANSCRIPT_SOURCE,
378
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
379
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
380
+ });
381
+
382
+ controller.attachPersistence(persistence);
383
+
384
+ let resolveSend: ((message: ChatMessage) => void) | null = null;
385
+ apiClient.connectChannel = () => ({
386
+ send: () => new Promise<ChatMessage>((resolve) => {
387
+ resolveSend = resolve;
388
+ }),
389
+ close: () => {},
390
+ });
391
+
392
+ controller.send("hello", "m1");
393
+
394
+ let snapshot = controller.getSnapshot();
395
+ expect(snapshot.draft).toBe("");
396
+ expect(snapshot.replyToId).toBeNull();
397
+ expect(snapshot.messages.map((message) => message.id)).toEqual(["m1", snapshot.messages[1]!.id]);
398
+ expect(snapshot.messages[1]).toMatchObject({
399
+ content: "hello",
400
+ replyToId: "m1",
401
+ clientStatus: "sending",
402
+ replyTo: { content: "first", user: { username: "bob" } },
403
+ });
404
+
405
+ resolveSend?.(sentMessage);
406
+ await flushMicrotasks();
407
+
408
+ snapshot = controller.getSnapshot();
409
+ expect(snapshot.messages).toEqual([replyTarget, sentMessage]);
410
+ });
411
+
412
+ test("marks a pending message as failed when sending errors", async () => {
413
+ const persistence = new MemoryPersistence();
414
+ const controller = new ChatController();
415
+ const toasts: string[] = [];
416
+
417
+ persistence.setState("session", {
418
+ sessionToken: "token-123",
419
+ user: { id: "u1", username: "vince", emailVerified: true },
420
+ }, { schemaVersion: 1 });
421
+
422
+ controller.setToastNotifier((message) => {
423
+ toasts.push(message);
424
+ });
425
+ controller.attachPersistence(persistence);
426
+
427
+ apiClient.connectChannel = () => ({
428
+ send: async () => {
429
+ throw new Error("server offline");
430
+ },
431
+ close: () => {},
432
+ });
433
+
434
+ controller.send("hello");
435
+ await flushMicrotasks();
436
+
437
+ const snapshot = controller.getSnapshot();
438
+ expect(snapshot.messages).toHaveLength(1);
439
+ expect(snapshot.messages[0]).toMatchObject({
440
+ content: "hello",
441
+ clientStatus: "failed",
442
+ clientError: "server offline",
443
+ });
444
+ expect(toasts).toEqual(["server offline"]);
445
+ });
446
+
447
+ test("tracks unread mentions and shows a toast while chat is closed", () => {
448
+ const persistence = new MemoryPersistence();
449
+ const controller = new ChatController();
450
+ const toasts: string[] = [];
451
+ const message: ChatMessage = {
452
+ id: "m1",
453
+ channelId: "everyone",
454
+ content: "hey @Vince can you take a look?",
455
+ replyToId: null,
456
+ createdAt: "2026-03-28T00:00:00.000Z",
457
+ user: { id: "u2", username: "bob", displayName: "Bob" },
458
+ };
459
+
460
+ persistence.setState("session", {
461
+ sessionToken: "token-123",
462
+ user: { id: "u1", username: "vince", emailVerified: true },
463
+ }, { schemaVersion: 1 });
464
+
465
+ controller.setToastNotifier((toast) => {
466
+ toasts.push(toast);
467
+ });
468
+ controller.attachPersistence(persistence);
469
+
470
+ (controller as any).mergeMessages([message]);
471
+
472
+ expect(controller.getSnapshot().unreadMentionCount).toBe(1);
473
+ expect(toasts).toEqual(["@bob mentioned you: hey @Vince can you take a look?"]);
474
+ expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
475
+ lastCursor: "m1",
476
+ lastViewedMessageId: null,
477
+ });
478
+ });
479
+
480
+ test("marks mentions viewed when a chat view opens", () => {
481
+ const persistence = new MemoryPersistence();
482
+ const controller = new ChatController();
483
+ const toasts: string[] = [];
484
+ const message: ChatMessage = {
485
+ id: "m1",
486
+ channelId: "everyone",
487
+ content: "hey @vince",
488
+ replyToId: null,
489
+ createdAt: "2026-03-28T00:00:00.000Z",
490
+ user: { id: "u2", username: "bob", displayName: "Bob" },
491
+ };
492
+
493
+ persistence.setState("session", {
494
+ sessionToken: "token-123",
495
+ user: { id: "u1", username: "vince", emailVerified: true },
496
+ }, { schemaVersion: 1 });
497
+
498
+ controller.setToastNotifier((toast) => {
499
+ toasts.push(toast);
500
+ });
501
+ controller.attachPersistence(persistence);
502
+
503
+ (controller as any).mergeMessages([message]);
504
+
505
+ expect(controller.getSnapshot().unreadMentionCount).toBe(1);
506
+
507
+ const detachView = controller.attachView();
508
+
509
+ expect(controller.getSnapshot().unreadMentionCount).toBe(0);
510
+ expect(toasts).toEqual(["@bob mentioned you: hey @vince"]);
511
+ expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
512
+ lastViewedMessageId: "m1",
513
+ });
514
+ detachView();
515
+ });
516
+
517
+ test("does not keep mentions unread while a chat view is already open", () => {
518
+ const persistence = new MemoryPersistence();
519
+ const controller = new ChatController();
520
+ const toasts: string[] = [];
521
+ const message: ChatMessage = {
522
+ id: "m1",
523
+ channelId: "everyone",
524
+ content: "hey @vince",
525
+ replyToId: null,
526
+ createdAt: "2026-03-28T00:00:00.000Z",
527
+ user: { id: "u2", username: "bob", displayName: "Bob" },
528
+ };
529
+
530
+ persistence.setState("session", {
531
+ sessionToken: "token-123",
532
+ user: { id: "u1", username: "vince", emailVerified: true },
533
+ }, { schemaVersion: 1 });
534
+
535
+ controller.setToastNotifier((toast) => {
536
+ toasts.push(toast);
537
+ });
538
+ controller.attachPersistence(persistence);
539
+ const detachView = controller.attachView();
540
+
541
+ (controller as any).mergeMessages([message]);
542
+
543
+ expect(controller.getSnapshot().unreadMentionCount).toBe(0);
544
+ expect(toasts).toEqual([]);
545
+ expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
546
+ lastViewedMessageId: "m1",
547
+ });
548
+
549
+ detachView();
550
+ });
551
+
552
+ test("recovers from a legacy timestamp cursor by falling back to a full transcript fetch", async () => {
553
+ const persistence = new MemoryPersistence();
554
+ const controller = new ChatController();
555
+ const cached: ChatMessage = {
556
+ id: "m1",
557
+ channelId: "everyone",
558
+ content: "cached",
559
+ replyToId: null,
560
+ createdAt: "2026-03-28T00:00:00.000Z",
561
+ user: { id: "u1", username: "vince", displayName: "Vince" },
562
+ };
563
+ const fullTranscript: ChatMessage[] = [
564
+ cached,
565
+ {
566
+ id: "m2",
567
+ channelId: "everyone",
568
+ content: "fresh",
569
+ replyToId: null,
570
+ createdAt: "2026-03-28T00:01:00.000Z",
571
+ user: { id: "u2", username: "bob", displayName: "Bob" },
572
+ },
573
+ ];
574
+
575
+ persistence.setState("channel:everyone", {
576
+ draft: "",
577
+ replyToId: null,
578
+ lastCursor: "2026-03-28T00:00:00.000Z",
579
+ lastViewedMessageId: "m1",
580
+ }, { schemaVersion: 1 });
581
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
582
+ messages: [cached],
583
+ }, {
584
+ sourceKey: TRANSCRIPT_SOURCE,
585
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
586
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
587
+ });
588
+
589
+ controller.attachPersistence(persistence);
590
+
591
+ const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
592
+ apiClient.getMessages = async (channelId, opts) => {
593
+ calls.push({ channelId, opts });
594
+ return opts?.after ? [] : fullTranscript;
595
+ };
596
+
597
+ await controller.refreshMessages();
598
+
599
+ expect(calls).toEqual([
600
+ {
601
+ channelId: "everyone",
602
+ opts: { limit: 50, after: "2026-03-28T00:00:00.000Z" },
603
+ },
604
+ {
605
+ channelId: "everyone",
606
+ opts: { limit: 50 },
607
+ },
608
+ ]);
609
+ expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]);
610
+ expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
611
+ lastCursor: "m2",
612
+ });
613
+ });
217
614
  });