@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.
- package/package.json +8 -8
- package/src/client/BotAvatar.vue +118 -4
- package/src/client/FlockSidebar.vue +106 -6
- package/src/client/SheepAvatar.vue +1 -1
- package/src/client/delivered-notifications.test.ts +103 -0
- package/src/client/delivered-notifications.ts +100 -0
- package/src/client/index.test.ts +97 -2
- package/src/client/index.ts +236 -52
- package/src/client/sidebar.test.ts +47 -1
- package/src/client/sidebar.ts +37 -0
- package/src/client/state.ts +4 -2
- package/src/client/styles.css +107 -0
- package/src/shared.test.ts +22 -0
- package/src/shared.ts +18 -1
package/src/client/index.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
124
|
-
// directory it loaded once
|
|
125
|
-
// was reloaded. The row follows the settings the Shell is
|
|
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
|
-
() =>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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]: {
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
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
|
-
(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (
|
|
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")
|
|
616
|
-
|
|
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
|
|
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 {
|
|
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({
|
package/src/client/sidebar.ts
CHANGED
|
@@ -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;
|