@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 +2 -1
- package/package.json +8 -8
- package/src/agent.ts +3 -1
- package/src/bot.test.ts +85 -0
- package/src/bot.ts +111 -1
- package/src/client/BotAvatar.vue +94 -12
- package/src/client/FlockDangerZone.vue +71 -0
- package/src/client/FlockOverlay.vue +36 -1
- package/src/client/FlockSidebar.vue +42 -4
- package/src/client/delivered-notifications.test.ts +103 -0
- package/src/client/delivered-notifications.ts +100 -0
- package/src/client/index.test.ts +205 -2
- package/src/client/index.ts +0 -0
- package/src/client/state.ts +8 -3
- package/src/client/styles.css +34 -0
- package/src/shared.test.ts +41 -0
- package/src/shared.ts +26 -4
- package/src/user.test.ts +104 -0
- package/src/user.ts +76 -7
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.
|
|
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.
|
|
31
|
-
"@frockbot/client-ui": "0.3.
|
|
32
|
-
"@frockbot/configuration-core": "0.3.
|
|
33
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
34
|
-
"@frockbot/plugin-shell": "0.3.
|
|
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.
|
|
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": "
|
|
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(
|
|
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
|
}
|
package/src/client/BotAvatar.vue
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
* going
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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-
|
|
30
|
-
<
|
|
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
|
-
|
|
44
|
-
|
|
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-
|
|
49
|
-
.flock-avatar-
|
|
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
|
|
113
|
-
|
|
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
|
-
|
|
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>
|