@frockbot/plugin-shell 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.
Files changed (37) hide show
  1. package/package.json +34 -32
  2. package/src/agent.test.ts +66 -0
  3. package/src/agent.ts +107 -2
  4. package/src/backend-configuration.test.ts +15 -9
  5. package/src/backend-package-catalog.ts +75 -26
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +118 -27
  8. package/src/client/FrockBotApp.vue +405 -134
  9. package/src/client/activity-trail.test.ts +205 -0
  10. package/src/client/activity-trail.ts +227 -0
  11. package/src/client/index.test.ts +25 -5
  12. package/src/client/index.ts +191 -47
  13. package/src/client/model-presentation.test.ts +3 -3
  14. package/src/client/no-bot-model-label.test.ts +7 -7
  15. package/src/client/skill-invocation.test.ts +34 -0
  16. package/src/client/skill-invocation.ts +22 -0
  17. package/src/client/styles.css +134 -89
  18. package/src/client/transcript-cache.test.ts +125 -0
  19. package/src/client/transcript-cache.ts +190 -0
  20. package/src/compaction-scheduler.test.ts +96 -0
  21. package/src/compaction-scheduler.ts +108 -0
  22. package/src/compaction-transcript.test.ts +174 -0
  23. package/src/compaction.test.ts +596 -0
  24. package/src/compaction.ts +539 -0
  25. package/src/focus.test.ts +222 -0
  26. package/src/focus.ts +93 -0
  27. package/src/history.ts +86 -8
  28. package/src/legacy-frock-model-id.test.ts +148 -0
  29. package/src/notification-id.test.ts +26 -0
  30. package/src/notification-id.ts +0 -0
  31. package/src/run-protocol.test.ts +37 -0
  32. package/src/run-protocol.ts +148 -38
  33. package/src/settings-links.test.ts +8 -2
  34. package/src/settings-links.ts +17 -2
  35. package/src/shared.ts +36 -0
  36. package/src/unread.ts +23 -1
  37. package/tsconfig.json +1 -2
@@ -1,4 +1,10 @@
1
1
  .frockbot-root {
2
+ /*
3
+ * The clear margin a bubble leaves on the side it is not anchored to — about
4
+ * a centimetre and a half. It is what says which side of the conversation a
5
+ * line is on, so it is the same on a phone as on a desktop.
6
+ */
7
+ --frock-bubble-gutter: 56px;
2
8
  position: fixed;
3
9
  z-index: 1000;
4
10
  inset: 0;
@@ -195,6 +201,18 @@
195
201
  -webkit-app-region: no-drag;
196
202
  }
197
203
 
204
+ /*
205
+ * The Bot's own header controls on a phone, where the right panel that
206
+ * usually carries them is a closed drawer.
207
+ */
208
+ .topbar-bot-actions {
209
+ display: flex;
210
+ flex: 0 0 auto;
211
+ align-items: center;
212
+ gap: 2px;
213
+ margin-left: auto;
214
+ }
215
+
198
216
  /* Thread */
199
217
 
200
218
  .thread {
@@ -209,6 +227,20 @@
209
227
  background-image: var(--frock-thread-gradient);
210
228
  scrollbar-color: var(--frock-scrollbar) transparent;
211
229
  scrollbar-width: thin;
230
+ /*
231
+ * New content is added at the end, so the browser keeps the reader where
232
+ * they are rather than letting the growth push the view.
233
+ */
234
+ overflow-anchor: auto;
235
+ }
236
+
237
+ /*
238
+ * Laid out and measured, deliberately not painted. A conversation is put at
239
+ * its end while this holds, so it opens there instead of opening at the top
240
+ * and scrolling down where the reader can see it.
241
+ */
242
+ .thread-settling {
243
+ visibility: hidden;
212
244
  }
213
245
 
214
246
  .empty-thread {
@@ -324,26 +356,45 @@
324
356
  }
325
357
 
326
358
  /*
327
- * An assistant Turn is its avatar and, beside it, one column holding
328
- * everything the Turn produced. The row has exactly two children: bubbles,
329
- * notices, sends and chips stack inside the column, so a one-word reply is a
330
- * bubble the width of its word rather than a sliver of a shared row.
359
+ * An assistant Turn is one column holding everything it produced: bubbles,
360
+ * notices and sends stack inside it, so a one-word reply is a bubble the width
361
+ * of its word rather than a sliver of a shared row.
362
+ *
363
+ * The avatar is not in that column. While the Bot is working it sits on its
364
+ * own row underneath, so the trail streaming off its right has the width of
365
+ * the transcript to run through rather than the gutter beside a bubble. Every
366
+ * reply here is from the same Bot — there are no group conversations — so a
367
+ * sheep on every settled line said nothing and cost the column its left edge.
331
368
  */
332
369
  .message-assistant {
333
- flex-direction: row;
334
- align-items: flex-start;
370
+ flex-direction: column;
371
+ align-items: stretch;
335
372
  gap: 8px;
336
373
  }
337
374
 
338
375
  .message-column {
339
376
  display: flex;
340
377
  min-width: 0;
341
- flex: 1 1 auto;
342
378
  flex-direction: column;
343
379
  align-items: flex-start;
344
380
  gap: 6px;
345
381
  }
346
382
 
383
+ /*
384
+ * The working row. Its height is the trail's canvas: tall enough for the
385
+ * wobble to be visible, short enough that it reads as one line of the thread.
386
+ */
387
+ .bot-working {
388
+ display: flex;
389
+ height: 44px;
390
+ align-items: center;
391
+ gap: 0;
392
+ }
393
+
394
+ .bot-working-indicator {
395
+ height: 100%;
396
+ }
397
+
347
398
  .bot-avatar {
348
399
  position: relative;
349
400
  display: grid;
@@ -351,7 +402,6 @@
351
402
  height: var(--frock-avatar-sm);
352
403
  flex: 0 0 auto;
353
404
  place-items: center;
354
- margin-top: 2px;
355
405
  }
356
406
 
357
407
  .bot-avatar-fallback {
@@ -381,20 +431,23 @@
381
431
  animation: frock-breathe 2600ms ease-in-out infinite;
382
432
  }
383
433
 
384
- .bot-avatar-live::after {
385
- position: absolute;
386
- border-radius: 12px;
387
- animation: frock-halo 2600ms ease-in-out infinite;
388
- box-shadow: 0 0 0 2px var(--frock-surface-accent);
389
- content: "";
390
- inset: -4px;
391
- opacity: 0;
392
- pointer-events: none;
434
+ .bot-avatar-waiting {
435
+ animation-duration: 1600ms;
393
436
  }
394
437
 
395
- .bot-avatar-waiting,
396
- .bot-avatar-waiting::after {
397
- animation-duration: 1600ms;
438
+ /*
439
+ * The working row's way off screen. The Turn has settled, the reply is the
440
+ * answer now, and the row fades rather than vanishing between two frames —
441
+ * which is also long enough for the particles still in flight to drain.
442
+ */
443
+ .bot-working-enter-active,
444
+ .bot-working-leave-active {
445
+ transition: opacity 420ms ease-out;
446
+ }
447
+
448
+ .bot-working-enter-from,
449
+ .bot-working-leave-to {
450
+ opacity: 0;
398
451
  }
399
452
 
400
453
  @keyframes frock-breathe {
@@ -421,9 +474,59 @@
421
474
  }
422
475
  }
423
476
 
477
+ /*
478
+ * The bubble a Turn has before it has words: three dots that say the Bot is
479
+ * working, where the reply itself will appear.
480
+ */
481
+ .message-working {
482
+ display: flex;
483
+ align-items: center;
484
+ min-height: 20px;
485
+ }
486
+
487
+ .working-dots {
488
+ display: inline-flex;
489
+ gap: 4px;
490
+ }
491
+
492
+ .working-dots i {
493
+ width: 6px;
494
+ height: 6px;
495
+ border-radius: 50%;
496
+ background: var(--frock-text-muted);
497
+ animation: frock-working-dot 1.2s ease-in-out infinite;
498
+ }
499
+
500
+ .working-dots i:nth-child(2) {
501
+ animation-delay: 0.15s;
502
+ }
503
+
504
+ .working-dots i:nth-child(3) {
505
+ animation-delay: 0.3s;
506
+ }
507
+
508
+ @keyframes frock-working-dot {
509
+ 0%,
510
+ 60%,
511
+ 100% {
512
+ opacity: 0.3;
513
+ }
514
+
515
+ 30% {
516
+ opacity: 1;
517
+ }
518
+ }
519
+
520
+ /*
521
+ * A bubble runs nearly the width of the transcript, leaving one clear margin
522
+ * on the side it is not anchored to: the Bot's at the end, the User's at the
523
+ * start. That margin is the only thing distinguishing the two columns now that
524
+ * neither carries an avatar, so it is a fixed gap rather than a percentage —
525
+ * it has to stay legible at every width.
526
+ */
424
527
  .message-bubble {
425
528
  width: max-content;
426
- max-width: min(640px, 84%);
529
+ max-width: calc(100% - var(--frock-bubble-gutter));
427
530
  padding: 10px 14px;
428
531
  border: 1px solid var(--frock-border);
429
532
  border-radius: 18px 18px 18px 6px;
@@ -438,7 +541,7 @@
438
541
  }
439
542
 
440
543
  .message-user .message-bubble {
441
- max-width: min(640px, 84%);
544
+ max-width: calc(100% - var(--frock-bubble-gutter));
442
545
  border-color: var(--frock-action-primary);
443
546
  border-radius: 18px 18px 6px 18px;
444
547
  color: var(--frock-on-accent);
@@ -453,59 +556,9 @@
453
556
 
454
557
  .message-sends {
455
558
  display: flex;
559
+ width: calc(100% - var(--frock-bubble-gutter));
456
560
  flex-direction: column;
457
561
  gap: 8px;
458
- width: min(640px, 84%);
459
- }
460
-
461
- /*
462
- * Tool calls. Quieter than anything the Bot said: the User is watching work
463
- * happen, not reading a message, so a chip carries the name and its state and
464
- * opens in place to what the tool returned.
465
- */
466
-
467
- .message-tools {
468
- display: flex;
469
- flex-direction: column;
470
- gap: 6px;
471
- width: min(640px, 84%);
472
- }
473
-
474
- .tool-chip {
475
- display: flex;
476
- flex-wrap: wrap;
477
- gap: 8px;
478
- align-items: baseline;
479
- padding: 6px 10px;
480
- font: inherit;
481
- color: var(--frock-text-muted);
482
- text-align: left;
483
- cursor: pointer;
484
- background: var(--frock-surface);
485
- border: 1px solid var(--frock-border);
486
- border-radius: 10px;
487
- }
488
-
489
- .tool-chip-name {
490
- color: var(--frock-text);
491
- font-weight: 600;
492
- }
493
-
494
- .tool-chip-status {
495
- flex: 1 1 auto;
496
- font-size: var(--frock-text-xs);
497
- }
498
-
499
- .tool-chip-failed .tool-chip-status {
500
- color: var(--frock-danger-text);
501
- }
502
-
503
- .tool-chip-result {
504
- flex: 1 0 100%;
505
- max-height: 12em;
506
- overflow: auto;
507
- white-space: pre-wrap;
508
- word-break: break-word;
509
562
  }
510
563
 
511
564
  /*
@@ -516,9 +569,9 @@
516
569
 
517
570
  .message-tasks {
518
571
  display: flex;
572
+ width: calc(100% - var(--frock-bubble-gutter));
519
573
  flex-direction: column;
520
574
  gap: 6px;
521
- width: min(640px, 84%);
522
575
  }
523
576
 
524
577
  .task-chip {
@@ -1138,19 +1191,21 @@
1138
1191
  padding-left: 0;
1139
1192
  }
1140
1193
 
1194
+ /* No window chrome to clear, but the panel toggle still sits at the
1195
+ trailing edge, so the row ends before it rather than under it. */
1141
1196
  .topbar {
1142
1197
  gap: 8px;
1143
- padding: 0 100px 0 4px;
1144
- }
1145
-
1146
- .brand-mark {
1147
- font-size: var(--frock-text-lg);
1198
+ padding: 0 52px 0 4px;
1148
1199
  }
1149
1200
 
1150
1201
  .window-actions {
1151
1202
  padding: 0 8px;
1152
1203
  }
1153
1204
 
1205
+ .brand-mark {
1206
+ font-size: var(--frock-text-lg);
1207
+ }
1208
+
1154
1209
  .nav-toggle {
1155
1210
  flex: 0 0 auto;
1156
1211
  }
@@ -1169,16 +1224,6 @@
1169
1224
  padding: 20px;
1170
1225
  }
1171
1226
 
1172
- .message-bubble,
1173
- .message-user .message-bubble {
1174
- max-width: 92%;
1175
- }
1176
-
1177
- .message-sends,
1178
- .message-tasks {
1179
- width: 92%;
1180
- }
1181
-
1182
1227
  .message-attachments img {
1183
1228
  max-width: 100%;
1184
1229
  }
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { WebChatMessage } from "../shared.js";
3
+ import {
4
+ TRANSCRIPT_CACHE_LIMIT,
5
+ TranscriptCache,
6
+ TRANSCRIPT_FRESH_MS,
7
+ } from "./transcript-cache.js";
8
+
9
+ function message(runId: string, text = "hello"): WebChatMessage {
10
+ return {
11
+ id: `${runId}:user`,
12
+ runId,
13
+ role: "user",
14
+ text,
15
+ status: "completed",
16
+ tools: [],
17
+ sends: [],
18
+ };
19
+ }
20
+
21
+ function snapshot(conversationKey = "bot:1", runId = "run-1") {
22
+ return { conversationKey, messages: [message(runId)] };
23
+ }
24
+
25
+ describe("TranscriptCache", () => {
26
+ test("gives a saved conversation back without a read", () => {
27
+ const cache = new TranscriptCache();
28
+ cache.save("alpha", snapshot());
29
+ const restored = cache.take("alpha", "bot:1");
30
+ expect(restored?.messages.map((entry) => entry.runId)).toEqual(["run-1"]);
31
+ expect(restored?.stale).toBe(false);
32
+ });
33
+
34
+ test("hands back copies, so the caller's edits never reach the cache", () => {
35
+ const cache = new TranscriptCache();
36
+ cache.save("alpha", snapshot());
37
+ const restored = cache.take("alpha", "bot:1");
38
+ restored?.messages.push(message("run-2"));
39
+ const [first] = restored?.messages ?? [];
40
+ if (first) first.text = "rewritten";
41
+ expect(cache.take("alpha", "bot:1")?.messages).toEqual([message("run-1")]);
42
+ });
43
+
44
+ test("a different conversation on the same Bot is a miss, not the old one", () => {
45
+ const cache = new TranscriptCache();
46
+ cache.save("alpha", snapshot("bot:1"));
47
+ // ADR 0027: "new conversation" moves the Bot to a new Session, and the
48
+ // transcript that belonged to the previous one must not come back.
49
+ expect(cache.take("alpha", "bot:1#2")).toBeUndefined();
50
+ // The miss drops it: nothing will ask for that conversation again.
51
+ expect(cache.take("alpha", "bot:1")).toBeUndefined();
52
+ });
53
+
54
+ test("keeps the last N Bots and evicts the least recently used", () => {
55
+ const cache = new TranscriptCache();
56
+ for (let index = 0; index < TRANSCRIPT_CACHE_LIMIT + 2; index += 1) {
57
+ cache.save(`bot-${index}`, snapshot(`key-${index}`, `run-${index}`));
58
+ }
59
+ expect(cache.size).toBe(TRANSCRIPT_CACHE_LIMIT);
60
+ expect(cache.take("bot-0", "key-0")).toBeUndefined();
61
+ expect(cache.take("bot-1", "key-1")).toBeUndefined();
62
+ expect(cache.take("bot-2", "key-2")).toBeDefined();
63
+ });
64
+
65
+ test("reading a transcript makes it the last one evicted", () => {
66
+ const cache = new TranscriptCache({ limit: 2 });
67
+ cache.save("alpha", snapshot("a"));
68
+ cache.save("beta", snapshot("b"));
69
+ // Alpha is the oldest write but the newest use.
70
+ expect(cache.take("alpha", "a")).toBeDefined();
71
+ cache.save("gamma", snapshot("c"));
72
+ expect(cache.take("beta", "b")).toBeUndefined();
73
+ expect(cache.take("alpha", "a")).toBeDefined();
74
+ });
75
+
76
+ test("an empty transcript is not held", () => {
77
+ const cache = new TranscriptCache();
78
+ cache.save("alpha", snapshot());
79
+ cache.save("alpha", { conversationKey: "bot:1", messages: [] });
80
+ expect(cache.size).toBe(0);
81
+ });
82
+
83
+ test("a channel notice leaves the transcript drawable but owes a read", () => {
84
+ const cache = new TranscriptCache();
85
+ cache.save("alpha", snapshot());
86
+ cache.markStale("alpha");
87
+ const restored = cache.take("alpha", "bot:1");
88
+ expect(restored?.messages).toHaveLength(1);
89
+ expect(restored?.stale).toBe(true);
90
+ });
91
+
92
+ test("a transcript past its freshness window owes a read", () => {
93
+ let clock = 1_000;
94
+ const cache = new TranscriptCache({ now: () => clock });
95
+ cache.save("alpha", snapshot());
96
+ clock += TRANSCRIPT_FRESH_MS + 1;
97
+ expect(cache.take("alpha", "bot:1")?.stale).toBe(true);
98
+ });
99
+
100
+ test("forget drops one Bot, or every Bot", () => {
101
+ const cache = new TranscriptCache();
102
+ cache.save("alpha", snapshot("a"));
103
+ cache.save("beta", snapshot("b"));
104
+ cache.forget("alpha");
105
+ expect(cache.take("alpha", "a")).toBeUndefined();
106
+ expect(cache.take("beta", "b")).toBeDefined();
107
+ // Signing out is not "some conversations are stale", it is "none of these
108
+ // are this User's".
109
+ cache.forget();
110
+ expect(cache.size).toBe(0);
111
+ });
112
+
113
+ test("remembers where the reader had the thread", () => {
114
+ const cache = new TranscriptCache();
115
+ cache.save("alpha", snapshot());
116
+ cache.rememberViewport("alpha", { scrollTop: 420, pinnedToLatest: false });
117
+ expect(cache.take("alpha", "bot:1")?.viewport).toEqual({
118
+ scrollTop: 420,
119
+ pinnedToLatest: false,
120
+ });
121
+ // A viewport for a Bot that is not held is dropped rather than resurrecting it.
122
+ cache.rememberViewport("ghost", { scrollTop: 1, pinnedToLatest: true });
123
+ expect(cache.take("ghost", "bot:1")).toBeUndefined();
124
+ });
125
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * What the client keeps of a conversation it is not currently showing.
3
+ *
4
+ * Clicking between Bots used to throw the transcript away and read it back:
5
+ * every switch was an empty thread, a network round trip, and a scroll jump.
6
+ * A Bot's conversation is small, already durable behind it, and cheap to hold,
7
+ * so the last few are kept in memory and redrawn immediately.
8
+ *
9
+ * Two rules keep the cache from lying:
10
+ *
11
+ * - **It is keyed by conversation, not by Bot.** ADR 0027 makes "new
12
+ * conversation" change the Session a Bot's Turns record, so the transcript
13
+ * that belonged to the previous one must not come back under the same key.
14
+ * - **A cached transcript is still revalidated.** The entry carries when it
15
+ * was written; past {@link TRANSCRIPT_FRESH_MS}, or once something has told
16
+ * the client the Bot's runs moved, the restore is followed by a read. Inside
17
+ * that window the click costs nothing, which is the whole point.
18
+ *
19
+ * The cache is memory only and never outlives the page: nothing about one
20
+ * User's conversations reaches the next one through it.
21
+ */
22
+ import type { WebActiveRun, WebChatMessage } from "../shared.ts";
23
+
24
+ /** How many Bots' transcripts are held before the least recent is dropped. */
25
+ export const TRANSCRIPT_CACHE_LIMIT = 8;
26
+
27
+ /**
28
+ * How long a cached transcript is served without a read behind it. Long
29
+ * enough that clicking between Bots is free, short enough that a conversation
30
+ * changed on another device is never stale for long.
31
+ */
32
+ export const TRANSCRIPT_FRESH_MS = 30_000;
33
+
34
+ /** Where the reader had the thread when they switched away. */
35
+ export interface TranscriptViewport {
36
+ scrollTop: number;
37
+ /**
38
+ * True when they were at the end. Restored as "the end" rather than as the
39
+ * pixel offset, so a transcript that grew while they were away comes back
40
+ * pinned to the newest Turn and not to where it used to be.
41
+ */
42
+ pinnedToLatest: boolean;
43
+ }
44
+
45
+ /** One conversation, as the thread last drew it. */
46
+ export interface TranscriptSnapshot {
47
+ /** Distinguishes this conversation from the next one on the same Bot. */
48
+ conversationKey: string;
49
+ messages: WebChatMessage[];
50
+ activeRun?: WebActiveRun;
51
+ activeRunId?: string;
52
+ runningRunId?: string;
53
+ viewport?: TranscriptViewport;
54
+ }
55
+
56
+ interface CacheEntry extends TranscriptSnapshot {
57
+ writtenAt: number;
58
+ stale: boolean;
59
+ }
60
+
61
+ /** A restored transcript, and whether reading it back is still owed. */
62
+ export interface TranscriptRestore extends TranscriptSnapshot {
63
+ /** True when the caller should revalidate before trusting this for long. */
64
+ stale: boolean;
65
+ }
66
+
67
+ export interface TranscriptCacheOptions {
68
+ limit?: number;
69
+ freshMs?: number;
70
+ now?: () => number;
71
+ }
72
+
73
+ /**
74
+ * The last few Bots' conversations, most recently used last.
75
+ *
76
+ * `Map` iteration order is insertion order, so "touch on read" is a delete
77
+ * followed by a set and the first key is always the eviction candidate.
78
+ */
79
+ export class TranscriptCache {
80
+ readonly #entries = new Map<string, CacheEntry>();
81
+ readonly #limit: number;
82
+ readonly #freshMs: number;
83
+ readonly #now: () => number;
84
+
85
+ constructor(options: TranscriptCacheOptions = {}) {
86
+ this.#limit = options.limit ?? TRANSCRIPT_CACHE_LIMIT;
87
+ this.#freshMs = options.freshMs ?? TRANSCRIPT_FRESH_MS;
88
+ this.#now = options.now ?? (() => Date.now());
89
+ }
90
+
91
+ /** The Bots held, least recently used first. */
92
+ get botIds(): readonly string[] {
93
+ return [...this.#entries.keys()];
94
+ }
95
+
96
+ get size(): number {
97
+ return this.#entries.size;
98
+ }
99
+
100
+ /**
101
+ * The transcript for this Bot's current conversation, if it is held.
102
+ *
103
+ * A key mismatch is a miss and drops the entry: the conversation it holds
104
+ * is over, and nothing will ask for it again.
105
+ */
106
+ take(botId: string, conversationKey: string): TranscriptRestore | undefined {
107
+ const entry = this.#entries.get(botId);
108
+ if (!entry) return undefined;
109
+ if (entry.conversationKey !== conversationKey) {
110
+ this.#entries.delete(botId);
111
+ return undefined;
112
+ }
113
+ // Reading is using: this Bot is now the most recent and the last to go.
114
+ this.#entries.delete(botId);
115
+ this.#entries.set(botId, entry);
116
+ return {
117
+ conversationKey: entry.conversationKey,
118
+ // Copies, so the caller's edits never reach back into the cache.
119
+ messages: entry.messages.map((message) => ({ ...message })),
120
+ ...(entry.activeRun ? { activeRun: { ...entry.activeRun } } : {}),
121
+ ...(entry.activeRunId ? { activeRunId: entry.activeRunId } : {}),
122
+ ...(entry.runningRunId ? { runningRunId: entry.runningRunId } : {}),
123
+ ...(entry.viewport ? { viewport: { ...entry.viewport } } : {}),
124
+ stale: entry.stale || this.#now() - entry.writtenAt > this.#freshMs,
125
+ };
126
+ }
127
+
128
+ /** Holds this conversation, evicting the least recently used past the limit. */
129
+ save(botId: string, snapshot: TranscriptSnapshot): void {
130
+ // An empty transcript is not worth a slot: restoring it looks exactly like
131
+ // the read it would have saved.
132
+ if (snapshot.messages.length === 0) {
133
+ this.#entries.delete(botId);
134
+ return;
135
+ }
136
+ // The scroll position is written after the transcript, by the thread that
137
+ // still has it on screen, so a save that carries none keeps the last one.
138
+ const viewport =
139
+ snapshot.viewport ?? this.#entries.get(botId)?.viewport ?? undefined;
140
+ this.#entries.delete(botId);
141
+ this.#entries.set(botId, {
142
+ conversationKey: snapshot.conversationKey,
143
+ messages: snapshot.messages.map((message) => ({ ...message })),
144
+ ...(snapshot.activeRun ? { activeRun: { ...snapshot.activeRun } } : {}),
145
+ ...(snapshot.activeRunId ? { activeRunId: snapshot.activeRunId } : {}),
146
+ ...(snapshot.runningRunId ? { runningRunId: snapshot.runningRunId } : {}),
147
+ ...(viewport ? { viewport: { ...viewport } } : {}),
148
+ writtenAt: this.#now(),
149
+ stale: false,
150
+ });
151
+ while (this.#entries.size > this.#limit) {
152
+ const oldest = this.#entries.keys().next();
153
+ if (oldest.done) break;
154
+ this.#entries.delete(oldest.value);
155
+ }
156
+ }
157
+
158
+ /** Records where the reader had the thread, without touching the transcript. */
159
+ rememberViewport(botId: string, viewport: TranscriptViewport): void {
160
+ const entry = this.#entries.get(botId);
161
+ if (!entry) return;
162
+ entry.viewport = { ...viewport };
163
+ }
164
+
165
+ /** Where the reader had this Bot's thread, if it is still held. */
166
+ viewportFor(botId: string): TranscriptViewport | undefined {
167
+ const viewport = this.#entries.get(botId)?.viewport;
168
+ return viewport ? { ...viewport } : undefined;
169
+ }
170
+
171
+ /**
172
+ * Says this Bot's runs have moved. The transcript still draws immediately —
173
+ * a stale answer beats an empty thread — but a read follows it.
174
+ */
175
+ markStale(botId: string): void {
176
+ const entry = this.#entries.get(botId);
177
+ if (entry) entry.stale = true;
178
+ }
179
+
180
+ /**
181
+ * Drops what is held for this Bot, or for every Bot.
182
+ *
183
+ * Called where the cached transcript would be a lie rather than merely old:
184
+ * archive, delete, rename, and a change of signed-in User.
185
+ */
186
+ forget(botId?: string): void {
187
+ if (botId === undefined) this.#entries.clear();
188
+ else this.#entries.delete(botId);
189
+ }
190
+ }