@frockbot/plugin-shell 0.3.4 → 0.3.6

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.
@@ -53,7 +53,11 @@ import {
53
53
  import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
54
54
  import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
55
55
  import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
56
- import { decodeClientTurnV1 } from "../run-protocol.js";
56
+ import {
57
+ ClientTurnRefusedErrorV1,
58
+ type ClientTurnRefusalReasonV1,
59
+ decodeClientTurnV1,
60
+ } from "../run-protocol.js";
57
61
  import {
58
62
  decodeApprovalDecisionReceiptV1,
59
63
  decodeApprovalListViewV1,
@@ -240,16 +244,43 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
240
244
  runId: run.runId,
241
245
  status: run.status,
242
246
  message: run.stopRequestedAt
243
- ? "Stop accepted; reconciling the provider outcome before cancelling."
244
- : (run.recovery?.message ??
245
- run.failure ??
246
- "This Turn requires provider reconciliation before it can continue."),
247
- canResume: !run.stopRequestedAt && run.recovery?.action === "resume",
247
+ ? "Stopping…"
248
+ : "Something went wrong mid-reply. Try again to pick it up.",
249
+ // Offered whenever the run is parked, Stop included. Hiding it there
250
+ // hid it in exactly the case Stop creates: a Turn that was stopped
251
+ // while the model was mid-answer parks, and the person was left with a
252
+ // banner and no way to act on it.
253
+ canResume: run.recovery?.action === "resume",
248
254
  };
249
255
  }
250
256
  return undefined;
251
257
  }
252
258
 
259
+ /**
260
+ * What a refused send tells the person. The refusal's own `error` names the
261
+ * durable invariant that declined it, which the debug surface needs and the
262
+ * composer does not, so the typed reason picks the sentence instead.
263
+ */
264
+ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
265
+ if (reason === "busy")
266
+ return "This Bot is still working on your last message.";
267
+ if (reason === "reconciliation-required")
268
+ return "This Bot's last reply stopped partway. Try again to continue it.";
269
+ if (reason === "duplicate") return "That message was already sent.";
270
+ return "That message didn't go through. Try sending it again.";
271
+ }
272
+
273
+ /**
274
+ * The Bot's voice is its sends. When a Turn delivered anything to the User the
275
+ * model's own assistant text is scratch space and the thread does not draw it
276
+ * (issue 153): drawing both is how a one-word reply arrived twice, once as the
277
+ * model's text and once as the bubble that was actually delivered.
278
+ */
279
+ function visibleAssistantText(run: ClientRun, fallback = ""): string {
280
+ if (sendsFrom(run.events).length > 0) return "";
281
+ return run.responseText ?? fallback;
282
+ }
283
+
253
284
  function isTerminalRun(run: ClientRun): boolean {
254
285
  return (
255
286
  run.status === "completed" ||
@@ -270,7 +301,7 @@ function assistantMessage(
270
301
  id: `${run.runId}:assistant`,
271
302
  runId: run.runId,
272
303
  role: "assistant",
273
- text: run.responseText ?? "",
304
+ text: visibleAssistantText(run),
274
305
  status: "streaming",
275
306
  // A Turn that has not started shows nothing of its own: the greyed user
276
307
  // message is the whole of what the thread says about it.
@@ -287,7 +318,8 @@ function assistantMessage(
287
318
  id: `${run.runId}:assistant`,
288
319
  runId: run.runId,
289
320
  role: "assistant",
290
- text: run.failure ?? "Interrupted by your next message.",
321
+ text: visibleAssistantText(run),
322
+ notice: "Interrupted by your next message.",
291
323
  status: "aborted",
292
324
  tools: toolsFrom(run.events),
293
325
  sends: sendsFrom(run.events),
@@ -299,10 +331,7 @@ function assistantMessage(
299
331
  id: `${run.runId}:assistant`,
300
332
  runId: run.runId,
301
333
  role: "assistant",
302
- text:
303
- run.recovery?.message ??
304
- run.failure ??
305
- "Provider reconciliation is required before this Turn can continue.",
334
+ text: "This reply stopped partway. Try again to continue it.",
306
335
  status: "reconciliation-required",
307
336
  tools: toolsFrom(run.events),
308
337
  sends: sendsFrom(run.events),
@@ -314,21 +343,39 @@ function assistantMessage(
314
343
  id: `${run.runId}:assistant`,
315
344
  runId: run.runId,
316
345
  role: "assistant",
317
- text: run.failure ?? "Stopped by an authenticated Stop command.",
346
+ text: visibleAssistantText(run),
347
+ notice: "You stopped this.",
318
348
  status: "aborted",
319
349
  tools: toolsFrom(run.events),
320
350
  sends: sendsFrom(run.events),
321
351
  tasks: tasksFrom(run.events),
322
352
  };
323
353
  }
354
+ // A Turn that broke after it had started talking keeps what it said, with
355
+ // the reason underneath it — the treatment a stopped Turn already gets, for
356
+ // the same reason: the words arrived and the person read them (ADR 0028).
357
+ // A Turn that broke before saying anything is still just the reason.
358
+ if (run.status === "failed" && run.responseText) {
359
+ return {
360
+ id: `${run.runId}:assistant`,
361
+ runId: run.runId,
362
+ role: "assistant",
363
+ text: run.responseText,
364
+ notice: run.failure ?? "Agent request failed.",
365
+ status: "error",
366
+ tools: toolsFrom(run.events),
367
+ sends: sendsFrom(run.events),
368
+ tasks: tasksFrom(run.events),
369
+ };
370
+ }
324
371
  return {
325
372
  id: `${run.runId}:assistant`,
326
373
  runId: run.runId,
327
374
  role: "assistant",
328
375
  text:
329
376
  run.status === "failed"
330
- ? (run.failure ?? "Agent request failed.")
331
- : (run.responseText ?? notification?.body ?? ""),
377
+ ? "This Bot couldn't finish its reply. Try again."
378
+ : visibleAssistantText(run, notification?.body ?? ""),
332
379
  status: run.status === "failed" ? "error" : "completed",
333
380
  tools: toolsFrom(run.events),
334
381
  sends: sendsFrom(run.events),
@@ -420,8 +467,12 @@ export function projectDurableRuns(
420
467
  activeRun = activeRunView(run) ?? activeRun;
421
468
  if (run.status === "running" || run.status === "reconciliation-required") {
422
469
  busyRunId = run.runId;
423
- if (!run.queued) runningRunId = run.runId;
424
470
  }
471
+ // Stop belongs to a Turn that is executing. A Turn parked on a
472
+ // reconciliation is busy but not running: there is nothing to stop, and
473
+ // offering it left a Stop button standing for good — across reloads,
474
+ // because the state it was keyed off never became terminal.
475
+ if (run.status === "running" && !run.queued) runningRunId = run.runId;
425
476
  if (notification && isTerminalRun(run)) {
426
477
  projected.add(notification.notificationId);
427
478
  }
@@ -439,7 +490,13 @@ export function projectDurableRuns(
439
490
  state.activeRunId = undefined;
440
491
  }
441
492
  if (runningRunId) state.runningRunId = runningRunId;
442
- else if (state.runningRunId && terminalRunIds.has(state.runningRunId)) {
493
+ else if (
494
+ state.runningRunId &&
495
+ runs.some((run) => run.runId === state.runningRunId)
496
+ ) {
497
+ // The channel is carrying this run and it is not executing, whatever it
498
+ // settled as. A run the list does not carry yet is the one this tab just
499
+ // submitted, which keeps its Stop.
443
500
  state.runningRunId = undefined;
444
501
  }
445
502
  if (activeRun) state.activeRun = activeRun;
@@ -695,7 +752,7 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
695
752
  !Array.isArray(value.packages) ||
696
753
  value.packages.length > 256
697
754
  ) {
698
- throw new Error("Application manifest is invalid");
755
+ throw new Error("FrockBot couldn't load this deployment. Reload the page.");
699
756
  }
700
757
  return value.packages.flatMap((candidate) => {
701
758
  if (
@@ -728,7 +785,9 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
728
785
  kind === "mobile",
729
786
  )
730
787
  ) {
731
- throw new Error("Application Package metadata is invalid");
788
+ throw new Error(
789
+ "FrockBot couldn't load this deployment. Reload the page.",
790
+ );
732
791
  }
733
792
  const decoded = decodeFrockBotManifest({
734
793
  // v4, so a Capability carrying an admission ceiling decodes here too.
@@ -910,7 +969,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
910
969
  web.value.activeRun = {
911
970
  runId,
912
971
  status: "running",
913
- message: "Confirming whether this Turn was admitted.",
972
+ message: "Checking whether your message went through…",
914
973
  canResume: false,
915
974
  };
916
975
  if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
@@ -950,7 +1009,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
950
1009
  reconciliationError = `${
951
1010
  error instanceof Error
952
1011
  ? error.message
953
- : "Turn admission lookup failed"
1012
+ : "Couldn't check on your message."
954
1013
  } Retrying…`;
955
1014
  web.value.settingsError = reconciliationError;
956
1015
  }
@@ -982,7 +1041,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
982
1041
  ) {
983
1042
  return;
984
1043
  }
985
- if (!run) throw new Error("Stopped Turn is unavailable");
1044
+ if (!run) throw new Error("Couldn't load that reply.");
986
1045
  if (web.value.settingsError === observationError) {
987
1046
  web.value.settingsError = undefined;
988
1047
  }
@@ -992,7 +1051,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
992
1051
  } catch (error) {
993
1052
  if (signal.aborted) return;
994
1053
  observationError = `${
995
- error instanceof Error ? error.message : "Turn lookup failed"
1054
+ error instanceof Error ? error.message : "Couldn't load that reply."
996
1055
  } Retrying…`;
997
1056
  web.value.settingsError = observationError;
998
1057
  }
@@ -1180,7 +1239,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1180
1239
 
1181
1240
  const web: Ref<ShellWebData> = ref({
1182
1241
  connection: "ready",
1183
- modelLabel: "Model unavailable",
1242
+ modelLabel: "No model available — set one up in Models",
1184
1243
  modelReady: false,
1185
1244
  modelSource: "none",
1186
1245
  settingsAvailable: true,
@@ -1510,12 +1569,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1510
1569
  const botId = web.value.activeBotId;
1511
1570
  const catalog = web.value.packageUi;
1512
1571
  if (!post || !botId || !catalog || catalog.botId !== botId) {
1513
- throw new Error("Package UI is unavailable");
1572
+ throw new Error("That plugin's page isn't available.");
1514
1573
  }
1515
1574
  if (!packageIframeToolAllowedV1(contribution, name)) {
1516
- throw new Error(
1517
- `Package "${contribution.packageId}" did not declare tool "${name}"`,
1518
- );
1575
+ throw new Error(`That plugin isn't allowed to use ${name}.`);
1519
1576
  }
1520
1577
  const turn = decodeClientTurnV1(
1521
1578
  await post(
@@ -1942,9 +1999,22 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1942
1999
  updateSettingsLoadError("package-catalog");
1943
2000
  } catch (error) {
1944
2001
  if (generation !== packageCatalogGeneration) return;
2002
+ // The gateway answers 404 `catalog generation was not found` when the
2003
+ // deployment has published no Catalog at all. That is a state, not a
2004
+ // fault, and the raw server sentence means nothing to a person — so it
2005
+ // is translated here and the surface renders it instead of the
2006
+ // "nothing matched your search" empty state.
2007
+ const raw =
2008
+ error instanceof Error ? error.message : "Could not load the Catalog";
2009
+ web.value.packageCatalog = [];
2010
+ web.value.packageCatalogGeneration = undefined;
1945
2011
  updateSettingsLoadError(
1946
2012
  "package-catalog",
1947
- error instanceof Error ? error.message : "Could not load the Catalog",
2013
+ /catalog generation was not found|Package Catalog is not configured/.test(
2014
+ raw,
2015
+ )
2016
+ ? "No plugins are published for this deployment yet."
2017
+ : `Plugins could not be loaded: ${raw}`,
1948
2018
  );
1949
2019
  }
1950
2020
  },
@@ -1974,7 +2044,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1974
2044
  if (!settings || !ctx.transport.executeConfiguration) {
1975
2045
  throw new Error("Plugins are unavailable");
1976
2046
  }
1977
- if (!generation) throw new Error("The Catalog generation is unknown");
2047
+ if (!generation)
2048
+ throw new Error("The catalog isn't loaded yet. Try again in a moment.");
1978
2049
  const receipt = await ctx.transport.executeConfiguration({
1979
2050
  schemaVersion: 1,
1980
2051
  type: "user/install-package",
@@ -2257,8 +2328,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2257
2328
  if (result.status !== "applied") {
2258
2329
  throw new Error(
2259
2330
  result.status === "reconciliation-required"
2260
- ? "Connection revocation requires reconciliation"
2261
- : "Connection revocation failed",
2331
+ ? "Disconnecting didn't finish. Try again."
2332
+ : "Couldn't disconnect that account.",
2262
2333
  );
2263
2334
  }
2264
2335
  },
@@ -2296,6 +2367,12 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2296
2367
  const observed = web.value.activeRunId;
2297
2368
  const supersedes = observed ? { runId: observed } : {};
2298
2369
  web.value.activeRunId = pendingRunId;
2370
+ // Stop is offered for the whole of the Turn the User just started, not
2371
+ // only from the moment a projection happens to arrive. A send that
2372
+ // supersedes a running Turn is the exception: the Turn Stop targets is
2373
+ // still the one executing, and this one is queued behind it. The durable
2374
+ // projection corrects both the instant it arrives.
2375
+ if (!observed) web.value.runningRunId = pendingRunId;
2299
2376
  web.value.error = undefined;
2300
2377
  web.value.messages.push(
2301
2378
  {
@@ -2355,7 +2432,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2355
2432
  id: `${result.runId}:assistant`,
2356
2433
  runId: result.runId,
2357
2434
  role: "assistant",
2358
- text: result.text,
2435
+ // The same rule the durable projection follows: a Turn that
2436
+ // delivered something speaks through its sends, not through the
2437
+ // model's own text (issue 153).
2438
+ text: sendsFrom(result.events).length > 0 ? "" : result.text,
2359
2439
  at: optimisticAt,
2360
2440
  status: "completed",
2361
2441
  tools: toolsFrom(result.events),
@@ -2380,15 +2460,23 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2380
2460
  web.value.activeBotId !== botId
2381
2461
  )
2382
2462
  return { accepted: true, runId: pendingRunId };
2463
+ // A refusal is a normal answer, not an uncertain send: the Bot
2464
+ // declined and said why. Show that, drop the optimistic bubbles, and
2465
+ // let the composer give the person their text back — fencing a run
2466
+ // that was never admitted only threw the reason away.
2467
+ if (error instanceof ClientTurnRefusedErrorV1) {
2468
+ removeMessages(web.value.messages, pendingRunId);
2469
+ const refusal = turnRefusalCopyV1(error.refusal.reason);
2470
+ web.value.error = refusal;
2471
+ return { accepted: false, error: refusal };
2472
+ }
2383
2473
  const aborted =
2384
2474
  error instanceof DOMException && error.name === "AbortError";
2385
2475
  replaceMessage(web.value.messages, pendingRunId, {
2386
2476
  id: `${pendingRunId}:assistant`,
2387
2477
  runId: pendingRunId,
2388
2478
  role: "assistant",
2389
- text: aborted
2390
- ? "Request stopped locally; checking whether it started."
2391
- : "Confirming whether this Turn was admitted.",
2479
+ text: "Checking whether your message went through…",
2392
2480
  at: optimisticAt,
2393
2481
  status: "interrupted",
2394
2482
  tools: [],
@@ -2417,14 +2505,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2417
2505
  id: `${pendingRunId}:assistant`,
2418
2506
  runId: pendingRunId,
2419
2507
  role: "assistant",
2420
- text: "Turn was not admitted.",
2508
+ text: "Your message didn't go through. Try sending it again.",
2421
2509
  at: optimisticAt,
2422
2510
  status: "error",
2423
2511
  tools: [],
2424
2512
  sends: [],
2425
2513
  });
2426
- web.value.error = "Turn was not admitted";
2427
- return { accepted: false, error: "Turn was not admitted" };
2514
+ web.value.error =
2515
+ "Your message didn't go through. Try sending it again.";
2516
+ return {
2517
+ accepted: false,
2518
+ error: "Your message didn't go through. Try sending it again.",
2519
+ };
2428
2520
  }
2429
2521
  return { accepted: true, runId: pendingRunId };
2430
2522
  } finally {
@@ -2435,18 +2527,26 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2435
2527
  ) {
2436
2528
  web.value.activeRunId = undefined;
2437
2529
  }
2530
+ // The optimistic Stop target goes with it: a Turn nobody is running is
2531
+ // not a Turn anybody can stop.
2532
+ if (
2533
+ web.value.runningRunId === pendingRunId &&
2534
+ web.value.activeRunId !== pendingRunId
2535
+ ) {
2536
+ web.value.runningRunId = undefined;
2537
+ }
2438
2538
  }
2439
2539
  },
2440
2540
  async resumeRun(runId: string): Promise<void> {
2441
2541
  if (!ctx.transport.reconcileRun) {
2442
- web.value.settingsError = "Turn reconciliation is unavailable";
2542
+ web.value.settingsError = "Can't retry this right now.";
2443
2543
  return;
2444
2544
  }
2445
2545
  if (web.value.activeRun?.runId !== runId) return;
2446
2546
  web.value.activeRun = {
2447
2547
  runId,
2448
2548
  status: "running",
2449
- message: "Reconciliation requested; checking progress.",
2549
+ message: "Retrying…",
2450
2550
  canResume: false,
2451
2551
  };
2452
2552
  const botId = web.value.activeBotId;
@@ -2455,7 +2555,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2455
2555
  await ctx.transport.reconcileRun(botId, runId);
2456
2556
  } catch (error) {
2457
2557
  web.value.settingsError =
2458
- error instanceof Error ? error.message : "Reconciliation failed";
2558
+ error instanceof Error ? error.message : "Couldn't retry that.";
2459
2559
  }
2460
2560
  try {
2461
2561
  await deliverNotifications(botId);
@@ -2463,7 +2563,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2463
2563
  web.value.settingsError =
2464
2564
  error instanceof Error
2465
2565
  ? error.message
2466
- : "Could not refresh the reconciled Turn";
2566
+ : "Couldn't refresh this reply.";
2467
2567
  }
2468
2568
  },
2469
2569
  async stopRun(): Promise<void> {
@@ -2606,6 +2706,70 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2606
2706
  },
2607
2707
  );
2608
2708
 
2709
+ /*
2710
+ * A running Turn reaches every browser that is looking, not only the one
2711
+ * holding its POST.
2712
+ *
2713
+ * The reply to `POST /api/bots/:bot/turns` is one client's copy of a Turn.
2714
+ * A reload, a second tab, or a dropped request has no such copy, so the
2715
+ * transcript would sit on a spinner until somebody reloaded again. Two
2716
+ * seams close that: the Bot's state channel pushes a `runs` invalidation
2717
+ * whenever the durable run records move, and — for any client that has no
2718
+ * socket — the run is polled to its terminal state. Both end in the same
2719
+ * `GET /api/bots/:bot/turns` projection, so neither invents client state
2720
+ * and the one-bubble-per-send contract is untouched.
2721
+ */
2722
+ let stopRunChannel: (() => void) | undefined;
2723
+ const stopRunChannelWatch = watch(
2724
+ () => web.value.activeBotId,
2725
+ (botId) => {
2726
+ stopRunChannel?.();
2727
+ stopRunChannel = undefined;
2728
+ if (!botId || !ctx.transport.watchBotState) return;
2729
+ const generation = selectionGeneration;
2730
+ stopRunChannel = ctx.transport.watchBotState(botId, {
2731
+ async invalidate(topic) {
2732
+ // A reset carries no topic and means "read everything again".
2733
+ if (topic !== undefined && topic !== "runs") return;
2734
+ if (
2735
+ generation !== selectionGeneration ||
2736
+ web.value.activeBotId !== botId
2737
+ )
2738
+ return;
2739
+ await deliverNotifications(botId, generation);
2740
+ },
2741
+ status() {
2742
+ // The channel's health is not the transcript's: an unavailable
2743
+ // socket falls back to the observation below, which is what a
2744
+ // client without one uses anyway.
2745
+ },
2746
+ });
2747
+ },
2748
+ { immediate: true },
2749
+ );
2750
+
2751
+ const stopRunObservation = watch(
2752
+ () => [web.value.activeBotId, web.value.activeRunId] as const,
2753
+ ([botId, runId]) => {
2754
+ // The send path owns the run it started: its POST is the observation,
2755
+ // and `stopRun` starts its own. This is for every other way a client
2756
+ // finds itself watching a Turn it is not holding open.
2757
+ if (!botId || !runId || activeRequest || runObserver) return;
2758
+ const generation = selectionGeneration;
2759
+ const observer = new AbortController();
2760
+ runObserver = observer;
2761
+ void observeRunUntilTerminal(botId, runId, generation, observer.signal)
2762
+ .catch(() => {
2763
+ // `observeRunUntilTerminal` reports its own failures; a rejection
2764
+ // here would be an unhandled one.
2765
+ })
2766
+ .finally(() => {
2767
+ if (runObserver === observer) runObserver = undefined;
2768
+ });
2769
+ },
2770
+ { immediate: true },
2771
+ );
2772
+
2609
2773
  return [
2610
2774
  ctx.provide(clientSurfaceRegistryKey, surfaces),
2611
2775
  // The shared client projection is updated by the contracts lane. This cast
@@ -2624,6 +2788,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2624
2788
  () => {
2625
2789
  stopEntrySync();
2626
2790
  stopRunFollow();
2791
+ stopRunChannelWatch();
2792
+ stopRunChannel?.();
2793
+ stopRunObservation();
2627
2794
  stopSourceFollow();
2628
2795
  for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
2629
2796
  activeRequest?.abort();
@@ -2644,6 +2811,13 @@ function replaceMessage(
2644
2811
  if (index >= 0) messages[index] = replacement;
2645
2812
  }
2646
2813
 
2814
+ /** Takes back both optimistic lines of a send the Bot never admitted. */
2815
+ function removeMessages(messages: WebChatMessage[], runId: string): void {
2816
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
2817
+ if (messages[index]?.runId === runId) messages.splice(index, 1);
2818
+ }
2819
+ }
2820
+
2647
2821
  export default shellClientPlugin;
2648
2822
 
2649
2823
  /**
@@ -31,12 +31,14 @@ describe("model runtime presentation", () => {
31
31
  "Llama 3 · Ollama Cloud · Account model",
32
32
  );
33
33
  expect(modelRuntimeLabel({ ...label, source: "bot" })).toBe(
34
- "Llama 3 · Ollama Cloud · Bot override",
34
+ "Llama 3 · Ollama Cloud · this Bot only",
35
35
  );
36
36
  });
37
37
 
38
38
  test("shows unavailable and backend failure states", () => {
39
- expect(modelRuntimeLabel({ source: "none" })).toBe("Model unavailable");
39
+ expect(modelRuntimeLabel({ source: "none" })).toBe(
40
+ "No model available — set one up in Models",
41
+ );
40
42
  expect(
41
43
  modelRuntimeLabel({
42
44
  source: "account",
@@ -14,13 +14,13 @@ export function modelRuntimeLabel(input: {
14
14
  }): string {
15
15
  if (input.failure) return input.failure;
16
16
  if (input.source === "none" || !input.providerModelId) {
17
- return "Model unavailable";
17
+ return "No model available — set one up in Models";
18
18
  }
19
19
  const model =
20
20
  input.modelDisplayName ?? input.providerModelId ?? "Connected model";
21
21
  const provider = input.packageDisplayName ?? input.connectionDisplayName;
22
22
  const runtime = provider ? `${model} · ${provider}` : model;
23
- if (input.source === "bot") return `${runtime} · Bot override`;
23
+ if (input.source === "bot") return `${runtime} · this Bot only`;
24
24
  if (input.source === "account") return `${runtime} · Account model`;
25
25
  return runtime;
26
26
  }
@@ -290,13 +290,38 @@
290
290
  text-align: center;
291
291
  }
292
292
 
293
- /* An assistant Turn is its avatar and, once there is text, its bubble. */
293
+ /*
294
+ * Why a Turn ends where it does — stopped, or replaced by a later message.
295
+ * Quiet, and under the answer rather than instead of it: the words the Bot
296
+ * already said are the Turn's content, and this is only its last line.
297
+ */
298
+ .message-notice {
299
+ margin: 2px 0 0;
300
+ color: var(--frock-text-muted);
301
+ font-size: var(--frock-text-xs);
302
+ }
303
+
304
+ /*
305
+ * An assistant Turn is its avatar and, beside it, one column holding
306
+ * everything the Turn produced. The row has exactly two children: bubbles,
307
+ * notices, sends and chips stack inside the column, so a one-word reply is a
308
+ * bubble the width of its word rather than a sliver of a shared row.
309
+ */
294
310
  .message-assistant {
295
311
  flex-direction: row;
296
312
  align-items: flex-start;
297
313
  gap: 8px;
298
314
  }
299
315
 
316
+ .message-column {
317
+ display: flex;
318
+ min-width: 0;
319
+ flex: 1 1 auto;
320
+ flex-direction: column;
321
+ align-items: flex-start;
322
+ gap: 6px;
323
+ }
324
+
300
325
  .bot-avatar {
301
326
  position: relative;
302
327
  display: grid;
@@ -411,6 +436,56 @@
411
436
  width: min(640px, 84%);
412
437
  }
413
438
 
439
+ /*
440
+ * Tool calls. Quieter than anything the Bot said: the User is watching work
441
+ * happen, not reading a message, so a chip carries the name and its state and
442
+ * opens in place to what the tool returned.
443
+ */
444
+
445
+ .message-tools {
446
+ display: flex;
447
+ flex-direction: column;
448
+ gap: 6px;
449
+ width: min(640px, 84%);
450
+ }
451
+
452
+ .tool-chip {
453
+ display: flex;
454
+ flex-wrap: wrap;
455
+ gap: 8px;
456
+ align-items: baseline;
457
+ padding: 6px 10px;
458
+ font: inherit;
459
+ color: var(--frock-text-muted);
460
+ text-align: left;
461
+ cursor: pointer;
462
+ background: var(--frock-surface);
463
+ border: 1px solid var(--frock-border);
464
+ border-radius: 10px;
465
+ }
466
+
467
+ .tool-chip-name {
468
+ color: var(--frock-text);
469
+ font-weight: 600;
470
+ }
471
+
472
+ .tool-chip-status {
473
+ flex: 1 1 auto;
474
+ font-size: var(--frock-text-xs);
475
+ }
476
+
477
+ .tool-chip-failed .tool-chip-status {
478
+ color: var(--frock-danger-text);
479
+ }
480
+
481
+ .tool-chip-result {
482
+ flex: 1 0 100%;
483
+ max-height: 12em;
484
+ overflow: auto;
485
+ white-space: pre-wrap;
486
+ word-break: break-word;
487
+ }
488
+
414
489
  /*
415
490
  * Dispatched subagents. A chip is deliberately quiet — a subagent is work the
416
491
  * Bot delegated, not something it said — and opens in place to the summary the