@frockbot/plugin-flock 0.3.19 → 0.3.21

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-flock",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,16 +28,16 @@
28
28
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
29
29
  },
30
30
  "dependencies": {
31
- "@frockbot/client-core": "0.3.19",
32
- "@frockbot/client-ui": "0.3.19",
33
- "@frockbot/configuration-core": "0.3.19",
34
- "@frockbot/kernel-contracts": "0.3.19",
35
- "@frockbot/plugin-shell": "0.3.19",
31
+ "@frockbot/client-core": "0.3.21",
32
+ "@frockbot/client-ui": "0.3.21",
33
+ "@frockbot/configuration-core": "0.3.21",
34
+ "@frockbot/kernel-contracts": "0.3.21",
35
+ "@frockbot/plugin-shell": "0.3.21",
36
36
  "cordis": "4.0.0-rc.8",
37
37
  "vue": "3.5.41"
38
38
  },
39
39
  "devDependencies": {
40
- "@frockbot/plugin-testkit": "0.3.19",
40
+ "@frockbot/plugin-testkit": "0.3.21",
41
41
  "@types/bun": "1.4.0",
42
42
  "@vitejs/plugin-vue": "6.0.8",
43
43
  "css-tree": "2.3.1",
@@ -735,3 +735,108 @@ describe("Flock sidebar rows follow the transcript", () => {
735
735
  });
736
736
  });
737
737
  });
738
+
739
+ describe("Flock list follows the account", () => {
740
+ test("a Bot deleted elsewhere moves this tab's selection on the next unread beat", async () => {
741
+ installStorage();
742
+ const location = { href: "https://app.example/?bot=alpha" };
743
+ Object.defineProperty(globalThis, "window", {
744
+ configurable: true,
745
+ value: {
746
+ location,
747
+ history: {
748
+ state: null,
749
+ replaceState: (_state: unknown, _title: string, url: URL) => {
750
+ location.href = url.href;
751
+ },
752
+ },
753
+ },
754
+ });
755
+ const sheep = randomSheepRecipeV1(() => 0);
756
+ // The directory this tab read yesterday still has alpha; the account has
757
+ // since deleted it (from the phone, say) and made gamma.
758
+ let bots = ["alpha", "beta"];
759
+ let directoryReads = 0;
760
+ const state = mount((path) => {
761
+ if (path === "/api/bots/identities")
762
+ return Promise.resolve({ schemaVersion: 1, identities: [] });
763
+ if (path === "/api/bots") {
764
+ directoryReads += 1;
765
+ return Promise.resolve({
766
+ schemaVersion: 1,
767
+ revision: directoryReads,
768
+ bots: bots.map((botId) => ({
769
+ schemaVersion: 1,
770
+ botId,
771
+ registeredAt: new Date(0).toISOString(),
772
+ initialName: botId,
773
+ sheep,
774
+ })),
775
+ });
776
+ }
777
+ if (path === "/api/bots/lifecycles")
778
+ return Promise.resolve({
779
+ schemaVersion: 1,
780
+ lifecycles: bots.map((botId) => ({
781
+ schemaVersion: 1,
782
+ botId,
783
+ status: "active",
784
+ revision: 0,
785
+ })),
786
+ });
787
+ if (path === "/api/bots/unread")
788
+ return Promise.resolve({
789
+ schemaVersion: 1,
790
+ unread: bots.map((botId) => ({
791
+ schemaVersion: 1,
792
+ botId,
793
+ count: 0,
794
+ capped: false,
795
+ unread: false,
796
+ manuallyUnread: false,
797
+ })),
798
+ });
799
+ if (path.endsWith("/sheep")) {
800
+ const botId = path.split("/")[3]!;
801
+ return Promise.resolve({ schemaVersion: 1, botId, revision: 0, sheep });
802
+ }
803
+ return Promise.reject(new Error(`unexpected request: ${path}`));
804
+ });
805
+ const selected: string[] = [];
806
+ const shell = ref({
807
+ activeBotId: undefined as string | undefined,
808
+ selectBot: (botId: string) => {
809
+ selected.push(botId);
810
+ shell.value.activeBotId = botId;
811
+ return Promise.resolve();
812
+ },
813
+ transcripts: {
814
+ rememberViewport: () => undefined,
815
+ viewportFor: () => undefined,
816
+ forget: () => undefined,
817
+ },
818
+ });
819
+ state.value.bindShell(shell as unknown as Ref<FrockBotWebData>);
820
+ await state.value.load();
821
+ expect(selected).toEqual(["alpha"]);
822
+ expect(directoryReads).toBe(1);
823
+
824
+ // A beat that agrees with the list on screen reads nothing again.
825
+ await state.value.refreshUnread();
826
+ expect(directoryReads).toBe(1);
827
+
828
+ bots = ["beta", "gamma"];
829
+ await state.value.refreshUnread();
830
+ expect(directoryReads).toBe(2);
831
+ expect(state.value.directory.bots.map((bot) => bot.botId)).toEqual([
832
+ "beta",
833
+ "gamma",
834
+ ]);
835
+ expect(selected.at(-1)).toBe("beta");
836
+ expect(new URL(location.href).searchParams.get("bot")).toBe("beta");
837
+
838
+ // The same disagreement twice is still one reload.
839
+ await state.value.refreshUnread();
840
+ expect(directoryReads).toBe(2);
841
+ });
842
+ });
@@ -122,6 +122,11 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
122
122
  let unreadRefresh: Promise<void> | undefined;
123
123
  /** A beat that arrived while one was in flight, replayed once it finishes. */
124
124
  let unreadRefreshQueued = false;
125
+ /**
126
+ * The last flock the unread read disagreed with, so one disagreement is one
127
+ * reload and not a reload on every beat.
128
+ */
129
+ let reconciledFlockSignature: string | undefined;
125
130
 
126
131
  /**
127
132
  * One unread read at a time, with at most one more behind it.
@@ -383,6 +388,32 @@ export const flockClientPlugin: ClientPlugin = (ctx) => {
383
388
  const directory = decodeBotUnreadDirectoryViewV1(
384
389
  await request("/api/bots/unread"),
385
390
  );
391
+ // The unread read fans out over the whole non-archived flock, so it is
392
+ // also this tab's cheapest view of which Bots still exist. A Bot deleted
393
+ // or created from another tab or the phone leaves this tab's list
394
+ // stale, and a stale list can keep the conversation open on a Bot the
395
+ // server no longer has — every read of it answers 404 and the person
396
+ // cannot chat (2026-09-05: a day-old tab on a deleted Bob). A flock
397
+ // that differs from the list on screen is read again, which also moves
398
+ // the selection off a Bot that is gone. One disagreement is one reload.
399
+ if (state.value.loaded) {
400
+ const known = new Set(
401
+ state.value.directory.bots
402
+ .map((bot) => bot.botId)
403
+ .filter((botId) => state.value.lifecycles[botId] !== "archived"),
404
+ );
405
+ const seen = new Set(directory.unread.map((view) => view.botId));
406
+ const differs =
407
+ known.size !== seen.size ||
408
+ [...seen].some((botId) => !known.has(botId));
409
+ const signature = [...seen].sort().join("\n");
410
+ if (differs && signature !== reconciledFlockSignature) {
411
+ reconciledFlockSignature = signature;
412
+ await state.value.load();
413
+ return;
414
+ }
415
+ if (!differs) reconciledFlockSignature = undefined;
416
+ }
386
417
  // A Turn that settles in the conversation the User is looking at has
387
418
  // been read by the time it arrives, so the badge that counted it is
388
419
  // wrong the instant it appears — and painting it for the beat before the