@frockbot/plugin-shell 0.3.12 → 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.
@@ -1526,16 +1526,95 @@ describe("active durable Turn projection", () => {
1526
1526
  ],
1527
1527
  );
1528
1528
 
1529
- expect(state.messages[1]?.sends).toEqual([
1529
+ // One message per send, in the order they were sent, and nothing merged:
1530
+ // the last line is the Turn's own, and it carries no send at all.
1531
+ expect(
1532
+ state.messages.slice(1).map((message) => [message.id, message.sends]),
1533
+ ).toEqual([
1534
+ [
1535
+ "run-send:send:0",
1536
+ [{ kind: "payload", payload: { type: "text", text: "On it." } }],
1537
+ ],
1538
+ [
1539
+ "run-send:send:1",
1540
+ [
1541
+ {
1542
+ kind: "payload",
1543
+ payload: {
1544
+ type: "widget",
1545
+ widget: { prompt: "Which day?", options: ["Tue"] },
1546
+ },
1547
+ },
1548
+ ],
1549
+ ],
1550
+ ["run-send:send:2", [{ kind: "unsupported" }]],
1551
+ ["run-send:assistant", []],
1552
+ ]);
1553
+ });
1554
+
1555
+ test("a second send is a new bubble, and never rewrites the first", () => {
1556
+ const state: Pick<
1557
+ FrockBotWebData,
1558
+ "messages" | "activeRunId" | "activeRun"
1559
+ > = { messages: [] };
1560
+ const run = (
1561
+ events: ClientRun["events"],
1562
+ status: ClientRun["status"] = "running",
1563
+ ): ClientRun => ({
1564
+ runId: "run-stack",
1565
+ input: "Book it",
1566
+ admittedAt: "2026-09-04T00:00:00.000Z",
1567
+ status,
1568
+ events,
1569
+ });
1570
+
1571
+ // The acknowledgement arrives first, on its own.
1572
+ projectDurableRuns(state, [], [
1573
+ run([
1574
+ { type: "send/to-user", payload: { type: "text", text: "On it." } },
1575
+ ]),
1576
+ ] as ClientRun[]);
1577
+ const acknowledgement = state.messages[1];
1578
+ expect(acknowledgement?.sends).toEqual([
1530
1579
  { kind: "payload", payload: { type: "text", text: "On it." } },
1531
- {
1532
- kind: "payload",
1533
- payload: {
1534
- type: "widget",
1535
- widget: { prompt: "Which day?", options: ["Tue"] },
1536
- },
1537
- },
1538
- { kind: "unsupported" },
1580
+ ]);
1581
+
1582
+ // The result follows in the same Turn. It appends: the first bubble is
1583
+ // still the first bubble, still saying what it said.
1584
+ projectDurableRuns(state, [], [
1585
+ run(
1586
+ [
1587
+ {
1588
+ type: "send/to-user",
1589
+ payload: { type: "text", text: "On it." },
1590
+ },
1591
+ { type: "send/to-user", payload: { type: "text", text: "Booked." } },
1592
+ ],
1593
+ "completed",
1594
+ ),
1595
+ ] as ClientRun[]);
1596
+
1597
+ expect(
1598
+ state.messages
1599
+ .filter((message) => message.sends.length > 0)
1600
+ .map((message) => [message.id, message.sends]),
1601
+ ).toEqual([
1602
+ [
1603
+ "run-stack:send:0",
1604
+ [{ kind: "payload", payload: { type: "text", text: "On it." } }],
1605
+ ],
1606
+ [
1607
+ "run-stack:send:1",
1608
+ [{ kind: "payload", payload: { type: "text", text: "Booked." } }],
1609
+ ],
1610
+ ]);
1611
+ // The Turn's lines stay together and in order behind the prompt that
1612
+ // started it, so a reload draws the thread that was watched being written.
1613
+ expect(state.messages.map((message) => message.id)).toEqual([
1614
+ "run-stack:user",
1615
+ "run-stack:send:0",
1616
+ "run-stack:send:1",
1617
+ "run-stack:assistant",
1539
1618
  ]);
1540
1619
  });
1541
1620
 
@@ -1561,8 +1640,8 @@ describe("active durable Turn projection", () => {
1561
1640
  ],
1562
1641
  );
1563
1642
 
1564
- expect(state.messages[1]?.text).toBe("");
1565
1643
  expect(state.messages[1]?.sends).toHaveLength(1);
1644
+ expect(state.messages[2]?.text).toBe("");
1566
1645
  });
1567
1646
 
1568
1647
  test("a Turn that sent nothing still draws the model's text", () => {
@@ -1712,8 +1791,8 @@ describe("active durable Turn projection", () => {
1712
1791
  },
1713
1792
  ],
1714
1793
  );
1715
- expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
1716
1794
  expect(state.messages[1]?.sends).toHaveLength(1);
1795
+ expect(state.messages[2]).toMatchObject({ text: "", status: "streaming" });
1717
1796
  });
1718
1797
 
1719
1798
  test("projects reconciliation-required recovery state", () => {
@@ -3215,11 +3294,14 @@ describe("a message sent while a Turn is running", () => {
3215
3294
  expect(state.messages.every((message) => !message.pending)).toBe(true);
3216
3295
  expect(state.activeRunId).toBe("run-2");
3217
3296
  expect(state.runningRunId).toBe("run-2");
3218
- // The superseded Turn keeps the quiet treatment a stopped one gets.
3219
- expect(state.messages[1]).toMatchObject({
3220
- status: "aborted",
3221
- notice: "Interrupted by your next message.",
3222
- });
3297
+ // The superseded Turn is left unlabelled: the message that replaced it is
3298
+ // right underneath, and it says why the Turn ends there better than a
3299
+ // notice of ours would.
3300
+ expect(state.messages[1]).toMatchObject({ status: "aborted" });
3301
+ expect(state.messages[1]?.notice).toBeUndefined();
3302
+ expect(state.messages.some((message) => message.notice !== undefined)).toBe(
3303
+ false,
3304
+ );
3223
3305
  });
3224
3306
 
3225
3307
  test("a reload reconstructs the greyed state from durable runs alone", () => {
@@ -3254,6 +3336,11 @@ describe("a message sent while a Turn is running", () => {
3254
3336
  text: "second",
3255
3337
  pending: true,
3256
3338
  });
3339
+ // The durable record still says why the Turn ended; the transcript does
3340
+ // not repeat it at the reader.
3341
+ expect(
3342
+ reloaded.messages.some((message) => message.notice !== undefined),
3343
+ ).toBe(false);
3257
3344
  expect(reloaded.runningRunId).toBeUndefined();
3258
3345
  expect(reloaded.activeRunId).toBe("run-2");
3259
3346
  });
@@ -58,6 +58,7 @@ import {
58
58
  import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
59
59
  import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
60
60
  import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
61
+ import { knownFailureCopyV1 } from "../run-failure-copy.js";
61
62
  import {
62
63
  ClientTurnRefusedErrorV1,
63
64
  type ClientTurnRefusalReasonV1,
@@ -293,13 +294,28 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
293
294
  * The Bot's voice is its sends. When a Turn delivered anything to the User the
294
295
  * model's own assistant text is scratch space and the thread does not draw it
295
296
  * (issue 153): drawing both is how a one-word reply arrived twice, once as the
296
- * model's text and once as the bubble that was actually delivered.
297
+ * model's text and once as the bubble that was actually delivered — the Turn's
298
+ * derived text is the last text send when the model wrote no message of its
299
+ * own (`backend-runner.ts`'s `lastSentTextV1`), so it is literally that copy.
297
300
  *
298
301
  * A running Turn has no `responseText` yet — that is written only at
299
302
  * settlement — so it draws the words it has written so far. They occupy the
300
- * same bubble the settled answer will, and the same send gate applies to
301
- * both: a Turn that has already delivered a bubble streams nothing into a
302
- * second one.
303
+ * same bubble the settled answer will, and that bubble is the Turn's own line,
304
+ * which follows the sends rather than replacing any of them.
305
+ *
306
+ * The gate is absolute, and it has to be. Relaxing it to "suppress only text
307
+ * that duplicates a send" looked safer and was not: the model's *last* step
308
+ * routinely writes something of its own after the step that spoke — the e2e
309
+ * that pins this sends "pong" through the tool and then answers again in text —
310
+ * and comparing the two drew both. Two bubbles for one reply is the exact
311
+ * regression issue 153 named.
312
+ *
313
+ * So a Turn that sent anything is drawn entirely from its sends, and text the
314
+ * model wrote beside them is scratch space. That is what makes the promotion in
315
+ * `promoteAssistantTextToSendV1` the right shape: an acknowledgement reaches
316
+ * the person by *becoming* a send — and under the per-send projection it is
317
+ * then its own bubble, in the order it was journaled — rather than being drawn
318
+ * as text next to one.
303
319
  */
304
320
  function visibleAssistantText(run: ClientRun, fallback = ""): string {
305
321
  if (sendsFrom(run.events).length > 0) return "";
@@ -315,6 +331,33 @@ function isTerminalRun(run: ClientRun): boolean {
315
331
  );
316
332
  }
317
333
 
334
+ /**
335
+ * One message per `send_to_user`, in the order the Bot sent them.
336
+ *
337
+ * A Turn is not one bubble. The Bot acknowledges, works, and reports back,
338
+ * and each of those is a message in the conversation exactly as it would be
339
+ * from a person (issue 153). The order is the durable order of the run's
340
+ * events, so the thread a reload draws is the thread that was watched being
341
+ * written, and a bubble is never edited once it is in the transcript: a later
342
+ * send appends, it does not replace.
343
+ */
344
+ function sendMessages(run: ClientRun): WebChatMessage[] {
345
+ return sendsFrom(run.events).map((send, index) => ({
346
+ id: `${run.runId}:send:${index}`,
347
+ runId: run.runId,
348
+ role: "assistant" as const,
349
+ text: "",
350
+ status: "completed" as const,
351
+ tools: [],
352
+ sends: [send],
353
+ }));
354
+ }
355
+
356
+ /**
357
+ * The Turn's own line: the model's words, why the Turn ended where it did,
358
+ * the tools it ran and the subagents it dispatched. It closes the run, under
359
+ * whatever the Turn had already sent.
360
+ */
318
361
  function assistantMessage(
319
362
  run: ClientRun,
320
363
  notification: ClientNotificationIntent | undefined,
@@ -332,22 +375,23 @@ function assistantMessage(
332
375
  // message is the whole of what the thread says about it.
333
376
  ...(run.queued ? { pending: true } : {}),
334
377
  tools: toolsFrom(run.events),
335
- sends: sendsFrom(run.events),
378
+ sends: [],
336
379
  tasks: tasksFrom(run.events),
337
380
  };
338
381
  }
339
382
  if (run.status === "superseded") {
340
- // The same quiet treatment a stopped Turn gets. It keeps everything it
341
- // already sent; the line only says why it ends where it does.
383
+ // Quieter than a stopped Turn: it keeps everything it already sent and
384
+ // carries no notice at all. The message that superseded it is sitting
385
+ // right underneath, in the person's own words, and it explains the ending
386
+ // better than a line of ours would (ADR 0024).
342
387
  return {
343
388
  id: `${run.runId}:assistant`,
344
389
  runId: run.runId,
345
390
  role: "assistant",
346
391
  text: visibleAssistantText(run),
347
- notice: "Interrupted by your next message.",
348
392
  status: "aborted",
349
393
  tools: toolsFrom(run.events),
350
- sends: sendsFrom(run.events),
394
+ sends: [],
351
395
  tasks: tasksFrom(run.events),
352
396
  };
353
397
  }
@@ -367,7 +411,7 @@ function assistantMessage(
367
411
  notice: "This reply stopped partway. Try again to continue it.",
368
412
  status: "reconciliation-required",
369
413
  tools: toolsFrom(run.events),
370
- sends: sendsFrom(run.events),
414
+ sends: [],
371
415
  tasks: tasksFrom(run.events),
372
416
  };
373
417
  }
@@ -380,7 +424,7 @@ function assistantMessage(
380
424
  notice: "You stopped this.",
381
425
  status: "aborted",
382
426
  tools: toolsFrom(run.events),
383
- sends: sendsFrom(run.events),
427
+ sends: [],
384
428
  tasks: tasksFrom(run.events),
385
429
  };
386
430
  }
@@ -394,14 +438,18 @@ function assistantMessage(
394
438
  runId: run.runId,
395
439
  role: "assistant",
396
440
  text: run.responseText,
397
- // The same sentence the reply-less failure gets. The durable failure
398
- // text is a provider's, not the product's `Bot turn ended with
399
- // outcome model-error`, a status code, once a run UUID and under a
400
- // bubble it reads as part of what the Bot was saying.
401
- notice: "This Bot couldn't finish its reply. Try again.",
441
+ // The durable failure text is a provider's, not the product's — `Bot
442
+ // turn ended with outcome model-error`, a status code, once a run UUID —
443
+ // and under a bubble it reads as part of what the Bot was saying. By the
444
+ // time it reaches here it is already the sentence for a person: the
445
+ // projection maps it through `runFailureCopyV1` before it crosses the
446
+ // wire, so this keeps whatever that chose — the model-deadline copy says
447
+ // something the outcome alone cannot — and falls back to the same line a
448
+ // reply-less failure gets.
449
+ notice: knownFailureCopyV1(run.failure),
402
450
  status: "error",
403
451
  tools: toolsFrom(run.events),
404
- sends: sendsFrom(run.events),
452
+ sends: [],
405
453
  tasks: tasksFrom(run.events),
406
454
  };
407
455
  }
@@ -416,11 +464,11 @@ function assistantMessage(
416
464
  // Why the Turn ends there, under whatever it had already said — never as
417
465
  // the bubble's own text, which reads as the Bot saying it.
418
466
  ...(run.status === "failed"
419
- ? { notice: "This Bot couldn't finish its reply. Try again." }
467
+ ? { notice: knownFailureCopyV1(run.failure) }
420
468
  : {}),
421
469
  status: run.status === "failed" ? "error" : "completed",
422
470
  tools: toolsFrom(run.events),
423
- sends: sendsFrom(run.events),
471
+ sends: [],
424
472
  tasks: tasksFrom(run.events),
425
473
  };
426
474
  }
@@ -521,16 +569,12 @@ export function projectDurableRuns(
521
569
  if (userIndex >= 0) state.messages[userIndex] = user;
522
570
  else state.messages.push(user);
523
571
 
524
- const assistantIndex = state.messages.findIndex(
525
- (message) => message.runId === run.runId && message.role === "assistant",
572
+ replaceTurnMessages(
573
+ state.messages,
574
+ run.runId,
575
+ [...sendMessages(run), assistantMessage(run, notification)],
576
+ run.admittedAt,
526
577
  );
527
- const assistant = assistantMessage(run, notification);
528
- const assistantAt =
529
- run.admittedAt ??
530
- (assistantIndex >= 0 ? state.messages[assistantIndex]?.at : undefined);
531
- if (assistantAt) assistant.at = assistantAt;
532
- if (assistantIndex >= 0) state.messages[assistantIndex] = assistant;
533
- else state.messages.push(assistant);
534
578
 
535
579
  activeRun = activeRunView(run) ?? activeRun;
536
580
  if (run.status === "running" || run.status === "reconciliation-required") {
@@ -2638,19 +2682,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2638
2682
  message.runId = result.runId;
2639
2683
  message.id = `${result.runId}:${message.role}`;
2640
2684
  }
2641
- replaceMessage(web.value.messages, result.runId, {
2642
- id: `${result.runId}:assistant`,
2685
+ // Projected exactly as the durable read projects it, from the same
2686
+ // events: one bubble per send, in order, and the model's own text
2687
+ // after them (issue 153). The POST's copy of a Turn and the transcript
2688
+ // read must draw the same thread, or the reply rearranges itself on
2689
+ // the next reload.
2690
+ const settled: ClientRun = {
2643
2691
  runId: result.runId,
2644
- role: "assistant",
2645
- // The same rule the durable projection follows: a Turn that
2646
- // delivered something speaks through its sends, not through the
2647
- // model's own text (issue 153).
2648
- text: sendsFrom(result.events).length > 0 ? "" : result.text,
2649
- at: optimisticAt,
2692
+ input: text,
2650
2693
  status: "completed",
2651
- tools: toolsFrom(result.events),
2652
- sends: sendsFrom(result.events),
2653
- });
2694
+ responseText: result.text,
2695
+ events: result.events,
2696
+ };
2697
+ replaceTurnMessages(
2698
+ web.value.messages,
2699
+ result.runId,
2700
+ [...sendMessages(settled), assistantMessage(settled, undefined)],
2701
+ optimisticAt,
2702
+ );
2654
2703
  try {
2655
2704
  await deliverNotifications(botId, generation);
2656
2705
  } catch (error) {
@@ -2709,16 +2758,23 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2709
2758
  message.runId === pendingRunId && message.role === "user",
2710
2759
  )?.at ?? optimisticAt,
2711
2760
  );
2712
- replaceMessage(web.value.messages, pendingRunId, {
2713
- id: `${pendingRunId}:assistant`,
2714
- runId: pendingRunId,
2715
- role: "assistant",
2716
- text: "Checking whether your message went through…",
2717
- at: placeholderAt,
2718
- status: "interrupted",
2719
- tools: [],
2720
- sends: [],
2721
- });
2761
+ replaceTurnMessages(
2762
+ web.value.messages,
2763
+ pendingRunId,
2764
+ [
2765
+ {
2766
+ id: `${pendingRunId}:assistant`,
2767
+ runId: pendingRunId,
2768
+ role: "assistant",
2769
+ text: "Checking whether your message went through…",
2770
+ at: placeholderAt,
2771
+ status: "interrupted",
2772
+ tools: [],
2773
+ sends: [],
2774
+ },
2775
+ ],
2776
+ placeholderAt,
2777
+ );
2722
2778
  if (aborted) {
2723
2779
  web.value.error = undefined;
2724
2780
  } else {
@@ -2744,17 +2800,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2744
2800
  // have to improvise — and with the Turn no longer running, so Stop
2745
2801
  // stops standing for a Turn nobody is executing.
2746
2802
  if (disposition === "unreachable") {
2747
- replaceMessage(web.value.messages, pendingRunId, {
2748
- id: `${pendingRunId}:assistant`,
2749
- runId: pendingRunId,
2750
- role: "assistant",
2751
- text: UNREACHABLE_BOT_MESSAGE_V1,
2752
- at: placeholderAt,
2753
- status: "error",
2754
- retry: "resend",
2755
- tools: [],
2756
- sends: [],
2757
- });
2803
+ replaceTurnMessages(
2804
+ web.value.messages,
2805
+ pendingRunId,
2806
+ [
2807
+ {
2808
+ id: `${pendingRunId}:assistant`,
2809
+ runId: pendingRunId,
2810
+ role: "assistant",
2811
+ text: UNREACHABLE_BOT_MESSAGE_V1,
2812
+ at: placeholderAt,
2813
+ status: "error",
2814
+ retry: "resend",
2815
+ tools: [],
2816
+ sends: [],
2817
+ },
2818
+ ],
2819
+ placeholderAt,
2820
+ );
2758
2821
  // The bubble is the report, and it is the one carrying the Retry.
2759
2822
  // Saying the same sentence again in the banner above it is what the
2760
2823
  // thread already looked like when it was broken — the same string
@@ -2774,19 +2837,26 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2774
2837
  // retry, and one system line says so.
2775
2838
  const notAdmitted =
2776
2839
  "Your message didn't go through. Try sending it again.";
2777
- replaceMessage(web.value.messages, pendingRunId, {
2778
- id: `${pendingRunId}:assistant`,
2779
- runId: pendingRunId,
2780
- role: "system",
2781
- text: notAdmitted,
2782
- at: placeholderAt,
2783
- status: "error",
2784
- // The same affordance an unreachable send gets: the draft is back
2785
- // in the composer, and this sends it again.
2786
- retry: "resend",
2787
- tools: [],
2788
- sends: [],
2789
- });
2840
+ replaceTurnMessages(
2841
+ web.value.messages,
2842
+ pendingRunId,
2843
+ [
2844
+ {
2845
+ id: `${pendingRunId}:assistant`,
2846
+ runId: pendingRunId,
2847
+ role: "system",
2848
+ text: notAdmitted,
2849
+ at: placeholderAt,
2850
+ status: "error",
2851
+ // The same affordance an unreachable send gets: the draft is
2852
+ // back in the composer, and this sends it again.
2853
+ retry: "resend",
2854
+ tools: [],
2855
+ sends: [],
2856
+ },
2857
+ ],
2858
+ placeholderAt,
2859
+ );
2790
2860
  return { accepted: false, error: notAdmitted };
2791
2861
  }
2792
2862
  return { accepted: true, runId: pendingRunId };
@@ -3081,15 +3151,46 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
3081
3151
  ];
3082
3152
  };
3083
3153
 
3084
- function replaceMessage(
3154
+ /**
3155
+ * Puts a Turn's Bot-side lines in the thread, in order, in one place.
3156
+ *
3157
+ * A Turn is one user message and then however many the Bot sent, so a merge
3158
+ * cannot key one bubble by run id and overwrite it — that is exactly how a
3159
+ * second `send_to_user` used to replace the first. Every Bot-side line the
3160
+ * run already has is lifted out and the new ones go back at the same
3161
+ * position, which keeps the sends in their durable order and keeps the whole
3162
+ * Turn together between the Turn before it and the Turn after it.
3163
+ *
3164
+ * Where a line already carried a timestamp and the caller offers none, the
3165
+ * one it had is kept: the thread sorts by time and a line must not jump.
3166
+ */
3167
+ function replaceTurnMessages(
3085
3168
  messages: WebChatMessage[],
3086
3169
  runId: string,
3087
- replacement: WebChatMessage,
3170
+ replacements: WebChatMessage[],
3171
+ at?: string,
3088
3172
  ): void {
3089
- const index = messages.findIndex(
3090
- (message) => message.runId === runId && message.role === "assistant",
3091
- );
3092
- if (index >= 0) messages[index] = replacement;
3173
+ let start = -1;
3174
+ let existingAt: string | undefined;
3175
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
3176
+ const message = messages[index];
3177
+ if (!message || message.runId !== runId || message.role !== "assistant")
3178
+ continue;
3179
+ existingAt = message.at ?? existingAt;
3180
+ start = index;
3181
+ messages.splice(index, 1);
3182
+ }
3183
+ if (start < 0) {
3184
+ // No Bot-side line yet: it belongs directly after this Turn's prompt, so
3185
+ // an older Turn's reply can never come between them.
3186
+ const userIndex = messages.findIndex(
3187
+ (message) => message.runId === runId && message.role === "user",
3188
+ );
3189
+ start = userIndex >= 0 ? userIndex + 1 : messages.length;
3190
+ }
3191
+ const stamp = at ?? existingAt;
3192
+ if (stamp) for (const message of replacements) message.at = stamp;
3193
+ messages.splice(start, 0, ...replacements);
3093
3194
  }
3094
3195
 
3095
3196
  /** Takes back both optimistic lines of a send the Bot never admitted. */
@@ -360,11 +360,13 @@
360
360
  * notices and sends stack inside it, so a one-word reply is a bubble the width
361
361
  * of its word rather than a sliver of a shared row.
362
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.
363
+ * The avatar is not in that column, and not in the article at all: while the
364
+ * Bot is working it sits on its own row at the very end of the thread, so the
365
+ * trail streaming off its right has the width of the transcript to run through
366
+ * rather than the gutter beside a bubble, and a message sent mid-Turn lands
367
+ * above it. Every reply here is from the same Bot there are no group
368
+ * conversations — so a sheep on every settled line said nothing and cost the
369
+ * column its left edge.
368
370
  */
369
371
  .message-assistant {
370
372
  flex-direction: column;
@@ -383,11 +385,16 @@
383
385
  /*
384
386
  * The working row. Its height is the trail's canvas: tall enough for the
385
387
  * wobble to be visible, short enough that it reads as one line of the thread.
388
+ * It is the thread's last child, so it takes the same top margin every message
389
+ * has and adds no width of its own — the bubbles above never move sideways
390
+ * when it appears or goes.
386
391
  */
387
392
  .bot-working {
388
393
  display: flex;
394
+ width: 100%;
389
395
  height: 44px;
390
396
  align-items: center;
397
+ margin-top: 6px;
391
398
  gap: 0;
392
399
  }
393
400
 
@@ -461,62 +468,6 @@
461
468
  }
462
469
  }
463
470
 
464
- @keyframes frock-halo {
465
- 0%,
466
- 100% {
467
- opacity: 0;
468
- transform: scale(0.94);
469
- }
470
-
471
- 50% {
472
- opacity: 1;
473
- transform: scale(1.06);
474
- }
475
- }
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
471
  /*
521
472
  * A bubble runs nearly the width of the transcript, leaving one clear margin
522
473
  * on the side it is not anchored to: the Bot's at the end, the User's at the
Binary file