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
@@ -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 }>();
@@ -77,7 +85,9 @@ class MemoryPersistence implements PluginPersistence {
77
85
 
78
86
  afterEach(() => {
79
87
  apiClient.setSessionToken(null);
88
+ apiClient.connectChannel = originalConnectChannel;
80
89
  apiClient.getSession = originalGetSession;
90
+ apiClient.getMessages = originalGetMessages;
81
91
  });
82
92
 
83
93
  describe("ChatController", () => {
@@ -101,12 +111,13 @@ describe("ChatController", () => {
101
111
  draft: "cached draft",
102
112
  replyToId: "m1",
103
113
  lastCursor: "2026-03-28T00:00:00.000Z",
114
+ lastViewedMessageId: "m1",
104
115
  }, { schemaVersion: 1 });
105
116
  persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
106
117
  messages: [message],
107
118
  }, {
108
119
  sourceKey: TRANSCRIPT_SOURCE,
109
- schemaVersion: 1,
120
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
110
121
  cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
111
122
  });
112
123
 
@@ -155,12 +166,13 @@ describe("ChatController", () => {
155
166
  draft: "cached draft",
156
167
  replyToId: null,
157
168
  lastCursor: null,
169
+ lastViewedMessageId: null,
158
170
  }, { schemaVersion: 1 });
159
171
  persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
160
172
  messages: [],
161
173
  }, {
162
174
  sourceKey: TRANSCRIPT_SOURCE,
163
- schemaVersion: 1,
175
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
164
176
  cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
165
177
  });
166
178
 
@@ -169,7 +181,10 @@ describe("ChatController", () => {
169
181
 
170
182
  expect(apiClient.getSessionToken()).toBeNull();
171
183
  expect(persistence.getState("session", { schemaVersion: 1 })).toBeNull();
172
- expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: 1 })).toBeNull();
184
+ expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
185
+ sourceKey: TRANSCRIPT_SOURCE,
186
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
187
+ })).toBeNull();
173
188
  });
174
189
 
175
190
  test("pauses verification polling while the app is backgrounded", () => {
@@ -214,4 +229,356 @@ describe("ChatController", () => {
214
229
  emailVerified: true,
215
230
  });
216
231
  });
232
+
233
+ test("refreshes the public transcript without requiring a session", async () => {
234
+ const persistence = new MemoryPersistence();
235
+ const controller = new ChatController();
236
+ const message: ChatMessage = {
237
+ id: "m1",
238
+ channelId: "everyone",
239
+ content: "hello from the lobby",
240
+ replyToId: null,
241
+ createdAt: "2026-03-28T00:00:00.000Z",
242
+ user: { id: "u1", username: "vince", displayName: "Vince" },
243
+ };
244
+
245
+ controller.attachPersistence(persistence);
246
+ apiClient.getMessages = async () => [message];
247
+
248
+ await controller.refreshMessages();
249
+
250
+ expect(controller.getSnapshot().messages).toEqual([message]);
251
+ expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
252
+ sourceKey: TRANSCRIPT_SOURCE,
253
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
254
+ })?.value).toEqual({
255
+ messages: [message],
256
+ });
257
+ });
258
+
259
+ test("stores the latest message id as the incremental cursor", async () => {
260
+ const persistence = new MemoryPersistence();
261
+ const controller = new ChatController();
262
+ const initial: ChatMessage = {
263
+ id: "m1",
264
+ channelId: "everyone",
265
+ content: "hello",
266
+ replyToId: null,
267
+ createdAt: "2026-03-28T00:00:00.000Z",
268
+ user: { id: "u1", username: "vince", displayName: "Vince" },
269
+ };
270
+ const next: ChatMessage = {
271
+ id: "m2",
272
+ channelId: "everyone",
273
+ content: "new message",
274
+ replyToId: null,
275
+ createdAt: "2026-03-28T00:01:00.000Z",
276
+ user: { id: "u2", username: "bob", displayName: "Bob" },
277
+ };
278
+
279
+ persistence.setState("channel:everyone", {
280
+ draft: "",
281
+ replyToId: null,
282
+ lastCursor: "m1",
283
+ lastViewedMessageId: "m1",
284
+ }, { schemaVersion: 1 });
285
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
286
+ messages: [initial],
287
+ }, {
288
+ sourceKey: TRANSCRIPT_SOURCE,
289
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
290
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
291
+ });
292
+
293
+ controller.attachPersistence(persistence);
294
+
295
+ const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
296
+ apiClient.getMessages = async (channelId, opts) => {
297
+ calls.push({ channelId, opts });
298
+ return opts?.after === "m1" ? [next] : [];
299
+ };
300
+
301
+ await controller.refreshMessages();
302
+
303
+ expect(calls).toEqual([{
304
+ channelId: "everyone",
305
+ opts: { limit: 50, after: "m1" },
306
+ }]);
307
+ expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]);
308
+ expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
309
+ lastCursor: "m2",
310
+ });
311
+ });
312
+
313
+ test("shows a pending message immediately and replaces it when the send succeeds", async () => {
314
+ const persistence = new MemoryPersistence();
315
+ const controller = new ChatController();
316
+ const replyTarget: ChatMessage = {
317
+ id: "m1",
318
+ channelId: "everyone",
319
+ content: "first",
320
+ replyToId: null,
321
+ createdAt: "2026-03-28T00:00:00.000Z",
322
+ user: { id: "u2", username: "bob", displayName: "Bob" },
323
+ };
324
+ const sentMessage: ChatMessage = {
325
+ id: "m2",
326
+ channelId: "everyone",
327
+ content: "hello",
328
+ replyToId: "m1",
329
+ createdAt: "2026-03-28T00:01:00.000Z",
330
+ user: { id: "u1", username: "vince", displayName: "Vince" },
331
+ replyTo: { content: "first", user: { username: "bob" } },
332
+ };
333
+
334
+ persistence.setState("session", {
335
+ sessionToken: "token-123",
336
+ user: { id: "u1", username: "vince", emailVerified: true },
337
+ }, { schemaVersion: 1 });
338
+ persistence.setState("channel:everyone", {
339
+ draft: "hello",
340
+ replyToId: "m1",
341
+ lastCursor: "m1",
342
+ lastViewedMessageId: "m1",
343
+ }, { schemaVersion: 1 });
344
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
345
+ messages: [replyTarget],
346
+ }, {
347
+ sourceKey: TRANSCRIPT_SOURCE,
348
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
349
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
350
+ });
351
+
352
+ controller.attachPersistence(persistence);
353
+
354
+ let resolveSend: ((message: ChatMessage) => void) | null = null;
355
+ apiClient.connectChannel = () => ({
356
+ send: () => new Promise<ChatMessage>((resolve) => {
357
+ resolveSend = resolve;
358
+ }),
359
+ close: () => {},
360
+ });
361
+
362
+ controller.send("hello", "m1");
363
+
364
+ let snapshot = controller.getSnapshot();
365
+ expect(snapshot.draft).toBe("");
366
+ expect(snapshot.replyToId).toBeNull();
367
+ expect(snapshot.messages.map((message) => message.id)).toEqual(["m1", snapshot.messages[1]!.id]);
368
+ expect(snapshot.messages[1]).toMatchObject({
369
+ content: "hello",
370
+ replyToId: "m1",
371
+ clientStatus: "sending",
372
+ replyTo: { content: "first", user: { username: "bob" } },
373
+ });
374
+
375
+ resolveSend?.(sentMessage);
376
+ await flushMicrotasks();
377
+
378
+ snapshot = controller.getSnapshot();
379
+ expect(snapshot.messages).toEqual([replyTarget, sentMessage]);
380
+ });
381
+
382
+ test("marks a pending message as failed when sending errors", async () => {
383
+ const persistence = new MemoryPersistence();
384
+ const controller = new ChatController();
385
+ const toasts: string[] = [];
386
+
387
+ persistence.setState("session", {
388
+ sessionToken: "token-123",
389
+ user: { id: "u1", username: "vince", emailVerified: true },
390
+ }, { schemaVersion: 1 });
391
+
392
+ controller.setToastNotifier((message) => {
393
+ toasts.push(message);
394
+ });
395
+ controller.attachPersistence(persistence);
396
+
397
+ apiClient.connectChannel = () => ({
398
+ send: async () => {
399
+ throw new Error("server offline");
400
+ },
401
+ close: () => {},
402
+ });
403
+
404
+ controller.send("hello");
405
+ await flushMicrotasks();
406
+
407
+ const snapshot = controller.getSnapshot();
408
+ expect(snapshot.messages).toHaveLength(1);
409
+ expect(snapshot.messages[0]).toMatchObject({
410
+ content: "hello",
411
+ clientStatus: "failed",
412
+ clientError: "server offline",
413
+ });
414
+ expect(toasts).toEqual(["server offline"]);
415
+ });
416
+
417
+ test("tracks unread mentions and shows a toast while chat is closed", () => {
418
+ const persistence = new MemoryPersistence();
419
+ const controller = new ChatController();
420
+ const toasts: string[] = [];
421
+ const message: ChatMessage = {
422
+ id: "m1",
423
+ channelId: "everyone",
424
+ content: "hey @Vince can you take a look?",
425
+ replyToId: null,
426
+ createdAt: "2026-03-28T00:00:00.000Z",
427
+ user: { id: "u2", username: "bob", displayName: "Bob" },
428
+ };
429
+
430
+ persistence.setState("session", {
431
+ sessionToken: "token-123",
432
+ user: { id: "u1", username: "vince", emailVerified: true },
433
+ }, { schemaVersion: 1 });
434
+
435
+ controller.setToastNotifier((toast) => {
436
+ toasts.push(toast);
437
+ });
438
+ controller.attachPersistence(persistence);
439
+
440
+ (controller as any).mergeMessages([message]);
441
+
442
+ expect(controller.getSnapshot().unreadMentionCount).toBe(1);
443
+ expect(toasts).toEqual(["@bob mentioned you: hey @Vince can you take a look?"]);
444
+ expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
445
+ lastCursor: "m1",
446
+ lastViewedMessageId: null,
447
+ });
448
+ });
449
+
450
+ test("marks mentions viewed when a chat view opens", () => {
451
+ const persistence = new MemoryPersistence();
452
+ const controller = new ChatController();
453
+ const toasts: string[] = [];
454
+ const message: ChatMessage = {
455
+ id: "m1",
456
+ channelId: "everyone",
457
+ content: "hey @vince",
458
+ replyToId: null,
459
+ createdAt: "2026-03-28T00:00:00.000Z",
460
+ user: { id: "u2", username: "bob", displayName: "Bob" },
461
+ };
462
+
463
+ persistence.setState("session", {
464
+ sessionToken: "token-123",
465
+ user: { id: "u1", username: "vince", emailVerified: true },
466
+ }, { schemaVersion: 1 });
467
+
468
+ controller.setToastNotifier((toast) => {
469
+ toasts.push(toast);
470
+ });
471
+ controller.attachPersistence(persistence);
472
+
473
+ (controller as any).mergeMessages([message]);
474
+
475
+ expect(controller.getSnapshot().unreadMentionCount).toBe(1);
476
+
477
+ const detachView = controller.attachView();
478
+
479
+ expect(controller.getSnapshot().unreadMentionCount).toBe(0);
480
+ expect(toasts).toEqual(["@bob mentioned you: hey @vince"]);
481
+ expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
482
+ lastViewedMessageId: "m1",
483
+ });
484
+ detachView();
485
+ });
486
+
487
+ test("does not keep mentions unread while a chat view is already open", () => {
488
+ const persistence = new MemoryPersistence();
489
+ const controller = new ChatController();
490
+ const toasts: string[] = [];
491
+ const message: ChatMessage = {
492
+ id: "m1",
493
+ channelId: "everyone",
494
+ content: "hey @vince",
495
+ replyToId: null,
496
+ createdAt: "2026-03-28T00:00:00.000Z",
497
+ user: { id: "u2", username: "bob", displayName: "Bob" },
498
+ };
499
+
500
+ persistence.setState("session", {
501
+ sessionToken: "token-123",
502
+ user: { id: "u1", username: "vince", emailVerified: true },
503
+ }, { schemaVersion: 1 });
504
+
505
+ controller.setToastNotifier((toast) => {
506
+ toasts.push(toast);
507
+ });
508
+ controller.attachPersistence(persistence);
509
+ const detachView = controller.attachView();
510
+
511
+ (controller as any).mergeMessages([message]);
512
+
513
+ expect(controller.getSnapshot().unreadMentionCount).toBe(0);
514
+ expect(toasts).toEqual([]);
515
+ expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
516
+ lastViewedMessageId: "m1",
517
+ });
518
+
519
+ detachView();
520
+ });
521
+
522
+ test("recovers from a legacy timestamp cursor by falling back to a full transcript fetch", async () => {
523
+ const persistence = new MemoryPersistence();
524
+ const controller = new ChatController();
525
+ const cached: ChatMessage = {
526
+ id: "m1",
527
+ channelId: "everyone",
528
+ content: "cached",
529
+ replyToId: null,
530
+ createdAt: "2026-03-28T00:00:00.000Z",
531
+ user: { id: "u1", username: "vince", displayName: "Vince" },
532
+ };
533
+ const fullTranscript: ChatMessage[] = [
534
+ cached,
535
+ {
536
+ id: "m2",
537
+ channelId: "everyone",
538
+ content: "fresh",
539
+ replyToId: null,
540
+ createdAt: "2026-03-28T00:01:00.000Z",
541
+ user: { id: "u2", username: "bob", displayName: "Bob" },
542
+ },
543
+ ];
544
+
545
+ persistence.setState("channel:everyone", {
546
+ draft: "",
547
+ replyToId: null,
548
+ lastCursor: "2026-03-28T00:00:00.000Z",
549
+ lastViewedMessageId: "m1",
550
+ }, { schemaVersion: 1 });
551
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
552
+ messages: [cached],
553
+ }, {
554
+ sourceKey: TRANSCRIPT_SOURCE,
555
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
556
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
557
+ });
558
+
559
+ controller.attachPersistence(persistence);
560
+
561
+ const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
562
+ apiClient.getMessages = async (channelId, opts) => {
563
+ calls.push({ channelId, opts });
564
+ return opts?.after ? [] : fullTranscript;
565
+ };
566
+
567
+ await controller.refreshMessages();
568
+
569
+ expect(calls).toEqual([
570
+ {
571
+ channelId: "everyone",
572
+ opts: { limit: 50, after: "2026-03-28T00:00:00.000Z" },
573
+ },
574
+ {
575
+ channelId: "everyone",
576
+ opts: { limit: 50 },
577
+ },
578
+ ]);
579
+ expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]);
580
+ expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
581
+ lastCursor: "m2",
582
+ });
583
+ });
217
584
  });