@frockbot/plugin-flock 0.3.10 → 0.3.12

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.
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import type { ClientPluginContext } from "@frockbot/client-core";
3
3
  import type { FrockBotWebData } from "@frockbot/plugin-shell/shared";
4
- import { ref, type Ref } from "vue";
4
+ import { nextTick, ref, type Ref } from "vue";
5
5
  import { randomSheepRecipeV1 } from "../shared.js";
6
6
  import { flockClientPlugin } from "./index.js";
7
7
  import { pendingCreateKey, pendingSheepKey } from "./pending-create.js";
@@ -281,7 +281,8 @@ describe("Flock client reconciliation", () => {
281
281
 
282
282
  await state.value.load();
283
283
 
284
- expect(state.value.error).toBe("Directory changed");
284
+ // The sidebar says what failed, not what the deployment called it.
285
+ expect(state.value.error).toBe("Couldn't load your Bots.");
285
286
  expect(storage.has(pendingCreateKey("user-a"))).toBe(false);
286
287
  });
287
288
 
@@ -481,6 +482,7 @@ describe("Flock client reconciliation", () => {
481
482
  return Promise.reject(new Error(`unexpected request: ${path}`));
482
483
  });
483
484
  const selected: string[] = [];
485
+ const forgotten: (string | undefined)[] = [];
484
486
  state.value.bindShell(
485
487
  ref({
486
488
  activeBotId: "alpha",
@@ -488,6 +490,11 @@ describe("Flock client reconciliation", () => {
488
490
  selected.push(botId);
489
491
  return Promise.resolve();
490
492
  },
493
+ transcripts: {
494
+ rememberViewport: () => undefined,
495
+ viewportFor: () => undefined,
496
+ forget: (botId?: string) => forgotten.push(botId),
497
+ },
491
498
  }) as unknown as Ref<FrockBotWebData>,
492
499
  );
493
500
  state.value.openArchive("alpha");
@@ -496,6 +503,9 @@ describe("Flock client reconciliation", () => {
496
503
  expect(state.value.lifecycles.alpha).toBe("archived");
497
504
  expect(selected.at(-1)).toBe("beta");
498
505
  expect(new URL(location.href).searchParams.get("bot")).toBe("beta");
506
+ // The archived Bot's held transcript goes with it, so restoring it later
507
+ // reads from the Bot rather than redrawing what the cache still had.
508
+ expect(forgotten).toEqual(["alpha"]);
499
509
  });
500
510
 
501
511
  test("reconciles a lost sheep response and clears the exact pending command", async () => {
@@ -532,3 +542,88 @@ describe("Flock client reconciliation", () => {
532
542
  expect(storage.has(pendingSheepKey("user-a", "alpha"))).toBe(false);
533
543
  });
534
544
  });
545
+
546
+ describe("Flock sidebar rows follow the transcript", () => {
547
+ test("a settled Turn updates the row without waiting for the poll", async () => {
548
+ installStorage();
549
+ const reads: string[] = [];
550
+ let reply: string | undefined;
551
+ const state = mount((path) => {
552
+ if (path !== "/api/bots/unread")
553
+ return Promise.reject(new Error(`unexpected request: ${path}`));
554
+ reads.push(path);
555
+ return Promise.resolve({
556
+ schemaVersion: 1,
557
+ unread: [
558
+ {
559
+ schemaVersion: 1,
560
+ botId: "alpha",
561
+ count: 0,
562
+ capped: false,
563
+ unread: false,
564
+ manuallyUnread: false,
565
+ ...(reply === undefined
566
+ ? {}
567
+ : {
568
+ lastMessage: {
569
+ schemaVersion: 1,
570
+ text: reply,
571
+ at: "2026-08-31T00:00:01.000Z",
572
+ role: "assistant",
573
+ },
574
+ }),
575
+ },
576
+ ],
577
+ });
578
+ });
579
+ const shell = ref({
580
+ activeBotId: "alpha",
581
+ activeRunId: "run-1",
582
+ messages: [
583
+ {
584
+ id: "m1",
585
+ runId: "run-1",
586
+ role: "user",
587
+ text: "hello",
588
+ status: "completed",
589
+ },
590
+ ],
591
+ } as unknown as FrockBotWebData);
592
+ state.value.bindShell(shell as unknown as Ref<FrockBotWebData>);
593
+ // Binding a Shell is not itself a beat: nothing has settled, so nothing is
594
+ // re-read.
595
+ await nextTick();
596
+ expect(reads).toHaveLength(0);
597
+ expect(state.value.unread.alpha).toBeUndefined();
598
+
599
+ // The Turn settles: its reply is in the transcript, no run is in flight.
600
+ reply = "Ollama reply";
601
+ shell.value = {
602
+ ...shell.value,
603
+ activeRunId: undefined,
604
+ messages: [
605
+ ...shell.value.messages,
606
+ {
607
+ id: "m2",
608
+ runId: "run-1",
609
+ role: "assistant",
610
+ text: reply,
611
+ status: "completed",
612
+ },
613
+ ],
614
+ } as unknown as FrockBotWebData;
615
+ await nextTick();
616
+ // Draining the read the watcher started. A rendered frame does this on its
617
+ // own; the claim under test is that no 15-second poll was involved.
618
+ await Promise.resolve();
619
+ await Promise.resolve();
620
+ await Promise.resolve();
621
+
622
+ expect(reads).toHaveLength(1);
623
+ expect(state.value.unread.alpha?.lastMessage).toMatchObject({
624
+ text: "Ollama reply",
625
+ at: "2026-08-31T00:00:01.000Z",
626
+ role: "assistant",
627
+ });
628
+ });
629
+ });
@@ -25,7 +25,18 @@ import {
25
25
  decodeBotUnreadDirectoryViewV1,
26
26
  decodeBotUnreadReceiptV1,
27
27
  } from "@frockbot/plugin-shell/unread";
28
+ import {
29
+ isBotFocusedV1,
30
+ readViewerFocusV1,
31
+ shouldNotifyForBotV1,
32
+ suppressUnreadWhileFocusedV1,
33
+ } from "@frockbot/plugin-shell/focus";
28
34
  import { showClientNotificationV1 } from "@frockbot/plugin-shell/client/notify";
35
+ import {
36
+ claimNotificationDeliveryV1,
37
+ deliveredNotificationKeyV1,
38
+ releaseNotificationDeliveryV1,
39
+ } from "./delivered-notifications.js";
29
40
  import {
30
41
  clearPendingCreate,
31
42
  clearPendingSheep,
@@ -39,6 +50,10 @@ import { flockWebDataKey, type FlockWebData } from "./state.js";
39
50
  import "../../assets/layers.css";
40
51
  import "./styles.css";
41
52
  import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
53
+ import {
54
+ clientFailureDetailV1,
55
+ presentClientFailureV1,
56
+ } from "@frockbot/client-core";
42
57
 
43
58
  function slug(name: string): string {
44
59
  const base =
@@ -91,13 +106,52 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
91
106
  throw new Error("Flock hosted transport is unavailable");
92
107
  const request = ctx.transport.hostedRequest.bind(ctx.transport);
93
108
  let shell: Ref<FrockBotWebData> | undefined;
94
- /** Stops the watcher that keeps a renamed Bot's sidebar row in step. */
109
+ /** Stops the watcher that keeps an edited Bot's sidebar row in step. */
95
110
  let stopNameWatch: (() => void) | undefined;
111
+ /** Stops the watcher that refreshes the row on the transcript's own beat. */
112
+ let stopTranscriptWatch: (() => void) | undefined;
113
+ /** Stops the visibility/focus listeners that re-decide what is being read. */
114
+ let stopFocusListeners: (() => void) | undefined;
96
115
  let authenticatedUserId: string | undefined;
97
116
  let loadGeneration = 0;
98
117
  let selectionGeneration = 0;
99
118
  /** Intents already shown by this page, so a poll cannot show one twice. */
100
119
  const deliveredNotifications = new Set<string>();
120
+ /** The refresh currently in flight, so overlapping beats collapse into one. */
121
+ let unreadRefresh: Promise<void> | undefined;
122
+ /** A beat that arrived while one was in flight, replayed once it finishes. */
123
+ let unreadRefreshQueued = false;
124
+
125
+ /**
126
+ * One unread read at a time, with at most one more behind it.
127
+ *
128
+ * A Turn produces several beats — its line appears, it streams, it settles —
129
+ * and each is a reason to re-read the row. Firing a request per beat would
130
+ * make the sidebar noisier than the transcript it is following, and
131
+ * out-of-order replies could put an older row back. Collapsing to one
132
+ * in-flight read plus one pending replay keeps the last beat authoritative.
133
+ * A failure is a refresh that did not happen: the poll tries again.
134
+ */
135
+ function refreshUnreadCoalesced(): Promise<void> {
136
+ if (unreadRefresh) {
137
+ unreadRefreshQueued = true;
138
+ return unreadRefresh;
139
+ }
140
+ unreadRefresh = (async () => {
141
+ try {
142
+ await state.value.refreshUnread();
143
+ } catch {
144
+ // A refresh is never authority; the poll retries and nothing is lost.
145
+ } finally {
146
+ unreadRefresh = undefined;
147
+ }
148
+ if (unreadRefreshQueued) {
149
+ unreadRefreshQueued = false;
150
+ await refreshUnreadCoalesced();
151
+ }
152
+ })();
153
+ return unreadRefresh;
154
+ }
101
155
 
102
156
  async function requireAuthenticatedUserId(): Promise<string> {
103
157
  if (!ctx.transport.readAuthenticatedUserId)
@@ -120,24 +174,89 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
120
174
  draftSheep: randomSheepRecipeV1(),
121
175
  bindShell(value) {
122
176
  shell = value;
123
- // A rename is saved on the Bot's own settings, and the sidebar reads a
124
- // directory it loaded once so the row kept the old name until the page
125
- // was reloaded. The row follows the settings the Shell is already
126
- // holding rather than waiting for a second read of the directory.
177
+ // A rename — or a pin — is saved on the Bot's own settings, and the
178
+ // sidebar reads a directory it loaded once, so the row kept the old name
179
+ // until the page was reloaded. The row follows the settings the Shell is
180
+ // already holding rather than waiting for a second read of the directory.
127
181
  stopNameWatch?.();
128
182
  stopNameWatch = watch(
129
- () => value.value.botSettings?.profile.name,
130
- (name) => {
131
- const botId = value.value.botSettings?.botId;
132
- if (!botId || !name) return;
183
+ () => {
184
+ const profile = value.value.botSettings?.profile;
185
+ return `${profile?.name ?? ""}\u0000${profile?.pinnedAt ?? ""}`;
186
+ },
187
+ () => {
188
+ const settings = value.value.botSettings;
189
+ const botId = settings?.botId;
190
+ if (!botId || !settings?.profile.name) return;
133
191
  const profile = state.value.profiles[botId];
134
- if (!profile || profile.name === name) return;
192
+ if (!profile) return;
193
+ const pinnedAt = settings.profile.pinnedAt;
194
+ if (
195
+ profile.name === settings.profile.name &&
196
+ profile.pinnedAt === pinnedAt
197
+ )
198
+ return;
199
+ // Unpinning removes the field rather than blanking it, so the
200
+ // identity the sidebar reads keeps the shape its decoder produces.
201
+ const { pinnedAt: _previous, ...rest } = profile;
135
202
  state.value.profiles = {
136
203
  ...state.value.profiles,
137
- [botId]: { ...profile, name },
204
+ [botId]: {
205
+ ...rest,
206
+ name: settings.profile.name,
207
+ ...(pinnedAt === undefined ? {} : { pinnedAt }),
208
+ },
138
209
  };
139
210
  },
140
211
  );
212
+ // The row and the transcript are two renderings of the same Turn, so
213
+ // they have to move together. The poll below is a floor — a Bot nobody
214
+ // is looking at still gets its badge within a tick — but the open Bot's
215
+ // own conversation already knows the instant a line lands or a Turn
216
+ // settles, and a row that reads "No messages yet" over a reply the User
217
+ // is looking at is the poll's latency made visible. The signal is
218
+ // per-line, never per-token: the newest line's identity and status, the
219
+ // number of lines, and whether a Turn is in flight.
220
+ stopTranscriptWatch?.();
221
+ stopTranscriptWatch = watch(
222
+ () => {
223
+ const web = value.value;
224
+ // A host that has not projected a transcript yet — the first paint,
225
+ // and every test double — contributes no beat rather than throwing.
226
+ const messages = web.messages ?? [];
227
+ const last = messages[messages.length - 1];
228
+ return [
229
+ web.activeBotId ?? "",
230
+ web.activeRunId ?? "",
231
+ messages.length,
232
+ last?.id ?? "",
233
+ last?.status ?? "",
234
+ ].join(":");
235
+ },
236
+ () => {
237
+ void refreshUnreadCoalesced();
238
+ },
239
+ );
240
+ // Coming back to the window is the other moment the answer changes.
241
+ // Nothing about the transcript moved — the Bot replied while the tab was
242
+ // hidden, and both the badge and the notification were right to appear —
243
+ // but the User is now looking at that very chat, so the badge has to go
244
+ // without waiting for a poll. The same beat covers looking away: the row
245
+ // the rule was suppressing becomes an honest badge again.
246
+ if (typeof document !== "undefined" && !stopFocusListeners) {
247
+ const refresh = (): void => {
248
+ if (!state.value.directory.bots.length) return;
249
+ void refreshUnreadCoalesced();
250
+ };
251
+ document.addEventListener("visibilitychange", refresh);
252
+ window.addEventListener("focus", refresh);
253
+ window.addEventListener("blur", refresh);
254
+ stopFocusListeners = () => {
255
+ document.removeEventListener("visibilitychange", refresh);
256
+ window.removeEventListener("focus", refresh);
257
+ window.removeEventListener("blur", refresh);
258
+ };
259
+ }
141
260
  },
142
261
  async load() {
143
262
  const generation = ++loadGeneration;
@@ -155,6 +274,7 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
155
274
  authenticatedUserId = userId;
156
275
  state.value.directory = directory;
157
276
  state.value.loaded = true;
277
+ if (shell) shell.value.botsUnavailable = false;
158
278
  state.value.lifecycles = Object.fromEntries(
159
279
  lifecycleDirectory.lifecycles.map((item) => [
160
280
  item.botId,
@@ -246,8 +366,14 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
246
366
  else if (!selected && state.value.directory.bots.length === 0)
247
367
  state.value.openCreate();
248
368
  } catch (error) {
249
- state.value.error =
250
- error instanceof Error ? error.message : "Could not load your flock";
369
+ // The list already on screen is the last thing known to be true, so a
370
+ // failed refresh leaves it alone and says so instead. A transport
371
+ // failure that emptied the sidebar would read as data loss.
372
+ state.value.error = presentClientFailureV1(error, "load your Bots");
373
+ console.debug("flock load failed", clientFailureDetailV1(error));
374
+ // Tell the workspace the list is unknown, so it stops offering the
375
+ // first-run empty state to a User who may already have Bots.
376
+ if (shell && !state.value.loaded) shell.value.botsUnavailable = true;
251
377
  } finally {
252
378
  if (generation === loadGeneration) state.value.loading = false;
253
379
  }
@@ -256,22 +382,31 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
256
382
  const directory = decodeBotUnreadDirectoryViewV1(
257
383
  await request("/api/bots/unread"),
258
384
  );
259
- state.value.unread = Object.fromEntries(
260
- directory.unread.map((view) => [view.botId, view]),
261
- );
262
385
  // A Turn that settles in the conversation the User is looking at has
263
386
  // been read by the time it arrives, so the badge that counted it is
264
- // wrong the instant it appears. Reading up to it is still the explicit
265
- // authenticated command; what the poll refreshed is only which cursor it
266
- // names. Every other Bot's badge is left exactly as the fan-out said.
267
- const openBotId = shell?.value.activeBotId;
387
+ // wrong the instant it appears and painting it for the beat before the
388
+ // receipt lands is exactly the flicker the rule forbids. The row for a
389
+ // focused Bot therefore renders no count at all, whatever the fan-out
390
+ // says. Every other Bot's badge is left exactly as it came.
391
+ const focus = readViewerFocusV1(shell?.value.activeBotId);
392
+ state.value.unread = Object.fromEntries(
393
+ directory.unread.map((view) => [
394
+ view.botId,
395
+ isBotFocusedV1(focus, view.botId)
396
+ ? suppressUnreadWhileFocusedV1(view)
397
+ : view,
398
+ ]),
399
+ );
400
+ // Suppression is what the row shows; the read receipt is what makes it
401
+ // stay shown — on the next reload, and in the other tab. It is still the
402
+ // explicit authenticated command, never a side effect of the read.
403
+ const openBotId = focus.activeBotId;
268
404
  if (
269
405
  openBotId &&
270
- (state.value.unread[openBotId]?.count ?? 0) > 0 &&
271
- !state.value.unread[openBotId]?.manuallyUnread &&
272
- typeof document !== "undefined" &&
273
- document.visibilityState === "visible" &&
274
- document.hasFocus()
406
+ isBotFocusedV1(focus, openBotId) &&
407
+ (directory.unread.find((view) => view.botId === openBotId)?.count ??
408
+ 0) > 0 &&
409
+ !state.value.unread[openBotId]?.manuallyUnread
275
410
  ) {
276
411
  try {
277
412
  await state.value.markRead(openBotId);
@@ -281,7 +416,27 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
281
416
  }
282
417
  },
283
418
  async markRead(botId) {
284
- const cursor = state.value.unread[botId]?.lastActivityCursor;
419
+ let cursor = state.value.unread[botId]?.lastActivityCursor;
420
+ // Only when this page has never seen the Bot's row at all. A row that is
421
+ // present without a cursor is a Bot nothing has ever settled on — a Bot
422
+ // just created, most often — and asking again would put a round trip in
423
+ // front of every first open to learn what it already knows.
424
+ if (!cursor && !state.value.unread[botId]) {
425
+ // Opening a chat has to clear its badge even when this page has not
426
+ // read the fan-out yet: the first click after a reload. One read names
427
+ // the cursor; without it the badge sat there until the next poll.
428
+ try {
429
+ const directory = decodeBotUnreadDirectoryViewV1(
430
+ await request("/api/bots/unread"),
431
+ );
432
+ for (const view of directory.unread) {
433
+ state.value.unread[view.botId] ??= view;
434
+ if (view.botId === botId) cursor = view.lastActivityCursor;
435
+ }
436
+ } catch {
437
+ // Fall through: with no cursor there is nothing to read up to.
438
+ }
439
+ }
285
440
  // Nothing has ever settled on this Bot: there is no cursor to read up to.
286
441
  if (!cursor) return;
287
442
  const receipt = decodeBotUnreadReceiptV1(
@@ -330,13 +485,17 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
330
485
  if (generation !== selectionGeneration) return;
331
486
  if (!shell) throw new Error("Shell selection is unavailable");
332
487
  await shell.value.selectBot(botId);
333
- // Selecting a thread while looking at it is what "read" means. A
334
- // background poll that refreshed the same runs must never do this.
335
- if (
336
- generation === selectionGeneration &&
337
- typeof document !== "undefined" &&
338
- document.visibilityState === "visible"
339
- ) {
488
+ // Selecting a thread while looking at it is what "read" means, and it
489
+ // is the whole of "opening a chat clears its badge". Focus is not
490
+ // required — opening is the User's own act, and a window that has just
491
+ // been clicked may not report focus yet — but a visible tab is: a page
492
+ // restoring `?bot=` in a background tab has not been read.
493
+ if (generation === selectionGeneration && readViewerFocusV1().visible) {
494
+ // The row clears now rather than on the next poll, so the badge never
495
+ // outlives the click. The receipt below is what makes it durable.
496
+ const view = state.value.unread[botId];
497
+ if (view)
498
+ state.value.unread[botId] = suppressUnreadWhileFocusedV1(view);
340
499
  try {
341
500
  await state.value.markRead(botId);
342
501
  } catch {
@@ -391,10 +550,13 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
391
550
  }
392
551
  state.value.overlay = undefined;
393
552
  state.value.lifecyclePending = undefined;
553
+ // An archived Bot's transcript is no longer something to open
554
+ // instantly: the next time it is read it is read from the Bot.
555
+ shell?.value.transcripts.forget(botId);
394
556
  await state.value.load();
395
557
  } catch (error) {
396
- state.value.error =
397
- error instanceof Error ? error.message : "Could not archive Bot";
558
+ state.value.error = presentClientFailureV1(error, "archive this Bot");
559
+ console.debug("bot archive failed", clientFailureDetailV1(error));
398
560
  }
399
561
  },
400
562
  async restore(botId) {
@@ -417,10 +579,11 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
417
579
  state.value.error = "Still restoring — this will finish shortly.";
418
580
  return;
419
581
  }
582
+ shell?.value.transcripts.forget(botId);
420
583
  await state.value.load();
421
584
  } catch (error) {
422
- state.value.error =
423
- error instanceof Error ? error.message : "Could not restore Bot";
585
+ state.value.error = presentClientFailureV1(error, "restore this Bot");
586
+ console.debug("bot restore failed", clientFailureDetailV1(error));
424
587
  }
425
588
  },
426
589
  async openEdit() {
@@ -460,10 +623,8 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
460
623
  );
461
624
  state.value.identities[botId] = identity;
462
625
  } catch (error) {
463
- state.value.error =
464
- error instanceof Error
465
- ? error.message
466
- : "Couldn't finish saving your last change.";
626
+ state.value.error = presentClientFailureV1(error, "load this sheep");
627
+ console.debug("sheep refresh failed", clientFailureDetailV1(error));
467
628
  return;
468
629
  }
469
630
  }
@@ -514,8 +675,8 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
514
675
  } catch (error) {
515
676
  if (isDefinitiveFlockFailure(error) && authenticatedUserId)
516
677
  clearPendingCreate(authenticatedUserId);
517
- state.value.error =
518
- error instanceof Error ? error.message : "Could not create Bot";
678
+ state.value.error = presentClientFailureV1(error, "create the Bot");
679
+ console.debug("bot creation failed", clientFailureDetailV1(error));
519
680
  }
520
681
  },
521
682
  async saveSheep() {
@@ -576,8 +737,8 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
576
737
  /* Keep the exact pending command when reconciliation is uncertain. */
577
738
  }
578
739
  }
579
- state.value.error =
580
- error instanceof Error ? error.message : "Could not save sheep";
740
+ state.value.error = presentClientFailureV1(error, "save the sheep");
741
+ console.debug("sheep save failed", clientFailureDetailV1(error));
581
742
  }
582
743
  },
583
744
  });
@@ -600,20 +761,41 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
600
761
  notificationId: intent.notificationId,
601
762
  }),
602
763
  );
764
+ const focus = readViewerFocusV1(shell?.value.activeBotId);
603
765
  for (const intent of directory.notifications) {
604
766
  // The open Bot's intents belong to the Shell: it projects the Turn into
605
- // the conversation and acknowledges it there.
606
- if (intent.botId === shell?.value.activeBotId) continue;
607
- const key = `${intent.botId}:${intent.notificationId}`;
608
- if (deliveredNotifications.has(key)) {
767
+ // the conversation, decides whether the tab was being looked at, and
768
+ // acknowledges it there. Deciding again here would be a second answer to
769
+ // the same question, and a second notification when they disagreed.
770
+ if (intent.botId === focus.activeBotId) continue;
771
+ // Every other Bot is out of focus by definition — the sentence this
772
+ // implements — so the intent is one notification, once.
773
+ if (!shouldNotifyForBotV1(focus, intent.botId)) continue;
774
+ const key = deliveredNotificationKeyV1(
775
+ intent.botId,
776
+ intent.notificationId,
777
+ );
778
+ if (deliveredNotifications.has(key)) continue;
779
+ // Claimed before it is shown, and claimed in storage every tab of this
780
+ // browser shares: the acknowledgement that finally closes the intent
781
+ // lands after the notification, and a second tab polling in that gap
782
+ // used to speak the same message twice.
783
+ if (!claimNotificationDeliveryV1(key)) {
784
+ deliveredNotifications.add(key);
609
785
  continue;
610
786
  }
787
+ deliveredNotifications.add(key);
611
788
  const delivery = await showClientNotificationV1({
612
789
  title: intent.title,
613
790
  body: intent.body,
614
791
  });
615
- if (delivery === "unavailable") continue;
616
- deliveredNotifications.add(key);
792
+ if (delivery === "unavailable") {
793
+ // Nothing was said, so nothing was spent: the intent stays pending and
794
+ // is spoken once the User grants permission.
795
+ deliveredNotifications.delete(key);
796
+ releaseNotificationDeliveryV1(key);
797
+ continue;
798
+ }
617
799
  await acknowledge(intent);
618
800
  }
619
801
  }
@@ -622,7 +804,7 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
622
804
  if (!state.value.directory.bots.length) return;
623
805
  void (async () => {
624
806
  try {
625
- await state.value.refreshUnread();
807
+ await refreshUnreadCoalesced();
626
808
  await deliverBackgroundNotifications();
627
809
  } catch {
628
810
  // A poll is a refresh, not authority: the next tick tries again and
@@ -634,6 +816,8 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
634
816
  return [
635
817
  () => clearInterval(poll),
636
818
  () => stopNameWatch?.(),
819
+ () => stopTranscriptWatch?.(),
820
+ () => stopFocusListeners?.(),
637
821
  ctx.provide(flockWebDataKey, state),
638
822
  ctx.slot({
639
823
  slot: "frockbot.sidebar-bots",
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { formatSidebarMessageTimeV1, groupSidebarBotsV1 } from "./sidebar.js";
2
+ import {
3
+ formatSidebarMessageTimeV1,
4
+ groupSidebarBotsV1,
5
+ partitionPinnedSidebarBotsV1,
6
+ } from "./sidebar.js";
3
7
 
4
8
  const bots = [
5
9
  { botId: "alpha" },
@@ -8,6 +12,48 @@ const bots = [
8
12
  { botId: "delta" },
9
13
  ];
10
14
 
15
+ describe("pinned sidebar Bots", () => {
16
+ test("orders pinned Bots by pin time and leaves the rest in list order", () => {
17
+ expect(
18
+ partitionPinnedSidebarBotsV1(bots, {
19
+ alpha: { pinnedAt: "2026-09-02T09:00:00.000Z" },
20
+ beta: {},
21
+ gamma: { pinnedAt: "2026-08-30T09:00:00.000Z" },
22
+ }),
23
+ ).toEqual({
24
+ pinned: [{ botId: "gamma" }, { botId: "alpha" }],
25
+ rest: [{ botId: "beta" }, { botId: "delta" }],
26
+ });
27
+ });
28
+
29
+ test("a pinned Bot leaves the labelled groups entirely", () => {
30
+ const profiles: Record<string, { label?: string; pinnedAt?: string }> = {
31
+ alpha: { label: "Work", pinnedAt: "2026-09-02T09:00:00.000Z" },
32
+ beta: { label: "Work" },
33
+ };
34
+ const { pinned, rest } = partitionPinnedSidebarBotsV1(
35
+ bots.slice(0, 2),
36
+ profiles,
37
+ );
38
+ expect(pinned.map((bot) => bot.botId)).toEqual(["alpha"]);
39
+ expect(
40
+ groupSidebarBotsV1(rest, profiles).groups.map((group) => ({
41
+ label: group.label,
42
+ bots: group.bots.map((bot) => bot.botId),
43
+ })),
44
+ ).toEqual([{ label: "Work", bots: ["beta"] }]);
45
+ });
46
+
47
+ test("treats a blank pin instant as unpinned", () => {
48
+ expect(
49
+ partitionPinnedSidebarBotsV1(bots.slice(0, 2), {
50
+ alpha: { pinnedAt: " " },
51
+ beta: {},
52
+ }).pinned,
53
+ ).toEqual([]);
54
+ });
55
+ });
56
+
11
57
  describe("sidebar Bot groups", () => {
12
58
  test("renders one plain list until a visible Bot has a label", () => {
13
59
  expect(groupSidebarBotsV1(bots.slice(0, 2), {})).toEqual({
@@ -1,3 +1,40 @@
1
+ export interface PinnedSidebarBotsV1<T> {
2
+ /** Pinned Bots, earliest pin first. Rendered as tiles above the list. */
3
+ pinned: T[];
4
+ /** Everything else, in the order it arrived. */
5
+ rest: T[];
6
+ }
7
+
8
+ /**
9
+ * Splits the pinned Bots out of the sidebar list. A pinned Bot is a tile at
10
+ * the top instead of a row below, never both, so the list this returns is what
11
+ * {@link groupSidebarBotsV1} then groups by label.
12
+ *
13
+ * Order is by pin time — earliest first — because the tile row is a place a
14
+ * User builds up over time and a Bot pinned today must not displace the one
15
+ * they pinned last month. Ties and unparseable instants keep list order.
16
+ */
17
+ export function partitionPinnedSidebarBotsV1<T extends { botId: string }>(
18
+ bots: readonly T[],
19
+ profiles: Readonly<Record<string, { pinnedAt?: string } | undefined>>,
20
+ ): PinnedSidebarBotsV1<T> {
21
+ const pinned: Array<{ bot: T; at: number; index: number }> = [];
22
+ const rest: T[] = [];
23
+ for (const [index, bot] of bots.entries()) {
24
+ const pinnedAt = profiles[bot.botId]?.pinnedAt?.trim();
25
+ if (!pinnedAt) {
26
+ rest.push(bot);
27
+ continue;
28
+ }
29
+ const at = Date.parse(pinnedAt);
30
+ pinned.push({ bot, at: Number.isFinite(at) ? at : 0, index });
31
+ }
32
+ pinned.sort((left, right) =>
33
+ left.at === right.at ? left.index - right.index : left.at - right.at,
34
+ );
35
+ return { pinned: pinned.map((entry) => entry.bot), rest };
36
+ }
37
+
1
38
  export interface SidebarBotGroupV1<T> {
2
39
  key: string;
3
40
  label: string;