@app-vitals/agent-admin 0.6.1 → 0.7.0
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/e2e/chat-thread-page.e2e.ts +79 -0
- package/package.json +2 -2
- package/src/admin-ui-pages.ts +149 -6
- package/src/admin-ui-pages.unit.test.ts +209 -0
|
@@ -441,4 +441,83 @@ test.describe("GET /admin/chat/:agentId/threads/:threadId — CFB-3.2 mobile dra
|
|
|
441
441
|
// A 1px rounding tolerance guards against sub-pixel layout.
|
|
442
442
|
expect(composerBottom).toBeLessThanOrEqual(viewportHeight + 1);
|
|
443
443
|
});
|
|
444
|
+
|
|
445
|
+
// ─── Voice-first composer: phone mic size + auto-play toggle persistence ──
|
|
446
|
+
|
|
447
|
+
test("at 375px the mic and auto-play buttons are >= 56px round thumb targets", async ({
|
|
448
|
+
page,
|
|
449
|
+
context,
|
|
450
|
+
}) => {
|
|
451
|
+
await page.setViewportSize({ width: 375, height: 812 });
|
|
452
|
+
await loadChatThreadPage(page, context);
|
|
453
|
+
|
|
454
|
+
const sizes = await page.evaluate(() =>
|
|
455
|
+
["mic-btn", "autoplay-btn"].map((id) => {
|
|
456
|
+
const el = document.getElementById(id) as HTMLElement;
|
|
457
|
+
const rect = el.getBoundingClientRect();
|
|
458
|
+
return {
|
|
459
|
+
id,
|
|
460
|
+
width: rect.width,
|
|
461
|
+
height: rect.height,
|
|
462
|
+
radius: getComputedStyle(el).borderRadius,
|
|
463
|
+
};
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
for (const { id, width, height, radius } of sizes) {
|
|
467
|
+
expect(width, id).toBeGreaterThanOrEqual(56);
|
|
468
|
+
expect(height, id).toBeGreaterThanOrEqual(56);
|
|
469
|
+
expect(radius, id).toBe("50%");
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("at 1280px the mic keeps the 44px desktop row height", async ({
|
|
474
|
+
page,
|
|
475
|
+
context,
|
|
476
|
+
}) => {
|
|
477
|
+
await page.setViewportSize({ width: 1280, height: 900 });
|
|
478
|
+
await loadChatThreadPage(page, context);
|
|
479
|
+
const height = await page.evaluate(
|
|
480
|
+
() =>
|
|
481
|
+
(document.getElementById("mic-btn") as HTMLElement).getBoundingClientRect()
|
|
482
|
+
.height,
|
|
483
|
+
);
|
|
484
|
+
expect(height).toBe(44);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("the auto-play toggle persists per thread across a reload", async ({
|
|
488
|
+
page,
|
|
489
|
+
context,
|
|
490
|
+
}) => {
|
|
491
|
+
await loadChatThreadPage(page, context);
|
|
492
|
+
const toggle = page.locator("#autoplay-btn");
|
|
493
|
+
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
494
|
+
|
|
495
|
+
await toggle.click();
|
|
496
|
+
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
497
|
+
await expect(toggle).toHaveClass(/chat-autoplay-on/);
|
|
498
|
+
const stored = await page.evaluate(
|
|
499
|
+
(threadId) =>
|
|
500
|
+
window.localStorage.getItem(`shipwright.chat.autoplay.${threadId}`),
|
|
501
|
+
THREAD_ID,
|
|
502
|
+
);
|
|
503
|
+
expect(stored).toBe("1");
|
|
504
|
+
|
|
505
|
+
await page.reload();
|
|
506
|
+
await expect(page.locator("#autoplay-btn")).toHaveAttribute(
|
|
507
|
+
"aria-pressed",
|
|
508
|
+
"true",
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
await page.locator("#autoplay-btn").click();
|
|
512
|
+
await expect(page.locator("#autoplay-btn")).toHaveAttribute(
|
|
513
|
+
"aria-pressed",
|
|
514
|
+
"false",
|
|
515
|
+
);
|
|
516
|
+
const cleared = await page.evaluate(
|
|
517
|
+
(threadId) =>
|
|
518
|
+
window.localStorage.getItem(`shipwright.chat.autoplay.${threadId}`),
|
|
519
|
+
THREAD_ID,
|
|
520
|
+
);
|
|
521
|
+
expect(cleared).toBeNull();
|
|
522
|
+
});
|
|
444
523
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anthropic-ai/sdk": "0.135.0",
|
|
46
|
-
"@app-vitals/agent-lib": "0.
|
|
46
|
+
"@app-vitals/agent-lib": "0.7.0",
|
|
47
47
|
"@hono/zod-openapi": "^1.0.0",
|
|
48
48
|
"@prisma/adapter-pg": "^7.10.0",
|
|
49
49
|
"@prisma/client": "^7.10.0",
|
package/src/admin-ui-pages.ts
CHANGED
|
@@ -2266,6 +2266,16 @@ const chatPageStyles = `
|
|
|
2266
2266
|
squeezed to ~160px next to the Attach/Send buttons at narrow widths. */
|
|
2267
2267
|
.chat-composer-row { flex-wrap:wrap }
|
|
2268
2268
|
.chat-message-input { flex-basis:100%; }
|
|
2269
|
+
/* Voice-first composer on phones: the mic and the auto-play toggle
|
|
2270
|
+
become 56px round thumb targets (desktop keeps the 44px text-button
|
|
2271
|
+
row), and an active recording pulses a red ring so the state is
|
|
2272
|
+
obvious at arm's length. */
|
|
2273
|
+
.chat-composer-btn--mic, .chat-composer-btn--autoplay { width:56px;height:56px;padding:0;border-radius:50%;font-size:24px;line-height:1 }
|
|
2274
|
+
.chat-composer-btn--mic.chat-mic-recording { animation:chat-mic-pulse 1.2s ease-in-out infinite }
|
|
2275
|
+
@keyframes chat-mic-pulse { 0%,100% { box-shadow:0 0 0 0 rgba(185,28,28,0.45) } 50% { box-shadow:0 0 0 10px rgba(185,28,28,0) } }
|
|
2276
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2277
|
+
.chat-composer-btn--mic.chat-mic-recording { animation:none;box-shadow:0 0 0 4px rgba(185,28,28,0.3) }
|
|
2278
|
+
}
|
|
2269
2279
|
/* .chat-thread-page combines with .vos-page (specificity 0,2,0), which
|
|
2270
2280
|
otherwise beats .vos-page's own mobile padding rule (0,1,0) — pin the
|
|
2271
2281
|
effective mobile padding here. The bottom padding is intentionally
|
|
@@ -2355,6 +2365,21 @@ export const STALL_WARN_AFTER_MS = 120_000;
|
|
|
2355
2365
|
// import the lib file, so the value is hardcoded here with this reference.
|
|
2356
2366
|
export const ABSOLUTE_MAX_MS = 3_900_000;
|
|
2357
2367
|
|
|
2368
|
+
/**
|
|
2369
|
+
* localStorage key prefix for the per-thread "auto-play voice replies" switch
|
|
2370
|
+
* (`<prefix><threadId>` = "1" when on). Browser-local by design: a phone and a
|
|
2371
|
+
* laptop remember the switch separately and no server round-trip or schema
|
|
2372
|
+
* field is involved. Exported so tests can assert the inline script carries it.
|
|
2373
|
+
*/
|
|
2374
|
+
export const AUTOPLAY_STORAGE_KEY_PREFIX = "shipwright.chat.autoplay.";
|
|
2375
|
+
/**
|
|
2376
|
+
* A 10ms silent WAV. iOS Safari only lets `play()` run on a media element that
|
|
2377
|
+
* was first started inside a user gesture, so the inline script plays this on
|
|
2378
|
+
* a shared Audio element during the mic/toggle/send taps and later reuses that
|
|
2379
|
+
* unlocked element for a reply when the bubble's own control refuses.
|
|
2380
|
+
*/
|
|
2381
|
+
const AUTOPLAY_UNLOCK_WAV = "data:audio/wav;base64,UklGRnQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YVAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA==";
|
|
2382
|
+
|
|
2358
2383
|
/**
|
|
2359
2384
|
* Module-level pure bubble renderer (hoisted out of renderChatThreadPage in
|
|
2360
2385
|
* CFB-2.3). Returns the exact HTML the server emits on a full page load; the
|
|
@@ -2545,6 +2570,12 @@ const chatThreadStyles = `
|
|
|
2545
2570
|
.chat-composer-btn--attach { padding:0 16px }
|
|
2546
2571
|
.chat-composer-btn--mic { padding:0 16px }
|
|
2547
2572
|
.chat-composer-btn--mic.chat-mic-recording { background:#fee2e2;color:#b91c1c;border-color:#b91c1c }
|
|
2573
|
+
/* Auto-play voice replies toggle: aria-pressed button next to the mic;
|
|
2574
|
+
the on-state reads green so the phone shows at a glance that replies
|
|
2575
|
+
will speak on their own. */
|
|
2576
|
+
.chat-composer-btn--autoplay { padding:0 16px }
|
|
2577
|
+
.chat-composer-btn--autoplay.chat-autoplay-on { background:#dcfce7;color:#15803d;border-color:#15803d }
|
|
2578
|
+
.chat-autoplay-hint { display:inline-block;margin-top:4px;font-size:12px;color:#6b7280 }
|
|
2548
2579
|
.chat-composer-btn--send { padding:0 20px }
|
|
2549
2580
|
/* Live-progress stall state (CFB-2.3): when progressSeq hasn't advanced for
|
|
2550
2581
|
STALL_WARN_AFTER_MS the status bubble gains .${STALL_INDICATOR_CLASS},
|
|
@@ -2822,6 +2853,7 @@ export function renderChatThreadPage(
|
|
|
2822
2853
|
var fileInput = document.getElementById('file-input');
|
|
2823
2854
|
var fileName = document.getElementById('file-name');
|
|
2824
2855
|
var micBtn = document.getElementById('mic-btn');
|
|
2856
|
+
var autoplayBtn = document.getElementById('autoplay-btn');
|
|
2825
2857
|
var container = document.getElementById('messages-container');
|
|
2826
2858
|
var agentId = ${JSON.stringify(agentId)};
|
|
2827
2859
|
var threadId = ${JSON.stringify(thread.id)};
|
|
@@ -2851,6 +2883,102 @@ export function renderChatThreadPage(
|
|
|
2851
2883
|
var STALL_INDICATOR_CLASS = ${JSON.stringify(STALL_INDICATOR_CLASS)};
|
|
2852
2884
|
var ASSISTANT_ROLE_CLASS = '${CHAT_BUBBLE_CLASS} ${chatBubbleRoleClass("assistant")}';
|
|
2853
2885
|
|
|
2886
|
+
// ─── Auto-play voice replies ─────────────────────────────────────────────
|
|
2887
|
+
// Per-thread switch remembered in localStorage (no server state). When on,
|
|
2888
|
+
// the newest assistant audio bubble inserted by a poll is played as soon as
|
|
2889
|
+
// it lands, so a voice-note round trip needs no tap to hear the answer.
|
|
2890
|
+
// Bubbles already in the DOM at load are seeded into renderedIds and can
|
|
2891
|
+
// never qualify, so reopening a thread never replays its backlog.
|
|
2892
|
+
var AUTOPLAY_STORAGE_KEY = ${JSON.stringify(AUTOPLAY_STORAGE_KEY_PREFIX)} + threadId;
|
|
2893
|
+
var AUTOPLAY_UNLOCK_SRC = ${JSON.stringify(AUTOPLAY_UNLOCK_WAV)};
|
|
2894
|
+
var autoplayEnabled = false;
|
|
2895
|
+
try {
|
|
2896
|
+
autoplayEnabled = !!window.localStorage && window.localStorage.getItem(AUTOPLAY_STORAGE_KEY) === '1';
|
|
2897
|
+
} catch (e) {
|
|
2898
|
+
autoplayEnabled = false; // private mode / storage blocked
|
|
2899
|
+
}
|
|
2900
|
+
var unlockAudio = null;
|
|
2901
|
+
|
|
2902
|
+
function setAutoplayUI() {
|
|
2903
|
+
if (!autoplayBtn) return;
|
|
2904
|
+
autoplayBtn.setAttribute('aria-pressed', autoplayEnabled ? 'true' : 'false');
|
|
2905
|
+
autoplayBtn.classList.toggle('chat-autoplay-on', autoplayEnabled);
|
|
2906
|
+
autoplayBtn.textContent = autoplayEnabled ? '🔊' : '🔇';
|
|
2907
|
+
autoplayBtn.setAttribute('title', autoplayEnabled ? 'Auto-play voice replies: on' : 'Auto-play voice replies: off');
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
// Call from inside a user gesture (tap on mic / toggle / send). Plays the
|
|
2911
|
+
// silent clip on one shared Audio element so iOS treats that element as
|
|
2912
|
+
// user-started; playReply() falls back to it when a bubble's own control
|
|
2913
|
+
// is refused. Resets the src every time so a previous reply never replays.
|
|
2914
|
+
function unlockAutoplay() {
|
|
2915
|
+
if (!autoplayEnabled || typeof Audio === 'undefined') return;
|
|
2916
|
+
try {
|
|
2917
|
+
if (!unlockAudio) unlockAudio = new Audio();
|
|
2918
|
+
unlockAudio.src = AUTOPLAY_UNLOCK_SRC;
|
|
2919
|
+
var p = unlockAudio.play();
|
|
2920
|
+
if (p && p.then) p.then(function() { unlockAudio.pause(); }).catch(function() {});
|
|
2921
|
+
} catch (e) {
|
|
2922
|
+
// No audio support — the visible control is still there.
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
function showAutoplayHint(bubble) {
|
|
2927
|
+
if (!bubble || !bubble.querySelector || bubble.querySelector('.chat-autoplay-hint')) return;
|
|
2928
|
+
var hint = document.createElement('span');
|
|
2929
|
+
hint.className = 'chat-autoplay-hint';
|
|
2930
|
+
hint.textContent = 'Tap ▶ to listen';
|
|
2931
|
+
var audio = bubble.querySelector('audio');
|
|
2932
|
+
if (audio && audio.parentNode) audio.parentNode.insertBefore(hint, audio.nextSibling);
|
|
2933
|
+
else bubble.appendChild(hint);
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function playReply(audio, bubble) {
|
|
2937
|
+
var attempt;
|
|
2938
|
+
try { attempt = audio.play(); } catch (e) { attempt = Promise.reject(e); }
|
|
2939
|
+
Promise.resolve(attempt).catch(function() {
|
|
2940
|
+
// The bubble's control was refused (iOS: this element never saw a
|
|
2941
|
+
// gesture). Reuse the unlocked element; pause it if the user takes
|
|
2942
|
+
// over with the visible control.
|
|
2943
|
+
if (!unlockAudio) { showAutoplayHint(bubble); return; }
|
|
2944
|
+
try {
|
|
2945
|
+
unlockAudio.src = audio.currentSrc || audio.getAttribute('src');
|
|
2946
|
+
audio.addEventListener('play', function() { unlockAudio.pause(); });
|
|
2947
|
+
Promise.resolve(unlockAudio.play()).catch(function() { showAutoplayHint(bubble); });
|
|
2948
|
+
} catch (e) {
|
|
2949
|
+
showAutoplayHint(bubble);
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
// nodes: assistant bubbles first inserted by this poll, in order. Only the
|
|
2955
|
+
// newest one with an <audio> plays, so a burst never stacks overlapping
|
|
2956
|
+
// replies.
|
|
2957
|
+
function maybeAutoplay(nodes) {
|
|
2958
|
+
if (!autoplayEnabled) return;
|
|
2959
|
+
for (var i = nodes.length - 1; i >= 0; i--) {
|
|
2960
|
+
var audio = nodes[i] && nodes[i].querySelector ? nodes[i].querySelector('audio') : null;
|
|
2961
|
+
if (!audio) continue;
|
|
2962
|
+
playReply(audio, nodes[i]);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
if (autoplayBtn) {
|
|
2968
|
+
setAutoplayUI();
|
|
2969
|
+
autoplayBtn.addEventListener('click', function() {
|
|
2970
|
+
autoplayEnabled = !autoplayEnabled;
|
|
2971
|
+
try {
|
|
2972
|
+
if (autoplayEnabled) window.localStorage.setItem(AUTOPLAY_STORAGE_KEY, '1');
|
|
2973
|
+
else window.localStorage.removeItem(AUTOPLAY_STORAGE_KEY);
|
|
2974
|
+
} catch (e) {
|
|
2975
|
+
// Storage blocked: the switch still works for this page view.
|
|
2976
|
+
}
|
|
2977
|
+
setAutoplayUI();
|
|
2978
|
+
unlockAutoplay();
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2854
2982
|
// Poll cadence.
|
|
2855
2983
|
var POLL_PENDING_MS = 2000; // adaptive fast poll while a reply is pending
|
|
2856
2984
|
var POLL_IDLE_MS = 10000; // slow idle poll for agent-initiated messages
|
|
@@ -2916,16 +3044,18 @@ export function renderChatThreadPage(
|
|
|
2916
3044
|
else container.appendChild(node);
|
|
2917
3045
|
}
|
|
2918
3046
|
|
|
2919
|
-
// Insert a server-rendered bubbleHtml string, deduped by id.
|
|
3047
|
+
// Insert a server-rendered bubbleHtml string, deduped by id. Returns the
|
|
3048
|
+
// inserted node on a first insertion, null otherwise (dupe / no html).
|
|
2920
3049
|
function renderServerBubble(m) {
|
|
2921
|
-
if (!m || !m.id || renderedIds[m.id]) return
|
|
3050
|
+
if (!m || !m.id || renderedIds[m.id]) return null;
|
|
2922
3051
|
renderedIds[m.id] = true;
|
|
2923
|
-
if (!m.bubbleHtml) return
|
|
3052
|
+
if (!m.bubbleHtml) return null;
|
|
2924
3053
|
var tpl = document.createElement('template');
|
|
2925
3054
|
tpl.innerHTML = m.bubbleHtml.trim();
|
|
2926
3055
|
var node = tpl.content.firstChild;
|
|
2927
|
-
if (node)
|
|
2928
|
-
|
|
3056
|
+
if (!node) return null;
|
|
3057
|
+
insertBeforeLiveStatus(node);
|
|
3058
|
+
return node;
|
|
2929
3059
|
}
|
|
2930
3060
|
|
|
2931
3061
|
// True when the user is within ~80px of the bottom, i.e. hasn't scrolled up
|
|
@@ -3047,10 +3177,13 @@ export function renderChatThreadPage(
|
|
|
3047
3177
|
// bubbleHtml, which already includes any Retry button (CFB-2.4) since
|
|
3048
3178
|
// the server computes retryBody per-message; wire up any new buttons
|
|
3049
3179
|
// after insertion (see wireRetryButtons below).
|
|
3180
|
+
var newAssistantNodes = [];
|
|
3050
3181
|
for (var i = 0; i < msgs.length; i++) {
|
|
3051
|
-
renderServerBubble(msgs[i]);
|
|
3182
|
+
var inserted = renderServerBubble(msgs[i]);
|
|
3183
|
+
if (inserted && msgs[i].role === 'assistant') newAssistantNodes.push(inserted);
|
|
3052
3184
|
}
|
|
3053
3185
|
wireRetryButtons();
|
|
3186
|
+
maybeAutoplay(newAssistantNodes);
|
|
3054
3187
|
|
|
3055
3188
|
// Determine whether a reply is still pending (last message is an
|
|
3056
3189
|
// unreplied user message).
|
|
@@ -3142,6 +3275,7 @@ export function renderChatThreadPage(
|
|
|
3142
3275
|
micBtn.style.display = 'none';
|
|
3143
3276
|
} else {
|
|
3144
3277
|
micBtn.addEventListener('click', function() {
|
|
3278
|
+
unlockAutoplay(); // the tap is the gesture iOS needs for later playback
|
|
3145
3279
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
3146
3280
|
mediaRecorder.stop();
|
|
3147
3281
|
return;
|
|
@@ -3251,6 +3385,7 @@ export function renderChatThreadPage(
|
|
|
3251
3385
|
}
|
|
3252
3386
|
|
|
3253
3387
|
form.addEventListener('submit', function(e) {
|
|
3388
|
+
unlockAutoplay();
|
|
3254
3389
|
e.preventDefault();
|
|
3255
3390
|
var text = input.value.trim();
|
|
3256
3391
|
var file = fileInput && fileInput.files && fileInput.files[0];
|
|
@@ -3468,6 +3603,14 @@ export function renderChatThreadPage(
|
|
|
3468
3603
|
aria-label="Record a voice message"
|
|
3469
3604
|
title="Record a voice message"
|
|
3470
3605
|
>🎤</button>
|
|
3606
|
+
<button
|
|
3607
|
+
type="button"
|
|
3608
|
+
id="autoplay-btn"
|
|
3609
|
+
class="btn btn-secondary chat-composer-btn chat-composer-btn--autoplay"
|
|
3610
|
+
aria-label="Auto-play voice replies"
|
|
3611
|
+
aria-pressed="false"
|
|
3612
|
+
title="Auto-play voice replies: off"
|
|
3613
|
+
>🔇</button>
|
|
3471
3614
|
<button
|
|
3472
3615
|
type="button"
|
|
3473
3616
|
id="attach-btn"
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type MemberItem,
|
|
17
17
|
type PluginItem,
|
|
18
18
|
renderAddAnotherOrgAction,
|
|
19
|
+
AUTOPLAY_STORAGE_KEY_PREFIX,
|
|
19
20
|
renderAgentDetailPage,
|
|
20
21
|
renderAgentsPage,
|
|
21
22
|
renderChatMessageBubble,
|
|
@@ -4353,3 +4354,211 @@ describe("renderAgentDetailPage — signed commits toggle", () => {
|
|
|
4353
4354
|
expect(checkbox).toContain("checked");
|
|
4354
4355
|
});
|
|
4355
4356
|
});
|
|
4357
|
+
|
|
4358
|
+
// ─── Auto-play voice replies toggle + phone mic sizing ───────────────────────
|
|
4359
|
+
|
|
4360
|
+
describe("renderChatThreadPage — auto-play voice replies toggle", () => {
|
|
4361
|
+
const THREAD: ChatThread = {
|
|
4362
|
+
id: "thread-ap",
|
|
4363
|
+
agentId: "agent-ap",
|
|
4364
|
+
title: "Voice Thread",
|
|
4365
|
+
memberId: null,
|
|
4366
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
4367
|
+
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
4368
|
+
};
|
|
4369
|
+
|
|
4370
|
+
test("renders an aria-pressed auto-play toggle right after the mic button", () => {
|
|
4371
|
+
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4372
|
+
const micIdx = html.indexOf('id="mic-btn"');
|
|
4373
|
+
const toggleIdx = html.indexOf('id="autoplay-btn"');
|
|
4374
|
+
const attachIdx = html.indexOf('id="attach-btn"');
|
|
4375
|
+
expect(micIdx).toBeGreaterThan(-1);
|
|
4376
|
+
expect(toggleIdx).toBeGreaterThan(micIdx);
|
|
4377
|
+
expect(attachIdx).toBeGreaterThan(toggleIdx);
|
|
4378
|
+
expect(html).toContain('aria-pressed="false"');
|
|
4379
|
+
expect(html).toContain('aria-label="Auto-play voice replies"');
|
|
4380
|
+
});
|
|
4381
|
+
|
|
4382
|
+
test("inline script carries the per-thread localStorage key prefix", () => {
|
|
4383
|
+
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4384
|
+
expect(html).toContain(JSON.stringify(AUTOPLAY_STORAGE_KEY_PREFIX));
|
|
4385
|
+
expect(html).toContain("maybeAutoplay(newAssistantNodes)");
|
|
4386
|
+
expect(html).toContain("data:audio/wav;base64,");
|
|
4387
|
+
});
|
|
4388
|
+
|
|
4389
|
+
test("phone breakpoint grows the mic and toggle to 56px round buttons", () => {
|
|
4390
|
+
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4391
|
+
expect(html).toContain(
|
|
4392
|
+
".chat-composer-btn--mic, .chat-composer-btn--autoplay { width:56px;height:56px;padding:0;border-radius:50%",
|
|
4393
|
+
);
|
|
4394
|
+
expect(html).toContain("@keyframes chat-mic-pulse");
|
|
4395
|
+
// Desktop rule stays the plain 44px text button.
|
|
4396
|
+
expect(html).toContain(".chat-composer-btn--mic { padding:0 16px }");
|
|
4397
|
+
});
|
|
4398
|
+
|
|
4399
|
+
// Runs the inline script against stub DOM/fetch/storage (no globals touched)
|
|
4400
|
+
// and fires the first poll with one new message; returns the audio stub's
|
|
4401
|
+
// play() call count.
|
|
4402
|
+
async function bootAndPoll(opts: {
|
|
4403
|
+
stored: string | null;
|
|
4404
|
+
role: "assistant" | "user";
|
|
4405
|
+
withAudio: boolean;
|
|
4406
|
+
seededInDom?: boolean;
|
|
4407
|
+
}): Promise<{ plays: number; storedKeys: string[] }> {
|
|
4408
|
+
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4409
|
+
const script = html.match(
|
|
4410
|
+
/<script>\n\(function\(\) \{[\s\S]*?\n<\/script>/,
|
|
4411
|
+
);
|
|
4412
|
+
if (!script) throw new Error("inline chat script not found");
|
|
4413
|
+
const code = script[0].replace(/^<script>/, "").replace(/<\/script>$/, "");
|
|
4414
|
+
|
|
4415
|
+
let plays = 0;
|
|
4416
|
+
const audioStub = {
|
|
4417
|
+
play() {
|
|
4418
|
+
plays++;
|
|
4419
|
+
return Promise.resolve();
|
|
4420
|
+
},
|
|
4421
|
+
addEventListener() {},
|
|
4422
|
+
getAttribute: () => "/attachment",
|
|
4423
|
+
currentSrc: "",
|
|
4424
|
+
parentNode: null,
|
|
4425
|
+
};
|
|
4426
|
+
const makeEl = (): Record<string, unknown> => ({
|
|
4427
|
+
style: {},
|
|
4428
|
+
classList: { add() {}, remove() {}, toggle() {} },
|
|
4429
|
+
addEventListener() {},
|
|
4430
|
+
setAttribute() {},
|
|
4431
|
+
getAttribute: () => null,
|
|
4432
|
+
appendChild() {},
|
|
4433
|
+
insertBefore() {},
|
|
4434
|
+
querySelectorAll: () => [],
|
|
4435
|
+
querySelector: () => null,
|
|
4436
|
+
});
|
|
4437
|
+
const bubbleNode = {
|
|
4438
|
+
...makeEl(),
|
|
4439
|
+
querySelector: (sel: string) =>
|
|
4440
|
+
sel === "audio" && opts.withAudio ? audioStub : null,
|
|
4441
|
+
};
|
|
4442
|
+
const seeded = opts.seededInDom
|
|
4443
|
+
? [{ getAttribute: () => "msg-new" }]
|
|
4444
|
+
: [];
|
|
4445
|
+
const container = {
|
|
4446
|
+
...makeEl(),
|
|
4447
|
+
scrollTop: 0,
|
|
4448
|
+
scrollHeight: 1000,
|
|
4449
|
+
clientHeight: 400,
|
|
4450
|
+
querySelectorAll: () => seeded,
|
|
4451
|
+
};
|
|
4452
|
+
const document = {
|
|
4453
|
+
getElementById: (id: string) =>
|
|
4454
|
+
id === "messages-container"
|
|
4455
|
+
? container
|
|
4456
|
+
: id === "live-status-bubble"
|
|
4457
|
+
? null
|
|
4458
|
+
: makeEl(),
|
|
4459
|
+
createElement: (tag: string) =>
|
|
4460
|
+
tag === "template"
|
|
4461
|
+
? { innerHTML: "", content: { firstChild: bubbleNode } }
|
|
4462
|
+
: makeEl(),
|
|
4463
|
+
querySelectorAll: () => [],
|
|
4464
|
+
};
|
|
4465
|
+
const storage = new Map<string, string>();
|
|
4466
|
+
if (opts.stored !== null)
|
|
4467
|
+
storage.set(`${AUTOPLAY_STORAGE_KEY_PREFIX}thread-ap`, opts.stored);
|
|
4468
|
+
const window = {
|
|
4469
|
+
addEventListener() {},
|
|
4470
|
+
localStorage: {
|
|
4471
|
+
getItem: (k: string) => storage.get(k) ?? null,
|
|
4472
|
+
setItem: (k: string, v: string) => storage.set(k, v),
|
|
4473
|
+
removeItem: (k: string) => storage.delete(k),
|
|
4474
|
+
},
|
|
4475
|
+
};
|
|
4476
|
+
const fetchStub = () =>
|
|
4477
|
+
Promise.resolve({
|
|
4478
|
+
json: () =>
|
|
4479
|
+
Promise.resolve({
|
|
4480
|
+
messages: [
|
|
4481
|
+
{
|
|
4482
|
+
id: "msg-new",
|
|
4483
|
+
role: opts.role,
|
|
4484
|
+
body: "",
|
|
4485
|
+
repliedAt: "2024-01-01T00:00:01.000Z",
|
|
4486
|
+
bubbleHtml: "<div><audio></audio></div>",
|
|
4487
|
+
},
|
|
4488
|
+
],
|
|
4489
|
+
}),
|
|
4490
|
+
});
|
|
4491
|
+
const timers: Array<() => void> = [];
|
|
4492
|
+
new Function(
|
|
4493
|
+
"document",
|
|
4494
|
+
"fetch",
|
|
4495
|
+
"setTimeout",
|
|
4496
|
+
"clearTimeout",
|
|
4497
|
+
"setInterval",
|
|
4498
|
+
"clearInterval",
|
|
4499
|
+
"window",
|
|
4500
|
+
code,
|
|
4501
|
+
)(
|
|
4502
|
+
document,
|
|
4503
|
+
fetchStub,
|
|
4504
|
+
(fn: () => void) => {
|
|
4505
|
+
timers.push(fn);
|
|
4506
|
+
return timers.length;
|
|
4507
|
+
},
|
|
4508
|
+
() => {},
|
|
4509
|
+
() => 0,
|
|
4510
|
+
() => {},
|
|
4511
|
+
window,
|
|
4512
|
+
);
|
|
4513
|
+
timers[timers.length - 1]();
|
|
4514
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
4515
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
4516
|
+
return { plays, storedKeys: [...storage.keys()] };
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4519
|
+
test("switch on + new assistant audio bubble -> plays it once", async () => {
|
|
4520
|
+
const { plays } = await bootAndPoll({
|
|
4521
|
+
stored: "1",
|
|
4522
|
+
role: "assistant",
|
|
4523
|
+
withAudio: true,
|
|
4524
|
+
});
|
|
4525
|
+
expect(plays).toBe(1);
|
|
4526
|
+
});
|
|
4527
|
+
|
|
4528
|
+
test("switch off -> a new assistant audio bubble is not played", async () => {
|
|
4529
|
+
const { plays } = await bootAndPoll({
|
|
4530
|
+
stored: null,
|
|
4531
|
+
role: "assistant",
|
|
4532
|
+
withAudio: true,
|
|
4533
|
+
});
|
|
4534
|
+
expect(plays).toBe(0);
|
|
4535
|
+
});
|
|
4536
|
+
|
|
4537
|
+
test("switch on but the new bubble is the user's own recording -> not played", async () => {
|
|
4538
|
+
const { plays } = await bootAndPoll({
|
|
4539
|
+
stored: "1",
|
|
4540
|
+
role: "user",
|
|
4541
|
+
withAudio: true,
|
|
4542
|
+
});
|
|
4543
|
+
expect(plays).toBe(0);
|
|
4544
|
+
});
|
|
4545
|
+
|
|
4546
|
+
test("switch on but the message was already in the DOM at load -> never replayed", async () => {
|
|
4547
|
+
const { plays } = await bootAndPoll({
|
|
4548
|
+
stored: "1",
|
|
4549
|
+
role: "assistant",
|
|
4550
|
+
withAudio: true,
|
|
4551
|
+
seededInDom: true,
|
|
4552
|
+
});
|
|
4553
|
+
expect(plays).toBe(0);
|
|
4554
|
+
});
|
|
4555
|
+
|
|
4556
|
+
test("switch on but the assistant bubble has no audio -> no-op", async () => {
|
|
4557
|
+
const { plays } = await bootAndPoll({
|
|
4558
|
+
stored: "1",
|
|
4559
|
+
role: "assistant",
|
|
4560
|
+
withAudio: false,
|
|
4561
|
+
});
|
|
4562
|
+
expect(plays).toBe(0);
|
|
4563
|
+
});
|
|
4564
|
+
});
|