@app-vitals/agent-admin 0.8.0 → 0.9.1

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.
@@ -2266,16 +2266,6 @@ 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
- }
2279
2269
  /* .chat-thread-page combines with .vos-page (specificity 0,2,0), which
2280
2270
  otherwise beats .vos-page's own mobile padding rule (0,1,0) — pin the
2281
2271
  effective mobile padding here. The bottom padding is intentionally
@@ -2365,21 +2355,6 @@ export const STALL_WARN_AFTER_MS = 120_000;
2365
2355
  // import the lib file, so the value is hardcoded here with this reference.
2366
2356
  export const ABSOLUTE_MAX_MS = 3_900_000;
2367
2357
 
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
-
2383
2358
  /**
2384
2359
  * Module-level pure bubble renderer (hoisted out of renderChatThreadPage in
2385
2360
  * CFB-2.3). Returns the exact HTML the server emits on a full page load; the
@@ -2471,9 +2446,25 @@ export function renderChatMessageBubble(
2471
2446
  // filename extension (see attachment-validation.ts's isAudioFilename); the
2472
2447
  // src hits the admin attachment-proxy route, which streams the message's
2473
2448
  // bytes from the chat service with a real audio/* Content-Type.
2449
+ const attachmentUrl = m.attachmentFilename
2450
+ ? `/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(m.threadId)}/messages/${encodeURIComponent(m.id)}/attachment`
2451
+ : "";
2452
+ // Assistant voice replies get a large tap-to-play control (COS-63.1) driven
2453
+ // by a hidden <audio>; user-uploaded clips keep the native controls.
2454
+ const voiceControl = `<div class="chat-voice" data-voice>
2455
+ <button type="button" class="chat-voice-play" aria-label="Play">&#9654;</button>
2456
+ <div class="chat-voice-meta">
2457
+ <div class="chat-voice-progress"><div class="chat-voice-bar"></div></div>
2458
+ <span class="chat-voice-time">0:00 / 0:00</span>
2459
+ </div>
2460
+ <audio hidden preload="metadata" src="${escapeHtml(attachmentUrl)}"></audio>
2461
+ <div class="chat-voice-error" hidden>Could not play this reply <a download href="${escapeHtml(attachmentUrl)}">Download</a></div>
2462
+ </div>`;
2474
2463
  const attachmentMedia = m.attachmentFilename
2475
2464
  ? isAudioFilename(m.attachmentFilename)
2476
- ? `<audio controls style="display:block;margin-top:8px;max-width:100%" src="${escapeHtml(`/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(m.threadId)}/messages/${encodeURIComponent(m.id)}/attachment`)}"></audio>`
2465
+ ? isAssistant
2466
+ ? voiceControl
2467
+ : `<audio controls style="display:block;margin-top:8px;max-width:100%" src="${escapeHtml(attachmentUrl)}"></audio>`
2477
2468
  : `<div style="display:inline-block;margin-top:8px;padding:3px 8px;background:#e5e7eb;color:#374151;border-radius:6px;font-size:12px">📎 ${escapeHtml(m.attachmentFilename)}</div>`
2478
2469
  : "";
2479
2470
 
@@ -2570,12 +2561,16 @@ const chatThreadStyles = `
2570
2561
  .chat-composer-btn--attach { padding:0 16px }
2571
2562
  .chat-composer-btn--mic { padding:0 16px }
2572
2563
  .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 }
2564
+ /* Voice reply control (COS-63.1): 56px round tap target + progress. */
2565
+ .chat-voice { display:flex;align-items:center;gap:12px;margin-top:8px;flex-wrap:wrap }
2566
+ .chat-voice-play { width:56px;height:56px;min-width:56px;border-radius:50%;border:0;background:#2563eb;color:#fff;font-size:22px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0 }
2567
+ .chat-voice-play:focus-visible { outline:3px solid #93c5fd;outline-offset:2px }
2568
+ .chat-voice-meta { flex:1;min-width:120px }
2569
+ .chat-voice-progress { height:4px;border-radius:2px;background:#d1d5db;overflow:hidden }
2570
+ .chat-voice-bar { height:100%;width:0;background:#2563eb }
2571
+ .chat-voice-time { display:block;margin-top:4px;font-size:12px;color:#6b7280;font-variant-numeric:tabular-nums }
2572
+ .chat-voice-error { flex-basis:100%;font-size:13px;color:#b91c1c }
2573
+ .chat-voice-error[hidden] { display:none }
2579
2574
  .chat-composer-btn--send { padding:0 20px }
2580
2575
  /* Live-progress stall state (CFB-2.3): when progressSeq hasn't advanced for
2581
2576
  STALL_WARN_AFTER_MS the status bubble gains .${STALL_INDICATOR_CLASS},
@@ -2853,7 +2848,6 @@ export function renderChatThreadPage(
2853
2848
  var fileInput = document.getElementById('file-input');
2854
2849
  var fileName = document.getElementById('file-name');
2855
2850
  var micBtn = document.getElementById('mic-btn');
2856
- var autoplayBtn = document.getElementById('autoplay-btn');
2857
2851
  var container = document.getElementById('messages-container');
2858
2852
  var agentId = ${JSON.stringify(agentId)};
2859
2853
  var threadId = ${JSON.stringify(thread.id)};
@@ -2883,102 +2877,6 @@ export function renderChatThreadPage(
2883
2877
  var STALL_INDICATOR_CLASS = ${JSON.stringify(STALL_INDICATOR_CLASS)};
2884
2878
  var ASSISTANT_ROLE_CLASS = '${CHAT_BUBBLE_CLASS} ${chatBubbleRoleClass("assistant")}';
2885
2879
 
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
-
2982
2880
  // Poll cadence.
2983
2881
  var POLL_PENDING_MS = 2000; // adaptive fast poll while a reply is pending
2984
2882
  var POLL_IDLE_MS = 10000; // slow idle poll for agent-initiated messages
@@ -3044,18 +2942,16 @@ export function renderChatThreadPage(
3044
2942
  else container.appendChild(node);
3045
2943
  }
3046
2944
 
3047
- // Insert a server-rendered bubbleHtml string, deduped by id. Returns the
3048
- // inserted node on a first insertion, null otherwise (dupe / no html).
2945
+ // Insert a server-rendered bubbleHtml string, deduped by id.
3049
2946
  function renderServerBubble(m) {
3050
- if (!m || !m.id || renderedIds[m.id]) return null;
2947
+ if (!m || !m.id || renderedIds[m.id]) return false;
3051
2948
  renderedIds[m.id] = true;
3052
- if (!m.bubbleHtml) return null;
2949
+ if (!m.bubbleHtml) return false;
3053
2950
  var tpl = document.createElement('template');
3054
2951
  tpl.innerHTML = m.bubbleHtml.trim();
3055
2952
  var node = tpl.content.firstChild;
3056
- if (!node) return null;
3057
- insertBeforeLiveStatus(node);
3058
- return node;
2953
+ if (node) insertBeforeLiveStatus(node);
2954
+ return true;
3059
2955
  }
3060
2956
 
3061
2957
  // True when the user is within ~80px of the bottom, i.e. hasn't scrolled up
@@ -3163,8 +3059,15 @@ export function renderChatThreadPage(
3163
3059
  }
3164
3060
 
3165
3061
  function poll() {
3166
- fetch(messagesJsonUrl)
3167
- .then(function(r) { return r.json(); })
3062
+ fetch(messagesJsonUrl, { headers: { 'Accept': 'application/json' } })
3063
+ .then(function(r) {
3064
+ if (r.status === 401) {
3065
+ stopPolling();
3066
+ window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
3067
+ return { messages: [] };
3068
+ }
3069
+ return r.json();
3070
+ })
3168
3071
  .then(function(data) {
3169
3072
  var msgs = data.messages || [];
3170
3073
 
@@ -3177,13 +3080,10 @@ export function renderChatThreadPage(
3177
3080
  // bubbleHtml, which already includes any Retry button (CFB-2.4) since
3178
3081
  // the server computes retryBody per-message; wire up any new buttons
3179
3082
  // after insertion (see wireRetryButtons below).
3180
- var newAssistantNodes = [];
3181
3083
  for (var i = 0; i < msgs.length; i++) {
3182
- var inserted = renderServerBubble(msgs[i]);
3183
- if (inserted && msgs[i].role === 'assistant') newAssistantNodes.push(inserted);
3084
+ renderServerBubble(msgs[i]);
3184
3085
  }
3185
3086
  wireRetryButtons();
3186
- maybeAutoplay(newAssistantNodes);
3187
3087
 
3188
3088
  // Determine whether a reply is still pending (last message is an
3189
3089
  // unreplied user message).
@@ -3275,7 +3175,6 @@ export function renderChatThreadPage(
3275
3175
  micBtn.style.display = 'none';
3276
3176
  } else {
3277
3177
  micBtn.addEventListener('click', function() {
3278
- unlockAutoplay(); // the tap is the gesture iOS needs for later playback
3279
3178
  if (mediaRecorder && mediaRecorder.state === 'recording') {
3280
3179
  mediaRecorder.stop();
3281
3180
  return;
@@ -3354,8 +3253,14 @@ export function renderChatThreadPage(
3354
3253
  // POST multipart to the upload endpoint
3355
3254
  fetch(uploadUrl, {
3356
3255
  method: 'POST',
3256
+ headers: { 'Accept': 'application/json' },
3357
3257
  body: fd
3358
3258
  }).then(function(r) {
3259
+ if (r.status === 401) {
3260
+ stopAll();
3261
+ window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
3262
+ return;
3263
+ }
3359
3264
  if (!r.ok) {
3360
3265
  return r.json().then(function(data) {
3361
3266
  stopAll();
@@ -3385,7 +3290,6 @@ export function renderChatThreadPage(
3385
3290
  }
3386
3291
 
3387
3292
  form.addEventListener('submit', function(e) {
3388
- unlockAutoplay();
3389
3293
  e.preventDefault();
3390
3294
  var text = input.value.trim();
3391
3295
  var file = fileInput && fileInput.files && fileInput.files[0];
@@ -3435,6 +3339,66 @@ export function renderChatThreadPage(
3435
3339
  // Scroll to bottom on load
3436
3340
  container.scrollTop = container.scrollHeight;
3437
3341
  })();
3342
+ </script>`;
3343
+
3344
+ // Voice reply control (COS-63.1). Standalone block: media events don't
3345
+ // bubble, so listen in the capture phase on document — that also covers
3346
+ // bubbles inserted later by polling with no per-node wiring.
3347
+ const voiceScript = `
3348
+ <script>
3349
+ (function() {
3350
+ function fmt(t) {
3351
+ if (!isFinite(t) || t < 0) t = 0;
3352
+ var m = Math.floor(t / 60), s = Math.floor(t % 60);
3353
+ return m + ':' + (s < 10 ? '0' : '') + s;
3354
+ }
3355
+ function root(el) { return el && el.closest ? el.closest('[data-voice]') : null; }
3356
+ function paint(audio) {
3357
+ var box = root(audio);
3358
+ if (!box) return;
3359
+ var dur = audio.duration, cur = audio.currentTime || 0;
3360
+ box.querySelector('.chat-voice-time').textContent = fmt(cur) + ' / ' + fmt(dur);
3361
+ box.querySelector('.chat-voice-bar').style.width = (isFinite(dur) && dur > 0 ? Math.min(100, cur / dur * 100) : 0) + '%';
3362
+ }
3363
+ function setPlaying(audio, playing) {
3364
+ var box = root(audio);
3365
+ if (!box) return;
3366
+ var btn = box.querySelector('.chat-voice-play');
3367
+ btn.innerHTML = playing ? '&#10074;&#10074;' : '&#9654;';
3368
+ btn.setAttribute('aria-label', playing ? 'Pause' : 'Play');
3369
+ }
3370
+ function showError(audio) {
3371
+ var box = root(audio);
3372
+ if (!box) return;
3373
+ box.querySelector('.chat-voice-play').hidden = true;
3374
+ box.querySelector('.chat-voice-meta').hidden = true;
3375
+ box.querySelector('.chat-voice-error').hidden = false;
3376
+ }
3377
+ ['timeupdate', 'loadedmetadata', 'durationchange'].forEach(function(ev) {
3378
+ document.addEventListener(ev, function(e) { if (e.target.tagName === 'AUDIO') paint(e.target); }, true);
3379
+ });
3380
+ document.addEventListener('play', function(e) { if (e.target.tagName === 'AUDIO') setPlaying(e.target, true); }, true);
3381
+ document.addEventListener('pause', function(e) { if (e.target.tagName === 'AUDIO') setPlaying(e.target, false); }, true);
3382
+ document.addEventListener('ended', function(e) {
3383
+ if (e.target.tagName !== 'AUDIO') return;
3384
+ setPlaying(e.target, false);
3385
+ paint(e.target);
3386
+ }, true);
3387
+ document.addEventListener('error', function(e) { if (e.target.tagName === 'AUDIO' && root(e.target)) showError(e.target); }, true);
3388
+ document.addEventListener('click', function(e) {
3389
+ var btn = e.target.closest ? e.target.closest('.chat-voice-play') : null;
3390
+ var box = root(btn);
3391
+ if (!box) return;
3392
+ var audio = box.querySelector('audio');
3393
+ if (!audio) return;
3394
+ if (!audio.paused) { audio.pause(); return; }
3395
+ var others = document.querySelectorAll('[data-voice] audio');
3396
+ for (var i = 0; i < others.length; i++) if (others[i] !== audio) others[i].pause();
3397
+ var p;
3398
+ try { p = audio.play(); } catch (err) { showError(audio); return; }
3399
+ if (p && p.catch) p.catch(function() { if (audio.error) showError(audio); });
3400
+ });
3401
+ })();
3438
3402
  </script>`;
3439
3403
 
3440
3404
  // Standalone drawer + keyboard-inset script (CFB-3.2). Deliberately emitted
@@ -3603,14 +3567,6 @@ export function renderChatThreadPage(
3603
3567
  aria-label="Record a voice message"
3604
3568
  title="Record a voice message"
3605
3569
  >🎤</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>
3614
3570
  <button
3615
3571
  type="button"
3616
3572
  id="attach-btn"
@@ -3627,6 +3583,6 @@ export function renderChatThreadPage(
3627
3583
  </div>
3628
3584
  </div>
3629
3585
  </div>`,
3630
- bodyEnd: `${inlineScript}\n${drawerScript}`,
3586
+ bodyEnd: `${inlineScript}\n${voiceScript}\n${drawerScript}`,
3631
3587
  });
3632
3588
  }