@frockbot/plugin-shell 0.3.5 → 0.3.7

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.
@@ -55,6 +55,7 @@ import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
55
55
  import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
56
56
  import {
57
57
  ClientTurnRefusedErrorV1,
58
+ type ClientTurnRefusalReasonV1,
58
59
  decodeClientTurnV1,
59
60
  } from "../run-protocol.js";
60
61
  import {
@@ -69,6 +70,8 @@ import { defineComponent, h, ref, toRaw, watch, type Ref } from "vue";
69
70
  import {
70
71
  frockBotWebDataKey,
71
72
  type FrockBotWebData,
73
+ decodeConnectionReturnV1,
74
+ withoutConnectionReturnV1,
72
75
  type PluginCatalogItem,
73
76
  type SendPromptResult,
74
77
  type WebActiveRun,
@@ -243,10 +246,8 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
243
246
  runId: run.runId,
244
247
  status: run.status,
245
248
  message: run.stopRequestedAt
246
- ? "Stop accepted; reconciling the provider outcome before cancelling."
247
- : (run.recovery?.message ??
248
- run.failure ??
249
- "This Turn requires provider reconciliation before it can continue."),
249
+ ? "Stopping…"
250
+ : "Something went wrong mid-reply. Try again to pick it up.",
250
251
  // Offered whenever the run is parked, Stop included. Hiding it there
251
252
  // hid it in exactly the case Stop creates: a Turn that was stopped
252
253
  // while the model was mid-answer parks, and the person was left with a
@@ -257,6 +258,31 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
257
258
  return undefined;
258
259
  }
259
260
 
261
+ /**
262
+ * What a refused send tells the person. The refusal's own `error` names the
263
+ * durable invariant that declined it, which the debug surface needs and the
264
+ * composer does not, so the typed reason picks the sentence instead.
265
+ */
266
+ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
267
+ if (reason === "busy")
268
+ return "This Bot is still working on your last message.";
269
+ if (reason === "reconciliation-required")
270
+ return "This Bot's last reply stopped partway. Try again to continue it.";
271
+ if (reason === "duplicate") return "That message was already sent.";
272
+ return "That message didn't go through. Try sending it again.";
273
+ }
274
+
275
+ /**
276
+ * The Bot's voice is its sends. When a Turn delivered anything to the User the
277
+ * model's own assistant text is scratch space and the thread does not draw it
278
+ * (issue 153): drawing both is how a one-word reply arrived twice, once as the
279
+ * model's text and once as the bubble that was actually delivered.
280
+ */
281
+ function visibleAssistantText(run: ClientRun, fallback = ""): string {
282
+ if (sendsFrom(run.events).length > 0) return "";
283
+ return run.responseText ?? fallback;
284
+ }
285
+
260
286
  function isTerminalRun(run: ClientRun): boolean {
261
287
  return (
262
288
  run.status === "completed" ||
@@ -277,7 +303,7 @@ function assistantMessage(
277
303
  id: `${run.runId}:assistant`,
278
304
  runId: run.runId,
279
305
  role: "assistant",
280
- text: run.responseText ?? "",
306
+ text: visibleAssistantText(run),
281
307
  status: "streaming",
282
308
  // A Turn that has not started shows nothing of its own: the greyed user
283
309
  // message is the whole of what the thread says about it.
@@ -294,8 +320,8 @@ function assistantMessage(
294
320
  id: `${run.runId}:assistant`,
295
321
  runId: run.runId,
296
322
  role: "assistant",
297
- text: run.responseText ?? "",
298
- notice: run.failure ?? "Interrupted by your next message.",
323
+ text: visibleAssistantText(run),
324
+ notice: "Interrupted by your next message.",
299
325
  status: "aborted",
300
326
  tools: toolsFrom(run.events),
301
327
  sends: sendsFrom(run.events),
@@ -307,10 +333,7 @@ function assistantMessage(
307
333
  id: `${run.runId}:assistant`,
308
334
  runId: run.runId,
309
335
  role: "assistant",
310
- text:
311
- run.recovery?.message ??
312
- run.failure ??
313
- "Provider reconciliation is required before this Turn can continue.",
336
+ text: "This reply stopped partway. Try again to continue it.",
314
337
  status: "reconciliation-required",
315
338
  tools: toolsFrom(run.events),
316
339
  sends: sendsFrom(run.events),
@@ -322,22 +345,39 @@ function assistantMessage(
322
345
  id: `${run.runId}:assistant`,
323
346
  runId: run.runId,
324
347
  role: "assistant",
325
- text: run.responseText ?? "",
326
- notice: run.failure ?? "Stopped by an authenticated Stop command.",
348
+ text: visibleAssistantText(run),
349
+ notice: "You stopped this.",
327
350
  status: "aborted",
328
351
  tools: toolsFrom(run.events),
329
352
  sends: sendsFrom(run.events),
330
353
  tasks: tasksFrom(run.events),
331
354
  };
332
355
  }
356
+ // A Turn that broke after it had started talking keeps what it said, with
357
+ // the reason underneath it — the treatment a stopped Turn already gets, for
358
+ // the same reason: the words arrived and the person read them (ADR 0028).
359
+ // A Turn that broke before saying anything is still just the reason.
360
+ if (run.status === "failed" && run.responseText) {
361
+ return {
362
+ id: `${run.runId}:assistant`,
363
+ runId: run.runId,
364
+ role: "assistant",
365
+ text: run.responseText,
366
+ notice: run.failure ?? "Agent request failed.",
367
+ status: "error",
368
+ tools: toolsFrom(run.events),
369
+ sends: sendsFrom(run.events),
370
+ tasks: tasksFrom(run.events),
371
+ };
372
+ }
333
373
  return {
334
374
  id: `${run.runId}:assistant`,
335
375
  runId: run.runId,
336
376
  role: "assistant",
337
377
  text:
338
378
  run.status === "failed"
339
- ? (run.failure ?? "Agent request failed.")
340
- : (run.responseText ?? notification?.body ?? ""),
379
+ ? "This Bot couldn't finish its reply. Try again."
380
+ : visibleAssistantText(run, notification?.body ?? ""),
341
381
  status: run.status === "failed" ? "error" : "completed",
342
382
  tools: toolsFrom(run.events),
343
383
  sends: sendsFrom(run.events),
@@ -429,8 +469,12 @@ export function projectDurableRuns(
429
469
  activeRun = activeRunView(run) ?? activeRun;
430
470
  if (run.status === "running" || run.status === "reconciliation-required") {
431
471
  busyRunId = run.runId;
432
- if (!run.queued) runningRunId = run.runId;
433
472
  }
473
+ // Stop belongs to a Turn that is executing. A Turn parked on a
474
+ // reconciliation is busy but not running: there is nothing to stop, and
475
+ // offering it left a Stop button standing for good — across reloads,
476
+ // because the state it was keyed off never became terminal.
477
+ if (run.status === "running" && !run.queued) runningRunId = run.runId;
434
478
  if (notification && isTerminalRun(run)) {
435
479
  projected.add(notification.notificationId);
436
480
  }
@@ -448,7 +492,13 @@ export function projectDurableRuns(
448
492
  state.activeRunId = undefined;
449
493
  }
450
494
  if (runningRunId) state.runningRunId = runningRunId;
451
- else if (state.runningRunId && terminalRunIds.has(state.runningRunId)) {
495
+ else if (
496
+ state.runningRunId &&
497
+ runs.some((run) => run.runId === state.runningRunId)
498
+ ) {
499
+ // The channel is carrying this run and it is not executing, whatever it
500
+ // settled as. A run the list does not carry yet is the one this tab just
501
+ // submitted, which keeps its Stop.
452
502
  state.runningRunId = undefined;
453
503
  }
454
504
  if (activeRun) state.activeRun = activeRun;
@@ -704,7 +754,7 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
704
754
  !Array.isArray(value.packages) ||
705
755
  value.packages.length > 256
706
756
  ) {
707
- throw new Error("Application manifest is invalid");
757
+ throw new Error("FrockBot couldn't load this deployment. Reload the page.");
708
758
  }
709
759
  return value.packages.flatMap((candidate) => {
710
760
  if (
@@ -737,7 +787,9 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
737
787
  kind === "mobile",
738
788
  )
739
789
  ) {
740
- throw new Error("Application Package metadata is invalid");
790
+ throw new Error(
791
+ "FrockBot couldn't load this deployment. Reload the page.",
792
+ );
741
793
  }
742
794
  const decoded = decodeFrockBotManifest({
743
795
  // v4, so a Capability carrying an admission ceiling decodes here too.
@@ -812,6 +864,15 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
812
864
  let admissionObserver: AbortController | undefined;
813
865
  let runObserver: AbortController | undefined;
814
866
  let selectionGeneration = 0;
867
+ /*
868
+ * Which conversation the transcript is showing.
869
+ *
870
+ * A read that was already in flight when the User starts a new conversation
871
+ * answers with the conversation that just ended, and projecting it puts the
872
+ * old Turns back on a transcript the User has just been told is empty. The
873
+ * epoch is bumped at the boundary so those answers are dropped.
874
+ */
875
+ let conversationGeneration = 0;
815
876
  let userSettingsGeneration = 0;
816
877
  let pluginCatalogGeneration = 0;
817
878
  let packageCatalogGeneration = 0;
@@ -919,7 +980,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
919
980
  web.value.activeRun = {
920
981
  runId,
921
982
  status: "running",
922
- message: "Confirming whether this Turn was admitted.",
983
+ message: "Checking whether your message went through…",
923
984
  canResume: false,
924
985
  };
925
986
  if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
@@ -959,7 +1020,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
959
1020
  reconciliationError = `${
960
1021
  error instanceof Error
961
1022
  ? error.message
962
- : "Turn admission lookup failed"
1023
+ : "Couldn't check on your message."
963
1024
  } Retrying…`;
964
1025
  web.value.settingsError = reconciliationError;
965
1026
  }
@@ -978,6 +1039,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
978
1039
  if (!ctx.transport.lookupRun) return;
979
1040
  let delayMs = 250;
980
1041
  let observationError: string | undefined;
1042
+ const conversation = conversationGeneration;
981
1043
  while (!signal.aborted) {
982
1044
  try {
983
1045
  const run = await observeWhileAttached(
@@ -987,11 +1049,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
987
1049
  if (
988
1050
  signal.aborted ||
989
1051
  generation !== selectionGeneration ||
1052
+ // The Turn belongs to the conversation it was sent in, so a new one
1053
+ // ends the observation rather than drawing it on an empty thread.
1054
+ conversation !== conversationGeneration ||
990
1055
  web.value.activeBotId !== botId
991
1056
  ) {
992
1057
  return;
993
1058
  }
994
- if (!run) throw new Error("Stopped Turn is unavailable");
1059
+ if (!run) throw new Error("Couldn't load that reply.");
995
1060
  if (web.value.settingsError === observationError) {
996
1061
  web.value.settingsError = undefined;
997
1062
  }
@@ -1001,7 +1066,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1001
1066
  } catch (error) {
1002
1067
  if (signal.aborted) return;
1003
1068
  observationError = `${
1004
- error instanceof Error ? error.message : "Turn lookup failed"
1069
+ error instanceof Error ? error.message : "Couldn't load that reply."
1005
1070
  } Retrying…`;
1006
1071
  web.value.settingsError = observationError;
1007
1072
  }
@@ -1014,15 +1079,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1014
1079
  botId: string,
1015
1080
  generation = selectionGeneration,
1016
1081
  ): Promise<void> {
1082
+ const conversation = conversationGeneration;
1083
+ const current = () =>
1084
+ generation === selectionGeneration &&
1085
+ conversation === conversationGeneration &&
1086
+ web.value.activeBotId === botId;
1017
1087
  const runs = await (ctx.transport.listRuns?.(botId) ?? Promise.resolve([]));
1018
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1019
- return;
1088
+ if (!current()) return;
1020
1089
  projectDurableRuns(web.value, [], runs);
1021
1090
  try {
1022
1091
  const announcements = await (ctx.transport.listAnnouncements?.(botId) ??
1023
1092
  Promise.resolve([]));
1024
- if (generation === selectionGeneration && web.value.activeBotId === botId)
1025
- projectAnnouncements(web.value.messages, announcements);
1093
+ if (current()) projectAnnouncements(web.value.messages, announcements);
1026
1094
  } catch {
1027
1095
  // Announcements are conversational history, never admission: a Session
1028
1096
  // that cannot read them still shows every Turn.
@@ -1031,17 +1099,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1031
1099
  try {
1032
1100
  notifications = await (ctx.transport.listNotifications?.(botId) ??
1033
1101
  Promise.resolve([]));
1034
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1035
- return;
1102
+ if (!current()) return;
1036
1103
  } catch (error) {
1037
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1038
- return;
1104
+ if (!current()) return;
1039
1105
  web.value.settingsError =
1040
1106
  error instanceof Error ? error.message : "Could not load notifications";
1041
1107
  return;
1042
1108
  }
1043
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1044
- return;
1109
+ if (!current()) return;
1045
1110
  const projected = projectDurableRuns(web.value, notifications, runs);
1046
1111
  // A decision may have been recorded on another device since the last poll,
1047
1112
  // and an expiry is recorded by an alarm nobody clicked.
@@ -1049,12 +1114,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1049
1114
  // A background subagent settles after its Turn is over, so the chips in
1050
1115
  // the transcript learn what became of it here and not from the run.
1051
1116
  await web.value.loadTasks();
1052
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1053
- return;
1117
+ if (!current()) return;
1054
1118
  if (!ctx.transport.acknowledgeNotification) return;
1055
1119
  for (const notification of notifications) {
1056
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1057
- return;
1120
+ if (!current()) return;
1058
1121
  if (!projected.has(notification.notificationId)) {
1059
1122
  web.value.settingsError = "A completed Bot result is waiting to load";
1060
1123
  continue;
@@ -1187,9 +1250,25 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1187
1250
  ): Promise<void>;
1188
1251
  };
1189
1252
 
1253
+ // Read once, from the URL the authorization redirect landed on, and then
1254
+ // stripped so a reload does not report the same return again.
1255
+ const connectionReturn =
1256
+ typeof window === "undefined"
1257
+ ? undefined
1258
+ : decodeConnectionReturnV1(window.location.search);
1259
+ if (connectionReturn && typeof window !== "undefined") {
1260
+ const rest = withoutConnectionReturnV1(window.location.search);
1261
+ window.history?.replaceState?.(
1262
+ window.history.state,
1263
+ "",
1264
+ `${window.location.pathname}${rest}${window.location.hash}`,
1265
+ );
1266
+ }
1267
+
1190
1268
  const web: Ref<ShellWebData> = ref({
1191
1269
  connection: "ready",
1192
- modelLabel: "Model unavailable",
1270
+ ...(connectionReturn ? { connectionReturn } : {}),
1271
+ modelLabel: "No model available — set one up in Models",
1193
1272
  modelReady: false,
1194
1273
  modelSource: "none",
1195
1274
  settingsAvailable: true,
@@ -1254,6 +1333,41 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1254
1333
  }
1255
1334
  await web.value.loadBotSettings();
1256
1335
  },
1336
+ /**
1337
+ * Puts this conversation down and starts the next one.
1338
+ *
1339
+ * What the Bot knows about you is Memory and stays; what it carries into
1340
+ * the next model request is the new conversation and nothing else. The
1341
+ * transcript clears because it is showing the conversation, and the one
1342
+ * just ended is still durable behind it.
1343
+ */
1344
+ async startConversation(): Promise<void> {
1345
+ const start = ctx.transport.startConversation;
1346
+ const botId = web.value.activeBotId;
1347
+ if (!start || !botId) return;
1348
+ const generation = selectionGeneration;
1349
+ try {
1350
+ await start(botId);
1351
+ } catch (error) {
1352
+ web.value.settingsError =
1353
+ error instanceof Error
1354
+ ? error.message
1355
+ : "Could not start a new conversation";
1356
+ return;
1357
+ }
1358
+ if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1359
+ return;
1360
+ // Reads already in flight answer with the conversation that just ended;
1361
+ // the epoch drops them instead of letting them redraw it.
1362
+ conversationGeneration += 1;
1363
+ runObserver?.abort();
1364
+ runObserver = undefined;
1365
+ web.value.messages = [];
1366
+ web.value.activeRun = undefined;
1367
+ web.value.activeRunId = undefined;
1368
+ web.value.runningRunId = undefined;
1369
+ web.value.settingsError = undefined;
1370
+ },
1257
1371
  async loadSkillCatalog(): Promise<void> {
1258
1372
  // A missing transport method or an unreadable catalog is an empty
1259
1373
  // popover, never a visible error: a Skill list the User did not ask for
@@ -1519,12 +1633,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1519
1633
  const botId = web.value.activeBotId;
1520
1634
  const catalog = web.value.packageUi;
1521
1635
  if (!post || !botId || !catalog || catalog.botId !== botId) {
1522
- throw new Error("Package UI is unavailable");
1636
+ throw new Error("That plugin's page isn't available.");
1523
1637
  }
1524
1638
  if (!packageIframeToolAllowedV1(contribution, name)) {
1525
- throw new Error(
1526
- `Package "${contribution.packageId}" did not declare tool "${name}"`,
1527
- );
1639
+ throw new Error(`That plugin isn't allowed to use ${name}.`);
1528
1640
  }
1529
1641
  const turn = decodeClientTurnV1(
1530
1642
  await post(
@@ -1951,9 +2063,22 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1951
2063
  updateSettingsLoadError("package-catalog");
1952
2064
  } catch (error) {
1953
2065
  if (generation !== packageCatalogGeneration) return;
2066
+ // The gateway answers 404 `catalog generation was not found` when the
2067
+ // deployment has published no Catalog at all. That is a state, not a
2068
+ // fault, and the raw server sentence means nothing to a person — so it
2069
+ // is translated here and the surface renders it instead of the
2070
+ // "nothing matched your search" empty state.
2071
+ const raw =
2072
+ error instanceof Error ? error.message : "Could not load the Catalog";
2073
+ web.value.packageCatalog = [];
2074
+ web.value.packageCatalogGeneration = undefined;
1954
2075
  updateSettingsLoadError(
1955
2076
  "package-catalog",
1956
- error instanceof Error ? error.message : "Could not load the Catalog",
2077
+ /catalog generation was not found|Package Catalog is not configured/.test(
2078
+ raw,
2079
+ )
2080
+ ? "No plugins are published for this deployment yet."
2081
+ : `Plugins could not be loaded: ${raw}`,
1957
2082
  );
1958
2083
  }
1959
2084
  },
@@ -1983,7 +2108,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1983
2108
  if (!settings || !ctx.transport.executeConfiguration) {
1984
2109
  throw new Error("Plugins are unavailable");
1985
2110
  }
1986
- if (!generation) throw new Error("The Catalog generation is unknown");
2111
+ if (!generation)
2112
+ throw new Error("The catalog isn't loaded yet. Try again in a moment.");
1987
2113
  const receipt = await ctx.transport.executeConfiguration({
1988
2114
  schemaVersion: 1,
1989
2115
  type: "user/install-package",
@@ -2266,8 +2392,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2266
2392
  if (result.status !== "applied") {
2267
2393
  throw new Error(
2268
2394
  result.status === "reconciliation-required"
2269
- ? "Connection revocation requires reconciliation"
2270
- : "Connection revocation failed",
2395
+ ? "Disconnecting didn't finish. Try again."
2396
+ : "Couldn't disconnect that account.",
2271
2397
  );
2272
2398
  }
2273
2399
  },
@@ -2370,7 +2496,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2370
2496
  id: `${result.runId}:assistant`,
2371
2497
  runId: result.runId,
2372
2498
  role: "assistant",
2373
- text: result.text,
2499
+ // The same rule the durable projection follows: a Turn that
2500
+ // delivered something speaks through its sends, not through the
2501
+ // model's own text (issue 153).
2502
+ text: sendsFrom(result.events).length > 0 ? "" : result.text,
2374
2503
  at: optimisticAt,
2375
2504
  status: "completed",
2376
2505
  tools: toolsFrom(result.events),
@@ -2401,8 +2530,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2401
2530
  // that was never admitted only threw the reason away.
2402
2531
  if (error instanceof ClientTurnRefusedErrorV1) {
2403
2532
  removeMessages(web.value.messages, pendingRunId);
2404
- web.value.error = error.refusal.error;
2405
- return { accepted: false, error: error.refusal.error };
2533
+ const refusal = turnRefusalCopyV1(error.refusal.reason);
2534
+ web.value.error = refusal;
2535
+ return { accepted: false, error: refusal };
2406
2536
  }
2407
2537
  const aborted =
2408
2538
  error instanceof DOMException && error.name === "AbortError";
@@ -2410,9 +2540,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2410
2540
  id: `${pendingRunId}:assistant`,
2411
2541
  runId: pendingRunId,
2412
2542
  role: "assistant",
2413
- text: aborted
2414
- ? "Request stopped locally; checking whether it started."
2415
- : "Confirming whether this Turn was admitted.",
2543
+ text: "Checking whether your message went through…",
2416
2544
  at: optimisticAt,
2417
2545
  status: "interrupted",
2418
2546
  tools: [],
@@ -2441,14 +2569,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2441
2569
  id: `${pendingRunId}:assistant`,
2442
2570
  runId: pendingRunId,
2443
2571
  role: "assistant",
2444
- text: "Turn was not admitted.",
2572
+ text: "Your message didn't go through. Try sending it again.",
2445
2573
  at: optimisticAt,
2446
2574
  status: "error",
2447
2575
  tools: [],
2448
2576
  sends: [],
2449
2577
  });
2450
- web.value.error = "Turn was not admitted";
2451
- return { accepted: false, error: "Turn was not admitted" };
2578
+ web.value.error =
2579
+ "Your message didn't go through. Try sending it again.";
2580
+ return {
2581
+ accepted: false,
2582
+ error: "Your message didn't go through. Try sending it again.",
2583
+ };
2452
2584
  }
2453
2585
  return { accepted: true, runId: pendingRunId };
2454
2586
  } finally {
@@ -2471,14 +2603,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2471
2603
  },
2472
2604
  async resumeRun(runId: string): Promise<void> {
2473
2605
  if (!ctx.transport.reconcileRun) {
2474
- web.value.settingsError = "Turn reconciliation is unavailable";
2606
+ web.value.settingsError = "Can't retry this right now.";
2475
2607
  return;
2476
2608
  }
2477
2609
  if (web.value.activeRun?.runId !== runId) return;
2478
2610
  web.value.activeRun = {
2479
2611
  runId,
2480
2612
  status: "running",
2481
- message: "Reconciliation requested; checking progress.",
2613
+ message: "Retrying…",
2482
2614
  canResume: false,
2483
2615
  };
2484
2616
  const botId = web.value.activeBotId;
@@ -2487,7 +2619,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2487
2619
  await ctx.transport.reconcileRun(botId, runId);
2488
2620
  } catch (error) {
2489
2621
  web.value.settingsError =
2490
- error instanceof Error ? error.message : "Reconciliation failed";
2622
+ error instanceof Error ? error.message : "Couldn't retry that.";
2491
2623
  }
2492
2624
  try {
2493
2625
  await deliverNotifications(botId);
@@ -2495,7 +2627,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2495
2627
  web.value.settingsError =
2496
2628
  error instanceof Error
2497
2629
  ? error.message
2498
- : "Could not refresh the reconciled Turn";
2630
+ : "Couldn't refresh this reply.";
2499
2631
  }
2500
2632
  },
2501
2633
  async stopRun(): Promise<void> {
@@ -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
  }
@@ -301,13 +301,27 @@
301
301
  font-size: var(--frock-text-xs);
302
302
  }
303
303
 
304
- /* An assistant Turn is its avatar and, once there is text, its bubble. */
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
+ */
305
310
  .message-assistant {
306
311
  flex-direction: row;
307
312
  align-items: flex-start;
308
313
  gap: 8px;
309
314
  }
310
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
+
311
325
  .bot-avatar {
312
326
  position: relative;
313
327
  display: grid;
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeConnectionReturnV1,
4
+ withoutConnectionReturnV1,
5
+ } from "./shared.js";
6
+
7
+ describe("authorization return parameter", () => {
8
+ test("reads the status the callback redirected with", () => {
9
+ expect(decodeConnectionReturnV1("?connection=composio-ready")).toEqual({
10
+ packageId: "composio",
11
+ status: "ready",
12
+ });
13
+ expect(decodeConnectionReturnV1("?connection=composio-pending")).toEqual({
14
+ packageId: "composio",
15
+ status: "pending",
16
+ });
17
+ });
18
+
19
+ test("carries the reason a failed grant came back with", () => {
20
+ expect(
21
+ decodeConnectionReturnV1(
22
+ "?connection=composio-failed&connection_reason=state%20has%20expired",
23
+ ),
24
+ ).toEqual({
25
+ packageId: "composio",
26
+ status: "failed",
27
+ reason: "state has expired",
28
+ });
29
+ });
30
+
31
+ test("ignores a query string that carries no return", () => {
32
+ expect(decodeConnectionReturnV1("")).toBeUndefined();
33
+ expect(decodeConnectionReturnV1("?as_user=someone")).toBeUndefined();
34
+ });
35
+
36
+ test("refuses a malformed or unknown return", () => {
37
+ expect(decodeConnectionReturnV1("?connection=composio")).toBeUndefined();
38
+ expect(
39
+ decodeConnectionReturnV1("?connection=composio-elsewhere"),
40
+ ).toBeUndefined();
41
+ expect(decodeConnectionReturnV1("?connection=-ready")).toBeUndefined();
42
+ expect(
43
+ decodeConnectionReturnV1("?connection=Not%20A%20Package-ready"),
44
+ ).toBeUndefined();
45
+ });
46
+
47
+ test("strips the return parameters and keeps the rest", () => {
48
+ expect(
49
+ withoutConnectionReturnV1(
50
+ "?as_user=someone&connection=composio-failed&connection_reason=nope",
51
+ ),
52
+ ).toBe("?as_user=someone");
53
+ expect(withoutConnectionReturnV1("?connection=composio-ready")).toBe("");
54
+ });
55
+ });