@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,7 +1,10 @@
1
1
  /// <reference path="../env.d.ts" />
2
2
 
3
3
  import {
4
+ clientFailureDetailV1,
4
5
  decodeExternalAuthorizationUrl,
6
+ presentClientFailureV1,
7
+ serverRefusalMessageV1,
5
8
  type AgentTransport,
6
9
  type ClientAnnouncement,
7
10
  type ClientNotificationIntent,
@@ -11,6 +14,8 @@ import {
11
14
  type ClientTurnEvent,
12
15
  } from "@frockbot/client-core";
13
16
  import { clientSurfaceRegistryKey } from "@frockbot/client-core";
17
+ import { COMPACTED_ANNOUNCEMENT_TEXT_V1 } from "../compaction.js";
18
+ import { readViewerFocusV1, shouldNotifyForBotV1 } from "../focus.js";
14
19
  // Connection mutations use the provider-neutral hosted command contract.
15
20
  import type {
16
21
  ConnectionCommandReceiptV1,
@@ -86,6 +91,7 @@ import {
86
91
  type WebTaskChip,
87
92
  type WebToolActivity,
88
93
  } from "../shared.js";
94
+ import { TranscriptCache } from "./transcript-cache.js";
89
95
  import FrockBotApp from "./FrockBotApp.vue";
90
96
  import PackageEntryTrigger from "./PackageEntryTrigger.vue";
91
97
  import PackageIframeSettings from "./PackageIframeSettings.vue";
@@ -251,6 +257,11 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
251
257
  return {
252
258
  runId: run.runId,
253
259
  status: run.status,
260
+ // Never the backend's own sentence. What arrived here read
261
+ // `Model request "1c7dd68e-…" has no durable provider outcome:` — a
262
+ // UUID and two internal nouns, in the one place a User is told what
263
+ // happened to their reply. The raw text stays on the run for the
264
+ // console; the banner says what it means and offers the one action.
254
265
  message: run.stopRequestedAt
255
266
  ? "Stopping…"
256
267
  : "Something went wrong mid-reply. Try again to pick it up.",
@@ -345,7 +356,15 @@ function assistantMessage(
345
356
  id: `${run.runId}:assistant`,
346
357
  runId: run.runId,
347
358
  role: "assistant",
348
- text: "This reply stopped partway. Try again to continue it.",
359
+ /*
360
+ * A failure is not something the Bot said. The bubble holds the text
361
+ * the model actually produced — often none — and the notice under it
362
+ * says why the Turn ends there: what arrived here read `Model request
363
+ * "1c7dd68e-…" has no durable provider outcome`, in a bubble styled
364
+ * exactly like the Bot speaking.
365
+ */
366
+ text: visibleAssistantText(run),
367
+ notice: "This reply stopped partway. Try again to continue it.",
349
368
  status: "reconciliation-required",
350
369
  tools: toolsFrom(run.events),
351
370
  sends: sendsFrom(run.events),
@@ -375,7 +394,11 @@ function assistantMessage(
375
394
  runId: run.runId,
376
395
  role: "assistant",
377
396
  text: run.responseText,
378
- notice: run.failure ?? "Agent request failed.",
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.",
379
402
  status: "error",
380
403
  tools: toolsFrom(run.events),
381
404
  sends: sendsFrom(run.events),
@@ -388,8 +411,13 @@ function assistantMessage(
388
411
  role: "assistant",
389
412
  text:
390
413
  run.status === "failed"
391
- ? "This Bot couldn't finish its reply. Try again."
414
+ ? visibleAssistantText(run)
392
415
  : visibleAssistantText(run, notification?.body ?? ""),
416
+ // Why the Turn ends there, under whatever it had already said — never as
417
+ // the bubble's own text, which reads as the Bot saying it.
418
+ ...(run.status === "failed"
419
+ ? { notice: "This Bot couldn't finish its reply. Try again." }
420
+ : {}),
393
421
  status: run.status === "failed" ? "error" : "completed",
394
422
  tools: toolsFrom(run.events),
395
423
  sends: sendsFrom(run.events),
@@ -411,7 +439,10 @@ export function projectAnnouncements(
411
439
  id: announcement.announcementId,
412
440
  runId: announcement.announcementId,
413
441
  role: "system",
414
- text: `Renamed to ${announcement.to} by ${announcement.namedBy}`,
442
+ text:
443
+ announcement.type === "conversation/compacted"
444
+ ? COMPACTED_ANNOUNCEMENT_TEXT_V1
445
+ : `Renamed to ${announcement.to} by ${announcement.namedBy}`,
415
446
  at: announcement.at,
416
447
  status: "completed",
417
448
  tools: [],
@@ -420,11 +451,34 @@ export function projectAnnouncements(
420
451
  const index = messages.findIndex(
421
452
  (candidate) => candidate.id === announcement.announcementId,
422
453
  );
423
- if (index >= 0) messages[index] = message;
424
- else messages.push(message);
454
+ // Removed before it is placed, so a marker that already sits in the thread
455
+ // is re-seated rather than frozen wherever the first poll put it.
456
+ if (index >= 0) messages.splice(index, 1);
457
+ messages.splice(announcementIndex(messages, message.at ?? ""), 0, message);
425
458
  }
426
459
  }
427
460
 
461
+ /**
462
+ * Where a system line belongs among the Turns.
463
+ *
464
+ * Only the user message of a Turn carries a timestamp; everything the Bot
465
+ * wrote for that Turn follows it untimed. So an untimed message inherits the
466
+ * last timestamp seen, and the line is placed before the first message that is
467
+ * genuinely later than it. A marker for a range that has scrolled out of the
468
+ * transcript sorts before everything, which puts it at the top of what remains.
469
+ */
470
+ function announcementIndex(
471
+ messages: readonly WebChatMessage[],
472
+ at: string,
473
+ ): number {
474
+ let seen = "";
475
+ for (const [index, candidate] of messages.entries()) {
476
+ if (candidate.at) seen = candidate.at;
477
+ if (seen > at) return index;
478
+ }
479
+ return messages.length;
480
+ }
481
+
428
482
  export function projectDurableRuns(
429
483
  state: DurableRunProjectionState,
430
484
  notifications: readonly ClientNotificationIntent[],
@@ -885,6 +939,26 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
885
939
  * epoch is bumped at the boundary so those answers are dropped.
886
940
  */
887
941
  let conversationGeneration = 0;
942
+ /*
943
+ * The conversations this client is still holding.
944
+ *
945
+ * Switching Bots used to be a blank thread and a read; the last few are now
946
+ * redrawn from memory and read back behind the paint. `transcriptEpochs`
947
+ * names the conversation each entry belongs to: the backend does not tell a
948
+ * client its Session id, but the client is the one that ends a conversation,
949
+ * so counting that action locally is the same boundary (ADR 0027).
950
+ */
951
+ const transcripts = new TranscriptCache();
952
+ const transcriptEpochs = new Map<string, number>();
953
+ const conversationKeyFor = (botId: string): string =>
954
+ `${botId}#${transcriptEpochs.get(botId) ?? 0}`;
955
+ /*
956
+ * The Bot whose first channel reset is already answered by the cache. A
957
+ * socket opening emits an untopiced invalidation meaning "read everything";
958
+ * for a transcript restored moments ago that read is the reload the User
959
+ * asked us to stop doing. A real `runs` notice is never suppressed.
960
+ */
961
+ let restoredWithoutRead: string | undefined;
888
962
  let userSettingsGeneration = 0;
889
963
  let pluginCatalogGeneration = 0;
890
964
  let packageCatalogGeneration = 0;
@@ -1028,11 +1102,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1028
1102
  if (isTerminalRun(run)) return "admitted";
1029
1103
  } catch (error) {
1030
1104
  if (signal.aborted) return "detached";
1031
- reconciliationError = `${
1032
- error instanceof Error
1033
- ? error.message
1034
- : "Couldn't check on your message."
1035
- } Retrying…`;
1105
+ reconciliationError = `${presentClientFailureV1(
1106
+ error,
1107
+ "check on your message",
1108
+ )} Retrying…`;
1036
1109
  web.value.settingsError = reconciliationError;
1037
1110
  }
1038
1111
  const delayMs = uncertainAdmissionDelayMsV1(attempt);
@@ -1084,9 +1157,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1084
1157
  if (isTerminalRun(run)) return;
1085
1158
  } catch (error) {
1086
1159
  if (signal.aborted) return;
1087
- observationError = `${
1088
- error instanceof Error ? error.message : "Couldn't load that reply."
1089
- } Retrying…`;
1160
+ observationError = `${presentClientFailureV1(
1161
+ error,
1162
+ "load that reply",
1163
+ )} Retrying…`;
1090
1164
  web.value.settingsError = observationError;
1091
1165
  }
1092
1166
  await waitForRunLookup(delayMs, signal);
@@ -1141,7 +1215,13 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1141
1215
  web.value.settingsError = "A completed Bot result is waiting to load";
1142
1216
  continue;
1143
1217
  }
1144
- if (document.hidden) {
1218
+ // The open Bot is only *read* while the tab is visible and the window
1219
+ // holds focus; `document.hidden` alone called a visible tab behind
1220
+ // another window "open", and the reply that landed there was never
1221
+ // heard about. One definition, shared with the sidebar's badge.
1222
+ if (
1223
+ shouldNotifyForBotV1(readViewerFocusV1(web.value.activeBotId), botId)
1224
+ ) {
1145
1225
  // One seam: the desktop or mobile notifications Package when the shell
1146
1226
  // exposes it, the web API when it does not.
1147
1227
  const delivery = await showClientNotificationV1({
@@ -1295,6 +1375,16 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1295
1375
  connectionsAvailable: ctx.transport.connectionsAvailable !== false,
1296
1376
  activeBotId: undefined,
1297
1377
  composerContext: undefined,
1378
+ transcripts: {
1379
+ rememberViewport: (botId, viewport) =>
1380
+ transcripts.rememberViewport(botId, viewport),
1381
+ viewportFor: (botId) => transcripts.viewportFor(botId),
1382
+ forget: (botId) => {
1383
+ transcripts.forget(botId);
1384
+ if (botId === undefined || botId === restoredWithoutRead)
1385
+ restoredWithoutRead = undefined;
1386
+ },
1387
+ },
1298
1388
  messages: [],
1299
1389
  pluginCatalog: [],
1300
1390
  packageCatalog: [],
@@ -1326,14 +1416,33 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1326
1416
  admissionObserver?.abort();
1327
1417
  runObserver?.abort();
1328
1418
  selectionGeneration += 1;
1419
+ // The conversation being left is kept, so coming back to it is a paint
1420
+ // and not a reload. The thread writes its scroll position onto this
1421
+ // entry once Vue has flushed, while the DOM still holds it.
1422
+ const leaving = web.value.activeBotId;
1423
+ if (leaving) {
1424
+ transcripts.save(leaving, {
1425
+ conversationKey: conversationKeyFor(leaving),
1426
+ messages: web.value.messages.map((message) => toRaw(message)),
1427
+ ...(web.value.activeRun ? { activeRun: web.value.activeRun } : {}),
1428
+ ...(web.value.activeRunId
1429
+ ? { activeRunId: web.value.activeRunId }
1430
+ : {}),
1431
+ ...(web.value.runningRunId
1432
+ ? { runningRunId: web.value.runningRunId }
1433
+ : {}),
1434
+ });
1435
+ }
1436
+ const restored = transcripts.take(botId, conversationKeyFor(botId));
1437
+ restoredWithoutRead = restored && !restored.stale ? botId : undefined;
1329
1438
  web.value.activeBotId = botId;
1330
1439
  web.value.composerContext = botId;
1331
1440
  web.value.botSettings = undefined;
1332
1441
  web.value.modelReady = false;
1333
- web.value.messages = [];
1334
- web.value.activeRun = undefined;
1335
- web.value.activeRunId = undefined;
1336
- web.value.runningRunId = undefined;
1442
+ web.value.messages = restored?.messages ?? [];
1443
+ web.value.activeRun = restored?.activeRun;
1444
+ web.value.activeRunId = restored?.activeRunId;
1445
+ web.value.runningRunId = restored?.runningRunId;
1337
1446
  web.value.skillCatalog = [];
1338
1447
  web.value.approvals = [];
1339
1448
  web.value.tasks = [];
@@ -1380,6 +1489,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1380
1489
  // Reads already in flight answer with the conversation that just ended;
1381
1490
  // the epoch drops them instead of letting them redraw it.
1382
1491
  conversationGeneration += 1;
1492
+ // The conversation just ended keeps none of this Bot's cache: its key
1493
+ // moves with it, and the transcript behind it is not this one.
1494
+ transcriptEpochs.set(botId, (transcriptEpochs.get(botId) ?? 0) + 1);
1495
+ transcripts.forget(botId);
1496
+ restoredWithoutRead = undefined;
1383
1497
  runObserver?.abort();
1384
1498
  runObserver = undefined;
1385
1499
  web.value.messages = [];
@@ -1793,8 +1907,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1793
1907
  return;
1794
1908
  updateSettingsLoadError(
1795
1909
  "bot",
1796
- error instanceof Error ? error.message : "Could not load settings",
1910
+ presentClientFailureV1(error, "load this Bot's settings"),
1797
1911
  );
1912
+ console.debug("bot settings load failed", clientFailureDetailV1(error));
1798
1913
  }
1799
1914
  },
1800
1915
  async saveBotProfile(profile: BotProfile): Promise<void> {
@@ -1909,7 +2024,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1909
2024
  if (generation !== userSettingsGeneration) return;
1910
2025
  updateSettingsLoadError(
1911
2026
  "user",
1912
- error instanceof Error ? error.message : "Could not load settings",
2027
+ presentClientFailureV1(error, "load your settings"),
2028
+ );
2029
+ console.debug(
2030
+ "user settings load failed",
2031
+ clientFailureDetailV1(error),
1913
2032
  );
1914
2033
  }
1915
2034
  },
@@ -1971,7 +2090,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1971
2090
  if (catalogGeneration !== pluginCatalogGeneration) return;
1972
2091
  updateSettingsLoadError(
1973
2092
  "catalog",
1974
- error instanceof Error ? error.message : "Could not load Plugins",
2093
+ presentClientFailureV1(error, "load your plugins"),
2094
+ );
2095
+ console.debug(
2096
+ "plugin catalog load failed",
2097
+ clientFailureDetailV1(error),
1975
2098
  );
1976
2099
  }
1977
2100
  },
@@ -2088,18 +2211,21 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2088
2211
  // fault, and the raw server sentence means nothing to a person — so it
2089
2212
  // is translated here and the surface renders it instead of the
2090
2213
  // "nothing matched your search" empty state.
2091
- const raw =
2092
- error instanceof Error ? error.message : "Could not load the Catalog";
2214
+ // The one server sentence worth reading is the one that says there is
2215
+ // nothing to read: everything else becomes the shared failure line,
2216
+ // because a raw fault text means nothing to the person looking at it.
2217
+ const detail = clientFailureDetailV1(error);
2093
2218
  web.value.packageCatalog = [];
2094
2219
  web.value.packageCatalogGeneration = undefined;
2095
2220
  updateSettingsLoadError(
2096
2221
  "package-catalog",
2097
2222
  /catalog generation was not found|Package Catalog is not configured/.test(
2098
- raw,
2223
+ detail,
2099
2224
  )
2100
2225
  ? "No plugins are published for this deployment yet."
2101
- : `Plugins could not be loaded: ${raw}`,
2226
+ : presentClientFailureV1(error, "load the plugin catalog"),
2102
2227
  );
2228
+ console.debug("package catalog load failed", detail);
2103
2229
  }
2104
2230
  },
2105
2231
  async loadCatalogEntry(
@@ -2532,10 +2658,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2532
2658
  generation === selectionGeneration &&
2533
2659
  web.value.activeBotId === botId
2534
2660
  )
2535
- web.value.settingsError =
2536
- error instanceof Error
2537
- ? error.message
2538
- : "Notification delivery failed";
2661
+ web.value.settingsError = presentClientFailureV1(
2662
+ error,
2663
+ "show this Bot's notification",
2664
+ );
2539
2665
  }
2540
2666
  return { accepted: true, runId: result.runId };
2541
2667
  } catch (error) {
@@ -2562,10 +2688,12 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2562
2688
  // draft back rather than the thread pretending it was sent.
2563
2689
  if (isCertainSendRefusalV1(error)) {
2564
2690
  removeMessages(web.value.messages, pendingRunId);
2691
+ // A refusal's own sentence is the one the person needs — the send
2692
+ // that was over the size limit is answered with the limit. Only a
2693
+ // refusal carries one; anything else falls back to the shared line.
2565
2694
  const refusal =
2566
- error instanceof Error && error.message
2567
- ? error.message
2568
- : "That message didn't go through. Try sending it again.";
2695
+ serverRefusalMessageV1(error) ??
2696
+ presentClientFailureV1(error, "send that message");
2569
2697
  web.value.error = refusal;
2570
2698
  return { accepted: false, error: refusal };
2571
2699
  }
@@ -2638,22 +2766,28 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2638
2766
  return { accepted: false, error: UNREACHABLE_BOT_MESSAGE_V1 };
2639
2767
  }
2640
2768
  if (disposition === "not-admitted") {
2769
+ // One affordance, not two. This used to be an assistant bubble
2770
+ // *and* a banner, both reading "Turn was not admitted." — the Bot
2771
+ // appearing to say a word the product does not use to a User whose
2772
+ // typing had already been thrown away. The draft comes back to the
2773
+ // composer (`FrockBotApp.sendMessage`), so sending again is the
2774
+ // retry, and one system line says so.
2775
+ const notAdmitted =
2776
+ "Your message didn't go through. Try sending it again.";
2641
2777
  replaceMessage(web.value.messages, pendingRunId, {
2642
2778
  id: `${pendingRunId}:assistant`,
2643
2779
  runId: pendingRunId,
2644
- role: "assistant",
2645
- text: "Your message didn't go through. Try sending it again.",
2780
+ role: "system",
2781
+ text: notAdmitted,
2646
2782
  at: placeholderAt,
2647
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",
2648
2787
  tools: [],
2649
2788
  sends: [],
2650
2789
  });
2651
- web.value.error =
2652
- "Your message didn't go through. Try sending it again.";
2653
- return {
2654
- accepted: false,
2655
- error: "Your message didn't go through. Try sending it again.",
2656
- };
2790
+ return { accepted: false, error: notAdmitted };
2657
2791
  }
2658
2792
  return { accepted: true, runId: pendingRunId };
2659
2793
  } finally {
@@ -2691,16 +2825,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2691
2825
  try {
2692
2826
  await ctx.transport.reconcileRun(botId, runId);
2693
2827
  } catch (error) {
2694
- web.value.settingsError =
2695
- error instanceof Error ? error.message : "Couldn't retry that.";
2828
+ web.value.settingsError = presentClientFailureV1(
2829
+ error,
2830
+ "pick that reply back up",
2831
+ );
2696
2832
  }
2697
2833
  try {
2698
2834
  await deliverNotifications(botId);
2699
2835
  } catch (error) {
2700
- web.value.settingsError =
2701
- error instanceof Error
2702
- ? error.message
2703
- : "Couldn't refresh this reply.";
2836
+ web.value.settingsError = presentClientFailureV1(
2837
+ error,
2838
+ "refresh that reply",
2839
+ );
2704
2840
  }
2705
2841
  },
2706
2842
  async stopRun(): Promise<void> {
@@ -2873,6 +3009,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2873
3009
  web.value.activeBotId !== botId
2874
3010
  )
2875
3011
  return;
3012
+ // An untopiced reset on a transcript this switch restored from
3013
+ // memory is the reload the cache exists to avoid. It is answered
3014
+ // once and only once: the next reset reads like any other.
3015
+ if (topic === undefined && restoredWithoutRead === botId) {
3016
+ restoredWithoutRead = undefined;
3017
+ return;
3018
+ }
3019
+ restoredWithoutRead = undefined;
2876
3020
  await deliverNotifications(botId, generation);
2877
3021
  },
2878
3022
  status() {
@@ -42,11 +42,11 @@ describe("model runtime presentation", () => {
42
42
  modelRuntimeLabel({
43
43
  source: "platform",
44
44
  modelDisplayName: "Auto",
45
- providerModelId: "@flock/auto",
46
- packageDisplayName: "Flock AI",
45
+ providerModelId: "@frock/auto",
46
+ packageDisplayName: "Frock AI",
47
47
  fallback: true,
48
48
  }),
49
- ).toBe("Auto · Flock AI · your chosen model is unavailable");
49
+ ).toBe("Auto · Frock AI · your chosen model is unavailable");
50
50
  });
51
51
 
52
52
  test("shows unavailable and backend failure states", () => {
@@ -20,7 +20,7 @@ const { shellClientPlugin } = await import("./index.js");
20
20
 
21
21
  /**
22
22
  * A first-run account: the platform model resolves against a ready ambient
23
- * Flock AI Connection whose Catalog is fresh, and no Bot has been created yet.
23
+ * Frock AI Connection whose Catalog is fresh, and no Bot has been created yet.
24
24
  * The account's model is available, so the shell must not tell the User it is
25
25
  * unavailable before they have made their first Bot.
26
26
  */
@@ -37,7 +37,7 @@ test("does not report the account model unavailable before a Bot exists", async
37
37
  connectionId: "flock-ai-ambient",
38
38
  packageId: "provider-flock-ai",
39
39
  connectionTypeId: "flock-ai-account",
40
- displayName: "Flock AI",
40
+ displayName: "Frock AI",
41
41
  state: "ready",
42
42
  providerType: "flock-ai",
43
43
  safeMetadata: {},
@@ -47,7 +47,7 @@ test("does not report the account model unavailable before a Bot exists", async
47
47
  state: "fresh",
48
48
  models: [
49
49
  {
50
- providerModelId: "@flock/auto",
50
+ providerModelId: "@frock/auto",
51
51
  displayName: "Auto (recommended)",
52
52
  capabilities: { tools: true, vision: false, reasoning: true },
53
53
  source: "discovered",
@@ -58,7 +58,7 @@ test("does not report the account model unavailable before a Bot exists", async
58
58
  ],
59
59
  platformModel: {
60
60
  connectionId: "flock-ai-ambient",
61
- providerModelId: "@flock/auto",
61
+ providerModelId: "@frock/auto",
62
62
  },
63
63
  };
64
64
 
@@ -74,7 +74,7 @@ test("does not report the account model unavailable before a Bot exists", async
74
74
  packages: [
75
75
  {
76
76
  id: "provider-flock-ai",
77
- displayName: "Flock AI",
77
+ displayName: "Frock AI",
78
78
  version: "0.0.1",
79
79
  contributions: ["backend", "runtime"],
80
80
  configuration: {
@@ -82,7 +82,7 @@ test("does not report the account model unavailable before a Bot exists", async
82
82
  connectionTypes: [
83
83
  {
84
84
  id: "flock-ai-account",
85
- displayName: "Flock AI",
85
+ displayName: "Frock AI",
86
86
  allowMultiple: false,
87
87
  authorization: { kind: "ambient-native" },
88
88
  capabilities: ["flock-ai-models"],
@@ -117,6 +117,6 @@ test("does not report the account model unavailable before a Bot exists", async
117
117
 
118
118
  // No Bot has been created, so `activeBotId` is unset.
119
119
  expect(provided.value.activeBotId).toBeUndefined();
120
- expect(provided.value.modelLabel).toBe("Auto (recommended) · Flock AI");
120
+ expect(provided.value.modelLabel).toBe("Auto (recommended) · Frock AI");
121
121
  expect(provided.value.modelReady).toBe(true);
122
122
  });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
3
3
  import {
4
+ keptSkillHighlightV1,
4
5
  nextSkillHighlightV1,
5
6
  rankSkillCandidatesV1,
6
7
  SkillAttachmentStore,
@@ -140,4 +141,37 @@ describe("keyboard navigation", () => {
140
141
  expect(nextSkillHighlightV1(0, 3, -1)).toBe(2);
141
142
  expect(nextSkillHighlightV1(0, 0, 1)).toBe(0);
142
143
  });
144
+
145
+ test("a refilter keeps the highlight on the Skill it was on", () => {
146
+ const ranked = rankSkillCandidatesV1(catalog, "standup");
147
+ // The composer refreshes the popover on every keyup, the arrow key's own
148
+ // keyup included. Row two has to survive that refresh, or the highlight
149
+ // snaps back to the first row and the arrow keys look dead.
150
+ const highlighted = ranked[1]!.entry.ref;
151
+ expect(keptSkillHighlightV1(highlighted, ranked)).toBe(1);
152
+ });
153
+
154
+ test("a narrower query keeps the highlight when the Skill is still offered", () => {
155
+ const before = rankSkillCandidatesV1(catalog, "s");
156
+ const highlighted = before.find(
157
+ (candidate) => candidate.entry.ref === "bot/standup-notes",
158
+ )!.entry.ref;
159
+ const after = rankSkillCandidatesV1(catalog, "standup-n");
160
+ expect(after.map((candidate) => candidate.entry.ref)).toContain(
161
+ highlighted,
162
+ );
163
+ expect(keptSkillHighlightV1(highlighted, after)).toBe(
164
+ after.findIndex((candidate) => candidate.entry.ref === highlighted),
165
+ );
166
+ });
167
+
168
+ test("falls back to the first row once the highlighted Skill is gone", () => {
169
+ const ranked = rankSkillCandidatesV1(catalog, "standup");
170
+ expect(keptSkillHighlightV1("bot/weekly-report", ranked)).toBe(0);
171
+ expect(keptSkillHighlightV1(undefined, ranked)).toBe(0);
172
+ });
173
+
174
+ test("an empty list has no highlight to keep", () => {
175
+ expect(keptSkillHighlightV1("bot/daily-standup", [])).toBe(0);
176
+ });
143
177
  });
@@ -164,6 +164,28 @@ export class SkillAttachmentStore {
164
164
  }
165
165
  }
166
166
 
167
+ /**
168
+ * The highlight to keep once the candidate list has been recomputed.
169
+ *
170
+ * The popover is refreshed from the composer's own `keyup` — including the
171
+ * `keyup` of the arrow key that has just moved the highlight — so a refresh
172
+ * that reset the highlight to the first row made the arrow keys look like they
173
+ * did nothing at all. The highlight is carried by ref rather than by index: the
174
+ * Skill under it keeps its place for as long as the query still offers it, and
175
+ * only a Skill that has dropped out of the list hands the highlight back to the
176
+ * first row.
177
+ */
178
+ export function keptSkillHighlightV1(
179
+ highlightedRef: string | undefined,
180
+ candidates: readonly SkillCandidateV1[],
181
+ ): number {
182
+ if (candidates.length === 0) return 0;
183
+ const index = candidates.findIndex(
184
+ (candidate) => candidate.entry.ref === highlightedRef,
185
+ );
186
+ return index === -1 ? 0 : index;
187
+ }
188
+
167
189
  /** Moves the popover's highlight, wrapping at both ends. */
168
190
  export function nextSkillHighlightV1(
169
191
  highlighted: number,