@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.
- package/package.json +34 -32
- package/src/agent.test.ts +78 -0
- package/src/agent.ts +65 -1
- package/src/backend-configuration.test.ts +20 -20
- package/src/backend-recovery-integration.test.ts +10 -10
- package/src/client/AppletCanvas.vue +19 -6
- package/src/client/FrockBotApp.vue +299 -68
- package/src/client/applets-client.test.ts +62 -0
- package/src/client/applets-client.ts +19 -0
- package/src/client/index.test.ts +103 -16
- package/src/client/index.ts +187 -79
- package/src/client/styles.css +56 -61
- package/src/client/voice-dictation.test.ts +105 -0
- package/src/client/voice-dictation.ts +137 -0
- package/src/client/voice-microphone.ts +125 -0
- package/src/client/voice-worklet.ts +75 -0
- package/src/notification-id.ts +0 -0
- package/src/run-failure-copy.test.ts +150 -0
- package/src/run-failure-copy.ts +110 -0
- package/src/run-protocol.test.ts +13 -7
- package/src/run-protocol.ts +17 -7
- package/src/shared.ts +17 -0
|
@@ -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
|
-
/**
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
+
}
|