@frockbot/plugin-shell 0.3.12 → 0.3.14

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.
@@ -28,6 +28,19 @@ import {
28
28
  type WebToolActivity,
29
29
  } from "../shared.js";
30
30
  import { ComposerDraftStore } from "./composer-draft.js";
31
+ import {
32
+ applyDictationTailV1,
33
+ voiceButtonLabelV1,
34
+ voiceWaveBarsV1,
35
+ VoiceDictationTranscriptV1,
36
+ type VoiceDictationStateV1,
37
+ } from "./voice-dictation.js";
38
+ import {
39
+ startVoiceMicrophoneV1,
40
+ voiceMicrophoneRefusalV1,
41
+ type VoiceMicrophoneV1,
42
+ } from "./voice-microphone.js";
43
+ import type { VoiceDictationSessionV1 } from "@frockbot/client-core";
31
44
  import {
32
45
  activityTrailBeginV1,
33
46
  activityTrailSampleV1,
@@ -372,6 +385,149 @@ const canSend = computed(
372
385
  */
373
386
  const showStop = computed(() => isRunning.value && !canSend.value);
374
387
 
388
+ /*
389
+ * Dictation (voice plan D4).
390
+ *
391
+ * The send button's slot already switches between Send and Stop, and this is
392
+ * the third thing it holds: with nothing to send and nothing to stop, the
393
+ * button offers to listen instead. While it is listening the slot carries a
394
+ * bin and a send, because those are the only two things a person wants next.
395
+ *
396
+ * Nothing here is a second composer. Speech lands in the same draft the
397
+ * keyboard writes to, through the same `ComposerDraftStore`, and Send is the
398
+ * ordinary `sendMessage` — so a message that is half spoken and half typed
399
+ * behaves exactly like one that is entirely typed, including a refusal that
400
+ * gives the draft back.
401
+ */
402
+ const voiceState = ref<VoiceDictationStateV1>("idle");
403
+ const voiceError = ref<string | undefined>(undefined);
404
+ const voiceBars = ref<number[]>(voiceWaveBarsV1(0, []));
405
+ const voiceTranscript = new VoiceDictationTranscriptV1();
406
+ let voiceSession: VoiceDictationSessionV1 | undefined;
407
+ let voiceMicrophone: VoiceMicrophoneV1 | undefined;
408
+ /** The exact text dictation last wrote, so a typed edit around it survives. */
409
+ let voiceTail = "";
410
+ /** Send was pressed; the last transcript is what we are waiting for. */
411
+ let voiceSendOnFinal = false;
412
+
413
+ const dictating = computed(() => voiceState.value !== "idle");
414
+ const voiceButtonLabel = computed(() => voiceButtonLabelV1(voiceState.value));
415
+ /**
416
+ * The wave button takes the slot only when the slot is otherwise idle: an
417
+ * empty draft, no Turn to stop, and a platform that can actually listen.
418
+ */
419
+ const showVoiceButton = computed(
420
+ () =>
421
+ state.value.voiceAvailable &&
422
+ !dictating.value &&
423
+ !showStop.value &&
424
+ draftText.value.length === 0,
425
+ );
426
+
427
+ function writeDictationIntoDraft(): void {
428
+ const applied = applyDictationTailV1(
429
+ draft.value,
430
+ voiceTail,
431
+ voiceTranscript.text(),
432
+ );
433
+ voiceTail = applied.tail;
434
+ draft.value = applied.draft;
435
+ void nextTick(syncComposerHeight);
436
+ }
437
+
438
+ async function startDictation(): Promise<void> {
439
+ if (dictating.value || !state.value.voiceAvailable) return;
440
+ voiceError.value = undefined;
441
+ voiceTranscript.reset();
442
+ voiceTail = "";
443
+ voiceSendOnFinal = false;
444
+ voiceBars.value = voiceWaveBarsV1(0, []);
445
+ voiceState.value = "starting";
446
+ const session = web.value.openVoiceDictation({
447
+ ready: () => {
448
+ if (voiceState.value === "starting") voiceState.value = "listening";
449
+ },
450
+ delta: (text) => {
451
+ voiceTranscript.delta(text);
452
+ writeDictationIntoDraft();
453
+ },
454
+ transcript: (text) => {
455
+ voiceTranscript.settle(text);
456
+ writeDictationIntoDraft();
457
+ },
458
+ final: () => {
459
+ void completeDictation();
460
+ },
461
+ failed: (message) => {
462
+ voiceError.value = message;
463
+ void stopDictation();
464
+ },
465
+ closed: () => {
466
+ // A socket that goes away mid-capture leaves the draft exactly where it
467
+ // is; the person can still type the rest and send it.
468
+ if (dictating.value) void stopDictation();
469
+ },
470
+ });
471
+ if (!session) {
472
+ voiceState.value = "idle";
473
+ voiceError.value = "Dictation isn't available on this device.";
474
+ return;
475
+ }
476
+ voiceSession = session;
477
+ try {
478
+ voiceMicrophone = await startVoiceMicrophoneV1({
479
+ audio: (pcm16) => session.sendAudio(pcm16),
480
+ level: (value) => {
481
+ voiceBars.value = voiceWaveBarsV1(value, voiceBars.value);
482
+ },
483
+ });
484
+ } catch (error) {
485
+ voiceError.value = voiceMicrophoneRefusalV1(error);
486
+ await stopDictation();
487
+ }
488
+ }
489
+
490
+ /** Everything captured has been transcribed; send it if that is why we stopped. */
491
+ async function completeDictation(): Promise<void> {
492
+ const send = voiceSendOnFinal;
493
+ await stopDictation();
494
+ if (send) await sendMessage();
495
+ }
496
+
497
+ async function stopDictation(): Promise<void> {
498
+ voiceState.value = "idle";
499
+ voiceSendOnFinal = false;
500
+ voiceBars.value = voiceWaveBarsV1(0, []);
501
+ const microphone = voiceMicrophone;
502
+ const session = voiceSession;
503
+ voiceMicrophone = undefined;
504
+ voiceSession = undefined;
505
+ await microphone?.stop();
506
+ session?.close();
507
+ }
508
+
509
+ /** The bin. Discards what was dictated, exactly as D4 says it does. */
510
+ function discardDictation(): void {
511
+ voiceSession?.cancel();
512
+ voiceTranscript.reset();
513
+ voiceTail = "";
514
+ draft.value = "";
515
+ voiceError.value = undefined;
516
+ void stopDictation();
517
+ void nextTick(syncComposerHeight);
518
+ }
519
+
520
+ /**
521
+ * Send, mid-dictation. The audio is committed and the message waits for the
522
+ * last transcript rather than sending half a sentence.
523
+ */
524
+ function sendDictation(): void {
525
+ if (voiceState.value !== "listening") return;
526
+ voiceSendOnFinal = true;
527
+ voiceState.value = "finishing";
528
+ voiceSession?.commit();
529
+ }
530
+
375
531
  /*
376
532
  * Tool activity is internal to the Turn. A Turn that produced only tool calls
377
533
  * shows the Bot avatar while it runs and nothing once it finishes with no
@@ -542,16 +698,18 @@ let trailRunId: string | undefined;
542
698
  let trailSeq = 0;
543
699
  let trailTick = 0;
544
700
 
545
- /** The one Turn still going, if any. Only one runs at a time. */
546
- const workingMessage = computed(() =>
547
- messages.value.find(
701
+ /**
702
+ * The Turn still going, if any. Only one executes at a time, but a Turn queued
703
+ * behind it is streaming-shaped too and has produced nothing yet, so the
704
+ * executing one wins: the trail keeps reading the words that are arriving
705
+ * rather than restarting on a Turn that has not begun.
706
+ */
707
+ const workingMessage = computed(() => {
708
+ const streaming = messages.value.filter(
548
709
  (message) => message.role === "assistant" && message.status === "streaming",
549
- ),
550
- );
551
-
552
- function isWorking(message: WebChatMessage): boolean {
553
- return message.id === workingMessage.value?.id;
554
- }
710
+ );
711
+ return streaming.find((message) => !message.pending) ?? streaming.at(-1);
712
+ });
555
713
 
556
714
  const workingSample = computed(() => {
557
715
  const message = workingMessage.value;
@@ -559,7 +717,16 @@ const workingSample = computed(() => {
559
717
  return activityTrailSampleV1({
560
718
  text: message.text,
561
719
  toolStatuses: message.tools.map((tool) => tool.status),
562
- sends: message.sends.length,
720
+ // Counted across the whole Turn, because every send the Bot delivers is a
721
+ // message of its own and the working line carries none of them. A send is
722
+ // still a beat the trail has to feel.
723
+ sends: messages.value.reduce(
724
+ (total, candidate) =>
725
+ candidate.runId === message.runId
726
+ ? total + candidate.sends.length
727
+ : total,
728
+ 0,
729
+ ),
563
730
  status: message.status,
564
731
  });
565
732
  });
@@ -807,6 +974,9 @@ onBeforeUnmount(() => {
807
974
  window.removeEventListener("hashchange", applySettingsDeepLink);
808
975
  phoneLayoutMedia?.removeEventListener("change", onPhoneLayoutChange);
809
976
  window.removeEventListener("keydown", onRootKeydown);
977
+ // A microphone outlives a component that stops drawing it unless it is told
978
+ // not to, and a browser shows the recording indicator for as long as it does.
979
+ void stopDictation();
810
980
  });
811
981
 
812
982
  watch(
@@ -1047,8 +1217,20 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1047
1217
  }
1048
1218
  }
1049
1219
  }
1220
+ if (event.key === "Escape" && dictating.value) {
1221
+ // Escape is the keyboard's bin, the same as it is for the Skill popover.
1222
+ event.preventDefault();
1223
+ discardDictation();
1224
+ return;
1225
+ }
1050
1226
  if (event.key !== "Enter" || event.shiftKey) return;
1051
1227
  event.preventDefault();
1228
+ // Enter means send either way; mid-dictation it commits the audio first so
1229
+ // the last word spoken is in the message.
1230
+ if (dictating.value) {
1231
+ sendDictation();
1232
+ return;
1233
+ }
1052
1234
  void sendMessage();
1053
1235
  }
1054
1236
  </script>
@@ -1183,23 +1365,6 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1183
1365
  <div v-if="message.text" class="message-bubble">
1184
1366
  <UiMarkdown :text="message.text" />
1185
1367
  </div>
1186
- <!--
1187
- The working state. Until the model has produced a word there
1188
- was nothing beside the avatar at all, for twenty seconds and
1189
- occasionally for two minutes, and the only other signal — the
1190
- composer's stop button — is at the far end of the window from
1191
- where the reply will appear.
1192
- -->
1193
- <div
1194
- v-else-if="message.status === 'streaming'"
1195
- class="message-bubble message-working"
1196
- role="status"
1197
- aria-label="Working on a reply"
1198
- >
1199
- <span class="working-dots" aria-hidden="true">
1200
- <i></i><i></i><i></i>
1201
- </span>
1202
- </div>
1203
1368
  <!--
1204
1369
  Why the Turn ends where it does, under whatever it had
1205
1370
  already said rather than in place of it.
@@ -1324,49 +1489,55 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1324
1489
  </button>
1325
1490
  </div>
1326
1491
  </div>
1327
- <!--
1328
- The working row: the Bot's own avatar on its own line at the
1329
- end of the thread, with the comet trail streaming off to its
1330
- right. It appears only while the Turn is running. Every line in
1331
- this transcript is from the same Bot — there are no group
1332
- conversations yet (issue 152) — so a sheep beside a settled
1333
- reply named nobody the reader did not already know; the one
1334
- under a running Turn is the whole account of what the Bot is
1335
- doing.
1336
-
1337
- The art comes from whichever Package owns Bot identity; when no
1338
- Package fills the slot the sparkle tile is the only child and
1339
- shows through. The row carries the status role, and the canvas
1340
- beside it is hidden from assistive technology: the trail is a
1341
- picture of a fact the label already states.
1342
- -->
1343
- <Transition name="bot-working">
1344
- <div
1345
- v-if="isWorking(message)"
1346
- class="bot-working"
1347
- role="status"
1348
- aria-label="Working"
1349
- >
1350
- <div
1351
- class="bot-avatar bot-avatar-live"
1352
- :class="{ 'bot-avatar-waiting': !message.text }"
1353
- >
1354
- <span class="bot-avatar-fallback" aria-hidden="true"
1355
- ><UiIcon name="sparkle" size="sm"
1356
- /></span>
1357
- <k-slot name="frockbot.bot-avatar" />
1358
- </div>
1359
- <UiActivityTrail
1360
- class="bot-working-indicator"
1361
- :rate="trailRate"
1362
- :bursts="trailBursts"
1363
- :state="trailState"
1364
- />
1365
- </div>
1366
- </Transition>
1367
1492
  </template>
1368
1493
  <div v-else class="message-bubble">{{ message.text }}</div>
1369
1494
  </article>
1495
+ <!--
1496
+ The working row: the Bot's own avatar on its own line at the end of
1497
+ the thread, with the comet trail streaming off to its right. It
1498
+ appears only while a Turn is running. Every line in this transcript
1499
+ is from the same Bot — there are no group conversations yet (issue
1500
+ 152) — so a sheep beside a settled reply named nobody the reader did
1501
+ not already know; the one under a running Turn is the whole account
1502
+ of what the Bot is doing.
1503
+
1504
+ It is a child of the thread rather than of the running Turn's
1505
+ article, so it is always the last thing in the transcript. Send a
1506
+ message while the Bot is still winding down and the new message
1507
+ lands above the sheep, where a reader looking at the bottom of the
1508
+ thread expects the newest thing to be — not underneath a running
1509
+ Turn that is already over.
1510
+
1511
+ The art comes from whichever Package owns Bot identity; when no
1512
+ Package fills the slot the sparkle tile is the only child and shows
1513
+ through. The row carries the status role, and the canvas beside it
1514
+ is hidden from assistive technology: the trail is a picture of a
1515
+ fact the label already states.
1516
+ -->
1517
+ <Transition name="bot-working">
1518
+ <div
1519
+ v-if="workingMessage"
1520
+ class="bot-working"
1521
+ role="status"
1522
+ aria-label="Working"
1523
+ >
1524
+ <div
1525
+ class="bot-avatar bot-avatar-live"
1526
+ :class="{ 'bot-avatar-waiting': !workingMessage.text }"
1527
+ >
1528
+ <span class="bot-avatar-fallback" aria-hidden="true"
1529
+ ><UiIcon name="sparkle" size="sm"
1530
+ /></span>
1531
+ <k-slot name="frockbot.bot-avatar" />
1532
+ </div>
1533
+ <UiActivityTrail
1534
+ class="bot-working-indicator"
1535
+ :rate="trailRate"
1536
+ :bursts="trailBursts"
1537
+ :state="trailState"
1538
+ />
1539
+ </div>
1540
+ </Transition>
1370
1541
  </section>
1371
1542
 
1372
1543
  <Transition name="banner">
@@ -1492,6 +1663,36 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1492
1663
  >
1493
1664
  {{ draftCounterLabel }}
1494
1665
  </p>
1666
+ <!--
1667
+ The capture animation, and the only place the state is written
1668
+ out. It is level-driven from the microphone: bars that move while
1669
+ the room is silent would say the microphone works when it does
1670
+ not.
1671
+ -->
1672
+ <p
1673
+ v-if="dictating"
1674
+ class="voice-capture"
1675
+ role="status"
1676
+ aria-live="polite"
1677
+ >
1678
+ <span class="voice-wave" aria-hidden="true">
1679
+ <span
1680
+ v-for="(bar, index) in voiceBars"
1681
+ :key="index"
1682
+ class="voice-wave-bar"
1683
+ :style="{ transform: `scaleY(${bar})` }"
1684
+ />
1685
+ </span>
1686
+ <span>{{ voiceButtonLabel }}</span>
1687
+ </p>
1688
+ <!--
1689
+ Why dictation stopped, in the words the server or the browser
1690
+ used. It sits under the draft it could not add to, and the draft
1691
+ itself is untouched.
1692
+ -->
1693
+ <p v-if="voiceError" class="voice-error" role="alert">
1694
+ {{ voiceError }}
1695
+ </p>
1495
1696
  </div>
1496
1697
  <!--
1497
1698
  Start a new conversation. Sits beside the composer because that is
@@ -1506,14 +1707,44 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1506
1707
  :disabled="isRunning"
1507
1708
  @click="web.startConversation()"
1508
1709
  />
1710
+ <!--
1711
+ The send slot, and its four states. Dictation replaces the one
1712
+ button with two, because while it is listening the only two things
1713
+ worth offering are "throw this away" and "that's the message".
1714
+ -->
1715
+ <template v-if="dictating">
1716
+ <UiIconButton
1717
+ class="voice-discard-button"
1718
+ icon="trash"
1719
+ label="Discard dictation"
1720
+ variant="ghost"
1721
+ @click="discardDictation"
1722
+ />
1723
+ <UiIconButton
1724
+ class="voice-send-button"
1725
+ icon="arrow-up"
1726
+ label="Send dictated message"
1727
+ variant="primary"
1728
+ :disabled="voiceState !== 'listening'"
1729
+ @click="sendDictation"
1730
+ />
1731
+ </template>
1509
1732
  <UiIconButton
1510
- v-if="showStop"
1733
+ v-else-if="showStop"
1511
1734
  class="stop-button"
1512
1735
  icon="stop"
1513
1736
  label="Stop generating"
1514
1737
  variant="primary"
1515
1738
  @click="web.stopRun()"
1516
1739
  />
1740
+ <UiIconButton
1741
+ v-else-if="showVoiceButton"
1742
+ class="voice-button"
1743
+ icon="waveform"
1744
+ :label="voiceButtonLabel"
1745
+ variant="primary"
1746
+ @click="startDictation"
1747
+ />
1517
1748
  <UiIconButton
1518
1749
  v-else
1519
1750
  type="submit"
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ appletSourceFingerprintV1,
3
4
  mostRecentlyChangedFileV1,
4
5
  readAppletList,
5
6
  readAppletSource,
@@ -119,6 +120,67 @@ describe("Applet routes", () => {
119
120
  }),
120
121
  ).toBe("ui.tsx");
121
122
  });
123
+
124
+ test("the source fingerprint is the same for a re-read of the same files", () => {
125
+ // The canvas moves the User to the code when a Turn *writes* source. It
126
+ // re-reads the source on every poll and gets a fresh view object each
127
+ // time, so the identity that decides has to be the files themselves —
128
+ // otherwise a Turn that only called an Applet's own tool throws the User
129
+ // off the live Applet.
130
+ const file = {
131
+ path: "server.ts",
132
+ text: "export default class {}",
133
+ generationId: "w-1",
134
+ changedAt: "2026-09-03T00:01:00.000Z",
135
+ };
136
+ const view = {
137
+ appletId: "u1abc.todo",
138
+ truncated: false,
139
+ files: [file, { ...file, path: "ui.tsx", generationId: "w-2" }],
140
+ };
141
+ expect(appletSourceFingerprintV1(view)).toBe(
142
+ appletSourceFingerprintV1(structuredClone(view)),
143
+ );
144
+ // Order is not a change either: two reads may sort the store differently.
145
+ expect(
146
+ appletSourceFingerprintV1({ ...view, files: view.files.toReversed() }),
147
+ ).toBe(appletSourceFingerprintV1(view));
148
+ expect(appletSourceFingerprintV1(undefined)).toBe("");
149
+ });
150
+
151
+ test("the source fingerprint changes when a Turn writes a file", () => {
152
+ const view = {
153
+ appletId: "u1abc.todo",
154
+ truncated: false,
155
+ files: [
156
+ {
157
+ path: "server.ts",
158
+ text: "",
159
+ generationId: "w-1",
160
+ changedAt: "2026-09-03T00:01:00.000Z",
161
+ },
162
+ ],
163
+ };
164
+ expect(
165
+ appletSourceFingerprintV1({
166
+ ...view,
167
+ files: [
168
+ {
169
+ ...view.files[0]!,
170
+ generationId: "w-2",
171
+ changedAt: "2026-09-03T00:02:00.000Z",
172
+ },
173
+ ],
174
+ }),
175
+ ).not.toBe(appletSourceFingerprintV1(view));
176
+ // A new file is a write too.
177
+ expect(
178
+ appletSourceFingerprintV1({
179
+ ...view,
180
+ files: [...view.files, { ...view.files[0]!, path: "ui.tsx" }],
181
+ }),
182
+ ).not.toBe(appletSourceFingerprintV1(view));
183
+ });
122
184
  });
123
185
 
124
186
  describe("the applets feed a page receives", () => {
@@ -137,3 +137,22 @@ export function mostRecentlyChangedFileV1(
137
137
  });
138
138
  return ordered[0]?.path;
139
139
  }
140
+
141
+ /**
142
+ * A stable identity for the source the canvas is showing.
143
+ *
144
+ * The canvas follows the Turn: a Turn that writes source lands the User on the
145
+ * code. "Wrote source" has to be a fact about the files, though, not about the
146
+ * store having been re-read — `refreshAppletCanvas` assigns a fresh view object
147
+ * on every poll, so a watcher on the array itself fired on Turns that touched
148
+ * no file at all and yanked the User off the live Applet.
149
+ */
150
+ export function appletSourceFingerprintV1(
151
+ source: AppletSourceViewV1 | undefined,
152
+ ): string {
153
+ if (!source) return "";
154
+ return source.files
155
+ .map((file) => `${file.path}@${file.generationId}@${file.changedAt ?? ""}`)
156
+ .toSorted()
157
+ .join("\n");
158
+ }