@frockbot/plugin-flock 0.3.11 → 0.3.13

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/frockbot.json CHANGED
@@ -22,7 +22,8 @@
22
22
  { "slot": "frockbot.overlays", "order": 10 },
23
23
  { "slot": "frockbot.bot-identity", "order": 10 },
24
24
  { "slot": "frockbot.bot-avatar", "order": 10 },
25
- { "slot": "frockbot.bot-avatar-editor", "order": 10 }
25
+ { "slot": "frockbot.bot-avatar-editor", "order": 10 },
26
+ { "slot": "frockbot.bot-settings-primary-sections", "order": 900 }
26
27
  ],
27
28
  "outlets": []
28
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-flock",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,20 +27,20 @@
27
27
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
28
28
  },
29
29
  "dependencies": {
30
- "@frockbot/client-core": "0.3.11",
31
- "@frockbot/client-ui": "0.3.11",
32
- "@frockbot/configuration-core": "0.3.11",
33
- "@frockbot/kernel-contracts": "0.3.11",
34
- "@frockbot/plugin-shell": "0.3.11",
30
+ "@frockbot/client-core": "0.3.13",
31
+ "@frockbot/client-ui": "0.3.13",
32
+ "@frockbot/configuration-core": "0.3.13",
33
+ "@frockbot/kernel-contracts": "0.3.13",
34
+ "@frockbot/plugin-shell": "0.3.13",
35
35
  "cordis": "4.0.0-rc.8",
36
36
  "vue": "3.5.41"
37
37
  },
38
38
  "devDependencies": {
39
- "@frockbot/plugin-testkit": "0.3.11",
39
+ "@frockbot/plugin-testkit": "0.3.13",
40
40
  "@types/bun": "1.4.0",
41
41
  "@vitejs/plugin-vue": "6.0.8",
42
42
  "css-tree": "2.3.1",
43
- "typescript": "5.9.3",
43
+ "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
44
44
  "vite": "8.2.2",
45
45
  "vue-tsc": "3.3.10"
46
46
  },
package/src/agent.ts CHANGED
@@ -318,7 +318,9 @@ export async function createdBotIdV1(
318
318
  .replace(/[^a-z0-9]+/g, "-")
319
319
  .replace(/^-|-$/g, "")
320
320
  .slice(0, 80) || "bot";
321
- const digest = await sha256HexV1(`${owner.userId}${owner.botId}${effectId}`);
321
+ const digest = await sha256HexV1(
322
+ `${owner.userId}\u0000${owner.botId}\u0000${effectId}`,
323
+ );
322
324
  return `${base}-${digest.slice(0, 12)}`;
323
325
  }
324
326
 
package/src/bot.test.ts CHANGED
@@ -49,6 +49,7 @@ describe("Flock Bot contribution", () => {
49
49
  return Promise.resolve();
50
50
  },
51
51
  archiveEligible: () => Promise.resolve(true),
52
+ tearDown: () => Promise.resolve(),
52
53
  });
53
54
  expect(await create().read(registration, "user-1")).toMatchObject({
54
55
  botId: "alpha",
@@ -95,6 +96,7 @@ describe("Flock Bot contribution", () => {
95
96
  storage,
96
97
  materializeSettings: () => Promise.resolve(),
97
98
  archiveEligible: () => Promise.resolve(eligible),
99
+ tearDown: () => Promise.resolve(),
98
100
  });
99
101
  const contribution = create();
100
102
  await contribution.read(registration, "user-1");
@@ -153,12 +155,95 @@ describe("Flock Bot contribution", () => {
153
155
  });
154
156
  });
155
157
 
158
+ test("deletes every key, cancels the alarm, and refuses to come back", async () => {
159
+ const storage = new MemoryStorage();
160
+ // Whatever else the Bot owned: a run, a Routine schedule, its transcript.
161
+ // The teardown is the host's, so the test plays the host and asserts that
162
+ // the Contribution asked for one and left nothing but the tombstone.
163
+ let alarms = 1;
164
+ const torn: Array<{ userId: string; botId: string }> = [];
165
+ const create = () =>
166
+ createFlockBotBackendContribution({
167
+ storage,
168
+ materializeSettings: () => Promise.resolve(),
169
+ archiveEligible: () => Promise.resolve(false),
170
+ tearDown: (identity) => {
171
+ torn.push(identity);
172
+ alarms = 0;
173
+ storage.values.clear();
174
+ return Promise.resolve();
175
+ },
176
+ });
177
+ const contribution = create();
178
+ await contribution.read(registration, "user-1");
179
+ await storage.put("run:run-1", { schemaVersion: 1 });
180
+ await storage.put("conversation", { schemaVersion: 1 });
181
+ await storage.put("routine-schedule:daily", { schemaVersion: 1 });
182
+ const command = {
183
+ schemaVersion: 1 as const,
184
+ type: "bot/delete" as const,
185
+ commandId: "delete-1",
186
+ botId: "alpha",
187
+ };
188
+ const applied = await contribution.executeLifecycle(
189
+ registration,
190
+ "user-1",
191
+ command,
192
+ );
193
+ expect(applied).toMatchObject({
194
+ status: "applied",
195
+ lifecycle: { botId: "alpha", status: "deleted" },
196
+ });
197
+ // Deletion is not gated on `archiveEligible`: a Bot mid-run is still
198
+ // deleted when its owner says so.
199
+ expect(torn).toEqual([{ userId: "user-1", botId: "alpha" }]);
200
+ expect(alarms).toBe(0);
201
+ // Nothing survives but the tombstone and the receipt that proves it.
202
+ expect([...storage.values.keys()].sort()).toEqual([
203
+ "flock:lifecycle-receipt:delete-1",
204
+ "flock:lifecycle:v1",
205
+ ]);
206
+ // Replaying the command, and replaying it against a fresh instance, both
207
+ // settle from the tombstone without tearing anything down again.
208
+ expect(
209
+ await contribution.executeLifecycle(registration, "user-1", command),
210
+ ).toEqual(applied);
211
+ const reconstructed = create();
212
+ expect(
213
+ await reconstructed.executeLifecycle(registration, "user-1", {
214
+ ...command,
215
+ commandId: "delete-2",
216
+ }),
217
+ ).toMatchObject({ status: "applied", lifecycle: { status: "deleted" } });
218
+ expect(torn).toHaveLength(1);
219
+ expect(
220
+ await reconstructed.readLifecycle(registration, "user-1"),
221
+ ).toMatchObject({ botId: "alpha", status: "deleted" });
222
+ // Neither restore nor archive resurrects it, and no Turn or sheep command
223
+ // may run against it.
224
+ expect(
225
+ await reconstructed.executeLifecycle(registration, "user-1", {
226
+ schemaVersion: 1,
227
+ type: "bot/restore",
228
+ commandId: "restore-1",
229
+ botId: "alpha",
230
+ }),
231
+ ).toMatchObject({ status: "rejected", failure: 'Bot "alpha" is deleted' });
232
+ await expect(reconstructed.read(registration, "user-1")).rejects.toThrow(
233
+ "deleted",
234
+ );
235
+ await expect(reconstructed.assertActive(storage, "alpha")).rejects.toThrow(
236
+ "deleted",
237
+ );
238
+ });
239
+
156
240
  test("rejects malformed durable sheep identity and receipt records", async () => {
157
241
  const storage = new MemoryStorage();
158
242
  const contribution = createFlockBotBackendContribution({
159
243
  storage,
160
244
  materializeSettings: () => Promise.resolve(),
161
245
  archiveEligible: () => Promise.resolve(true),
246
+ tearDown: () => Promise.resolve(),
162
247
  });
163
248
  await storage.put("flock:sheep:v1", {
164
249
  schemaVersion: 1,
package/src/bot.ts CHANGED
@@ -42,6 +42,17 @@ export interface FlockBotBackendHost {
42
42
  userId: string,
43
43
  ): Promise<void>;
44
44
  archiveEligible(storage: FlockBotTransaction): Promise<boolean>;
45
+ /**
46
+ * Destroy everything this Bot owns: every durable key in its own Durable
47
+ * Object, its scheduled alarm, and any object-store root keyed to it.
48
+ *
49
+ * The host owns this rather than the Contribution because the surfaces are
50
+ * the application's — `deleteAll()` and `deleteAlarm()` are not part of the
51
+ * transaction seam this Package writes against, and the object store is not
52
+ * named here at all. It must be idempotent: the delete saga replays, and a
53
+ * Bot torn down twice is torn down once.
54
+ */
55
+ tearDown(identity: { userId: string; botId: string }): Promise<void>;
45
56
  }
46
57
 
47
58
  export class BotArchivedError extends Error {
@@ -51,6 +62,19 @@ export class BotArchivedError extends Error {
51
62
  }
52
63
  }
53
64
 
65
+ /**
66
+ * A Bot that has been permanently deleted. Distinct from `BotNotFoundError`
67
+ * because the tombstone is the one thing a deleted Bot still knows about
68
+ * itself: the registration may already be gone from the User's directory, or
69
+ * it may not be gone yet, and either way no Turn, command or routine may run.
70
+ */
71
+ export class BotDeletedError extends Error {
72
+ constructor(readonly botId: string) {
73
+ super(`Bot "${botId}" is deleted`);
74
+ this.name = "BotDeletedError";
75
+ }
76
+ }
77
+
54
78
  export class FlockBotBackendContribution {
55
79
  constructor(private readonly host: FlockBotBackendHost) {}
56
80
 
@@ -59,6 +83,10 @@ export class FlockBotBackendContribution {
59
83
  userId: string,
60
84
  ): Promise<SheepIdentityViewV1> {
61
85
  const registration = decodeBotRegistrationV1(registrationInput);
86
+ // A tombstone is checked before anything is written back: materializing a
87
+ // deleted Bot would recreate the very rows the delete removed.
88
+ const tombstone = await this.deletedLifecycle();
89
+ if (tombstone) throw new BotDeletedError(registration.botId);
62
90
  await this.host.materializeSettings(registration, userId);
63
91
  return this.host.storage.transaction(async (storage) => {
64
92
  const existingValue = await storage.get<unknown>(IDENTITY_KEY);
@@ -155,10 +183,28 @@ export class FlockBotBackendContribution {
155
183
  });
156
184
  }
157
185
 
186
+ /**
187
+ * The tombstone, if this Bot has been deleted.
188
+ *
189
+ * The delete teardown wipes every key and then writes the lifecycle back as
190
+ * `deleted`, so this single read is the whole of what a deleted Bot is.
191
+ */
192
+ private async deletedLifecycle(): Promise<BotLifecycleViewV1 | undefined> {
193
+ const stored = await this.host.storage.get<unknown>(LIFECYCLE_KEY);
194
+ if (stored === undefined) return undefined;
195
+ const lifecycle = decodeBotLifecycleViewV1(stored);
196
+ return lifecycle.status === "deleted" ? lifecycle : undefined;
197
+ }
198
+
158
199
  async readLifecycle(
159
200
  registration: BotRegistrationV1,
160
201
  userId: string,
161
202
  ): Promise<BotLifecycleViewV1> {
203
+ // The saga reconciles an uncertain delete by reading the lifecycle back,
204
+ // so a tombstone answers here rather than throwing: it is the proof the
205
+ // teardown ran.
206
+ const tombstone = await this.deletedLifecycle();
207
+ if (tombstone) return structuredClone(tombstone);
162
208
  await this.materialize(registration, userId);
163
209
  const stored = await this.host.storage.get<unknown>(LIFECYCLE_KEY);
164
210
  if (stored === undefined)
@@ -174,8 +220,25 @@ export class FlockBotBackendContribution {
174
220
  const command = decodeBotLifecycleCommandV1(input);
175
221
  if (registration.botId !== command.botId)
176
222
  throw new Error("lifecycle command does not match Bot registration");
177
- await this.materialize(registration, userId);
178
223
  const fingerprint = flockCommandFingerprint(command);
224
+ // Deletion is terminal, so the tombstone is read before the Bot is
225
+ // materialized: a replayed `bot/delete` settles from it, and archive or
226
+ // restore is refused instead of resurrecting the Bot.
227
+ const tombstone = await this.deletedLifecycle();
228
+ if (tombstone)
229
+ return {
230
+ schemaVersion: 1,
231
+ commandId: command.commandId,
232
+ botId: command.botId,
233
+ status: command.type === "bot/delete" ? "applied" : "rejected",
234
+ lifecycle: structuredClone(tombstone),
235
+ ...(command.type === "bot/delete"
236
+ ? {}
237
+ : { failure: `Bot "${command.botId}" is deleted` }),
238
+ } satisfies BotLifecycleReceiptV1;
239
+ if (command.type === "bot/delete")
240
+ return this.delete(registration, userId, command, fingerprint);
241
+ await this.materialize(registration, userId);
179
242
  return this.host.storage.transaction(async (storage) => {
180
243
  const receiptKey = `${LIFECYCLE_RECEIPT_PREFIX}${command.commandId}`;
181
244
  const storedReceipt = await storage.get<unknown>(receiptKey);
@@ -229,6 +292,52 @@ export class FlockBotBackendContribution {
229
292
  });
230
293
  }
231
294
 
295
+ /**
296
+ * Permanent deletion, as one replayable step.
297
+ *
298
+ * The teardown runs first and the tombstone is written after it, so a crash
299
+ * anywhere in between leaves an empty Durable Object that the saga's retry
300
+ * tears down again — `tearDown` is idempotent, and an empty object has
301
+ * nothing left to remove. Writing the tombstone first would be the unsafe
302
+ * order: `deleteAll` would take it out again and the Bot would look alive.
303
+ */
304
+ private async delete(
305
+ registration: BotRegistrationV1,
306
+ userId: string,
307
+ command: BotLifecycleCommandV1,
308
+ fingerprint: string,
309
+ ): Promise<BotLifecycleReceiptV1> {
310
+ const currentValue = await this.host.storage.get<unknown>(LIFECYCLE_KEY);
311
+ const current =
312
+ currentValue === undefined
313
+ ? undefined
314
+ : decodeBotLifecycleViewV1(currentValue);
315
+ if (current && current.botId !== registration.botId)
316
+ throw new Error("Bot lifecycle identity does not match");
317
+ await this.host.tearDown({ userId, botId: registration.botId });
318
+ const lifecycle = {
319
+ schemaVersion: 1,
320
+ botId: command.botId,
321
+ status: "deleted",
322
+ revision: (current?.revision ?? 0) + 1,
323
+ } satisfies BotLifecycleViewV1;
324
+ const receipt = {
325
+ schemaVersion: 1,
326
+ commandId: command.commandId,
327
+ botId: command.botId,
328
+ status: "applied",
329
+ lifecycle,
330
+ } satisfies BotLifecycleReceiptV1;
331
+ await this.host.storage.put({
332
+ [LIFECYCLE_KEY]: lifecycle,
333
+ [`${LIFECYCLE_RECEIPT_PREFIX}${command.commandId}`]: {
334
+ fingerprint,
335
+ receipt,
336
+ },
337
+ });
338
+ return decodeBotLifecycleReceiptV1(receipt);
339
+ }
340
+
232
341
  async assertActive(
233
342
  storage: FlockBotTransaction,
234
343
  botId: string,
@@ -238,6 +347,7 @@ export class FlockBotBackendContribution {
238
347
  const lifecycle = decodeBotLifecycleViewV1(value);
239
348
  if (lifecycle.botId !== botId)
240
349
  throw new Error("Bot lifecycle identity does not match");
350
+ if (lifecycle.status === "deleted") throw new BotDeletedError(botId);
241
351
  if (lifecycle.status === "archived") throw new BotArchivedError(botId);
242
352
  }
243
353
  }
@@ -2,13 +2,18 @@
2
2
  /**
3
3
  * What a Bot looks like. The Flock's generated sheep recipe is the avatar.
4
4
  *
5
- * `working` draws the same activity ring the thread puts on the Bot it is
6
- * talking to, so a person reading one conversation can see another Bot still
7
- * going on a list row and on a pinned tile alike. The sidebar knows only that
8
- * a Turn is running; the step count is the open conversation's to show, so a
9
- * ring out here pulses and does not tick.
5
+ * `working` draws the same language the thread uses for the Bot it is talking
6
+ * to particles drifting off the avatar's right so a person reading one
7
+ * conversation can see another Bot still going, on a list row and on a pinned
8
+ * tile alike.
9
+ *
10
+ * It is three CSS dots rather than the thread's canvas. A sidebar can hold
11
+ * twenty rows, and twenty `requestAnimationFrame` loops running full particle
12
+ * fields to say one bit each — "this Bot is busy" — is a cost the list should
13
+ * not pay. Out here that is genuinely all there is to say: the sidebar knows a
14
+ * Turn is running and nothing about its pace, which is the open
15
+ * conversation's to show.
10
16
  */
11
- import { UiActivityRing } from "@frockbot/client-ui";
12
17
  import SheepAvatar from "./SheepAvatar.vue";
13
18
  import type { SheepRecipeV1 } from "../shared.js";
14
19
 
@@ -26,8 +31,12 @@ withDefaults(
26
31
  <template>
27
32
  <span class="flock-avatar-slot">
28
33
  <SheepAvatar :sheep="sheep" :label="label" :size="size" />
29
- <Transition name="flock-avatar-ring">
30
- <UiActivityRing v-if="working" :progress="0" />
34
+ <Transition name="flock-avatar-drift">
35
+ <span v-if="working" class="flock-avatar-drift" aria-hidden="true">
36
+ <i />
37
+ <i />
38
+ <i />
39
+ </span>
31
40
  </Transition>
32
41
  </span>
33
42
  </template>
@@ -40,13 +49,86 @@ withDefaults(
40
49
  place-items: center;
41
50
  }
42
51
 
43
- .flock-avatar-ring-enter-active,
44
- .flock-avatar-ring-leave-active {
52
+ /*
53
+ * Clear of the avatar rather than over it, in the strip of row to its right.
54
+ * `pointer-events` keeps the whole thing out of the way of the row's click,
55
+ * and it lives inside the gap the row already leaves between the avatar and the
56
+ * name, so it never runs under the text.
57
+ */
58
+ .flock-avatar-drift {
59
+ position: absolute;
60
+ z-index: 1;
61
+ top: 50%;
62
+ left: 100%;
63
+ width: 12px;
64
+ height: 6px;
65
+ margin-left: 2px;
66
+ pointer-events: none;
67
+ transform: translateY(-50%);
68
+ }
69
+
70
+ .flock-avatar-drift i {
71
+ position: absolute;
72
+ top: 2px;
73
+ width: 2px;
74
+ height: 2px;
75
+ border-radius: 50%;
76
+ background: var(--frock-action-primary);
77
+ animation: frock-drift 2400ms linear infinite;
78
+ }
79
+
80
+ .flock-avatar-drift i:nth-child(2) {
81
+ animation-delay: 800ms;
82
+ }
83
+
84
+ .flock-avatar-drift i:nth-child(3) {
85
+ animation-delay: 1600ms;
86
+ }
87
+
88
+ /*
89
+ * The same journey the thread's particles make — born at the avatar's edge,
90
+ * out to the right, fading as they go — at a size a list can afford.
91
+ */
92
+ @keyframes frock-drift {
93
+ 0% {
94
+ opacity: 0;
95
+ transform: translate(0, 0);
96
+ }
97
+
98
+ 20% {
99
+ opacity: 0.9;
100
+ }
101
+
102
+ 100% {
103
+ opacity: 0;
104
+ transform: translate(10px, -2px);
105
+ }
106
+ }
107
+
108
+ .flock-avatar-drift-enter-active,
109
+ .flock-avatar-drift-leave-active {
45
110
  transition: opacity 420ms ease-out;
46
111
  }
47
112
 
48
- .flock-avatar-ring-enter-from,
49
- .flock-avatar-ring-leave-to {
113
+ .flock-avatar-drift-enter-from,
114
+ .flock-avatar-drift-leave-to {
50
115
  opacity: 0;
51
116
  }
117
+
118
+ /*
119
+ * Reduced motion keeps the fact and drops the movement: one still dot beside
120
+ * the avatar, which is the whole of what this was saying.
121
+ */
122
+ @media (prefers-reduced-motion: reduce) {
123
+ .flock-avatar-drift i {
124
+ animation: none;
125
+ opacity: 0.9;
126
+ transform: translate(4px, 0);
127
+ }
128
+
129
+ .flock-avatar-drift i:nth-child(2),
130
+ .flock-avatar-drift i:nth-child(3) {
131
+ display: none;
132
+ }
133
+ }
52
134
  </style>
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ // Deleting a Bot is Flock's authority — the directory it removes the
3
+ // registration from is Flock's — so the affordance is contributed into the Bot
4
+ // settings screen from here rather than reimplemented inside the Settings
5
+ // Package, which would have to reach across a Package seam for the store.
6
+ //
7
+ // The confirmation itself is `FlockOverlay`'s: one dialog, one focus trap, one
8
+ // mobile layout, and no second copy of the copy.
9
+ import { inject } from "vue";
10
+ import { flockWebDataKey } from "./state.js";
11
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
12
+
13
+ const flock = inject(flockWebDataKey);
14
+ if (!flock) throw new Error("Flock client data was not provided");
15
+ const shell = inject(frockBotWebDataKey);
16
+ if (!shell) throw new Error("FrockBot client data was not provided");
17
+ </script>
18
+ <template>
19
+ <section v-if="shell.activeBotId" class="flock-danger-zone">
20
+ <div>
21
+ <strong>Delete Bot</strong>
22
+ <small>This Bot and its chat history will be permanently deleted.</small>
23
+ </div>
24
+ <button
25
+ type="button"
26
+ class="flock-danger-zone__action"
27
+ @click="flock.openDelete(shell.activeBotId)"
28
+ >
29
+ Delete Bot
30
+ </button>
31
+ </section>
32
+ </template>
33
+ <style scoped>
34
+ .flock-danger-zone {
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: space-between;
38
+ gap: 16px;
39
+ padding: 12px 14px;
40
+ border: 1px solid var(--frock-danger-border);
41
+ border-radius: var(--frock-radius-surface, 12px);
42
+ background: var(--frock-danger-surface);
43
+ }
44
+
45
+ .flock-danger-zone div {
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 2px;
49
+ }
50
+
51
+ .flock-danger-zone small {
52
+ color: var(--frock-text-muted);
53
+ }
54
+
55
+ .flock-danger-zone__action {
56
+ flex: none;
57
+ height: var(--frock-control-md, 32px);
58
+ padding: 0 14px;
59
+ border: 1px solid var(--frock-danger-strong);
60
+ border-radius: 999px;
61
+ background: var(--frock-danger-strong);
62
+ color: var(--frock-on-accent);
63
+ font: inherit;
64
+ font-weight: 700;
65
+ cursor: pointer;
66
+ }
67
+
68
+ .flock-danger-zone__action:hover {
69
+ background: var(--frock-danger-text);
70
+ }
71
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { inject, nextTick, onBeforeUnmount, ref, watch } from "vue";
2
+ import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from "vue";
3
3
  import { sheepCatalog } from "../shared.js";
4
4
  import { flockWebDataKey } from "./state.js";
5
5
  import { dialogFocusWrapTarget } from "./dialog-focus.js";
@@ -8,6 +8,22 @@ const providedFlock = inject(flockWebDataKey);
8
8
  if (!providedFlock) throw new Error("Flock client data was not provided");
9
9
  const flock = providedFlock;
10
10
 
11
+ /**
12
+ * The name the confirmation names. The live profile is the Bot's current name;
13
+ * the registration seed is what it was called when it was made, and is the
14
+ * fallback for a Bot whose identity read has not landed.
15
+ */
16
+ const pendingName = computed(() => {
17
+ const botId = flock.value.lifecyclePending;
18
+ if (!botId) return "this Bot";
19
+ return (
20
+ flock.value.profiles[botId]?.name ??
21
+ flock.value.directory.bots.find((bot) => bot.botId === botId)
22
+ ?.initialName ??
23
+ "this Bot"
24
+ );
25
+ });
26
+
11
27
  const dialog = ref<HTMLElement>();
12
28
  let restoreFocus: HTMLElement | undefined;
13
29
  const focusable =
@@ -90,6 +106,25 @@ onBeforeUnmount(() => restoreFocus?.focus());
90
106
  </button>
91
107
  </div>
92
108
  </div>
109
+ <div v-else-if="flock.overlay === 'delete'" class="flock-form">
110
+ <span class="flock-eyebrow">Delete Bot</span>
111
+ <h1 id="flock-title">Delete {{ pendingName }}?</h1>
112
+ <p>This Bot and its chat history will be permanently deleted.</p>
113
+ <p
114
+ v-if="flock.error"
115
+ class="flock-error"
116
+ role="alert"
117
+ aria-live="assertive"
118
+ >
119
+ {{ flock.error }}
120
+ </p>
121
+ <div class="flock-actions">
122
+ <button type="button" @click="flock.closeOverlay">Cancel</button>
123
+ <button class="danger" type="button" @click="flock.deleteBot">
124
+ Delete
125
+ </button>
126
+ </div>
127
+ </div>
93
128
  <template v-else>
94
129
  <div class="flock-preview">
95
130
  <SheepAvatar
@@ -109,17 +109,39 @@ onMounted(() => void flock.value.load());
109
109
 
110
110
  <template>
111
111
  <div class="flock-list-actions">
112
- <button type="button" class="flock-manage" @click="flock.toggleArchived">
113
- {{ flock.showArchived ? "Hide archived" : "Manage" }}
112
+ <button
113
+ type="button"
114
+ class="flock-manage"
115
+ :aria-pressed="flock.showArchived"
116
+ @click="flock.toggleArchived"
117
+ >
118
+ {{ flock.showArchived ? "Done" : "Manage" }}
114
119
  </button>
115
120
  </div>
121
+ <!--
122
+ An unreadable list is not an empty one, and it is not a loading one either.
123
+ Offering to add a first Bot to a User whose Bots simply did not load is the
124
+ worst thing this column can say; leaving the skeleton up forever is the
125
+ second worst, because it says the read is still coming when it has already
126
+ failed. So the failure takes the slot first, and offers the read again.
127
+ -->
128
+ <p
129
+ v-if="flock.error && !flock.directory.bots.length"
130
+ class="flock-error"
131
+ role="alert"
132
+ >
133
+ {{ flock.error }}
134
+ <button type="button" class="flock-retry" @click="flock.load()">
135
+ Retry
136
+ </button>
137
+ </p>
116
138
  <!--
117
139
  The skeleton is for a list nobody has yet, not for every request: the first
118
140
  paint happens before `load()` is even called, and a reload after creating a
119
141
  Bot must keep the list already on screen rather than blanking it. "No Bots
120
142
  yet." is a fact about the account, so it waits for an answer.
121
143
  -->
122
- <div v-if="!flock.loaded" class="flock-skeleton" aria-busy="true">
144
+ <div v-else-if="!flock.loaded" class="flock-skeleton" aria-busy="true">
123
145
  <span class="flock-skeleton-label">Loading your flock…</span>
124
146
  <div v-for="row in 3" :key="row" class="flock-skeleton-row">
125
147
  <UiSkeleton shape="circle" />
@@ -257,6 +279,14 @@ onMounted(() => void flock.value.load());
257
279
  >
258
280
  Archive
259
281
  </button>
282
+ <button
283
+ v-if="flock.showArchived"
284
+ type="button"
285
+ class="flock-lifecycle flock-lifecycle--danger"
286
+ @click="flock.openDelete(bot.botId)"
287
+ >
288
+ Delete
289
+ </button>
260
290
  </div>
261
291
  </TransitionGroup>
262
292
  </section>
@@ -326,5 +356,13 @@ onMounted(() => void flock.value.load());
326
356
  </TransitionGroup>
327
357
  </div>
328
358
  </template>
329
- <p v-if="flock.error" class="flock-error" role="alert">{{ flock.error }}</p>
359
+ <!-- The same failure over a list that still has rows: a banner, not a
360
+ replacement, because what is on screen is still the last thing known. -->
361
+ <p
362
+ v-if="flock.error && flock.directory.bots.length"
363
+ class="flock-error"
364
+ role="alert"
365
+ >
366
+ {{ flock.error }}
367
+ </p>
330
368
  </template>