@goodandready/dsh-tts 0.4.33 โ†’ 0.4.34

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 (3) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/client.js +430 -67
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.34 (2026-10-17)
4
+
5
+ - **Per-Reply Speak Action** (#208, GitHub PR #2):
6
+ - Added dedicated speaker button to `conversation.chat.assistant-actions` (order 5), positioned beside copy action to read rendered assistant turn text directly from the DOM.
7
+ - Added in-row transport cluster next to the reply clock (pause, stop, skip, export) that remains visible during playback even when mouse leaves message row.
8
+ - **Context Menu & Contained Scroll** (#208, GitHub PR #2):
9
+ - Added right-click context menu on the speaker action to display recent utterances with contained wheel scrolling that prevents closing on scroll.
10
+ - **Hotkeys & Playback Enhancements** (#208, GitHub PR #2):
11
+ - Added `Shift+Space` hotkey to stop active playback or instantly replay the last utterance from memory cache (`player.lastItem`).
12
+ - Added `statusKnown` guard in SSE event handling to prevent dropping initial stream utterances during server startup.
13
+ - Primed pending audio poll immediately on mount and on audio unlock gesture.
14
+ - **Client Bundle**: Rebuilt `lib/client.js` from canonical `lib/client-src/` fragments with updated tests.
15
+
3
16
  ## 0.4.33 (2026-10-11)
4
17
 
5
18
  - **SSE Stream Watchdog**: Added idle timeout watchdog (30s) in Web player (`lib/client-src/20-player.js`) to automatically trigger reconnect on stale/frozen EventSource connection (#206).
package/lib/client.js CHANGED
@@ -210,6 +210,39 @@ window.__ModuleLoader__.load({
210
210
  '.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
211
211
  '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 10%, transparent);color:var(--dsw-alias-state-warn-primary, #f59e0b);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 20%, transparent)}' +
212
212
  '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
213
+ // Per-message speak button: icon-only, same weight as the sibling actions.
214
+ '.dts-act{box-sizing:border-box;width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));display:inline-flex;align-items:center;justify-content:center;padding:6px;cursor:pointer;border:none;border-radius:var(--dsw-radius-sm, 8px);background:none;color:var(--dsw-alias-label-tertiary, #999);transition:background .15s ease,color .15s ease}' +
215
+ '.dts-act:hover{background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12));color:var(--dsw-alias-label-secondary, #666)}' +
216
+ '.dts-act:active{background:color-mix(in srgb, var(--dsw-alias-label-secondary, #666) 16%, transparent)}' +
217
+ '.dts-act[data-active]{color:var(--dsw-alias-brand-primary, #4d6bfe)}' +
218
+ '.dts-act[data-active]:hover{background:color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 12%, transparent);color:var(--dsw-alias-brand-primary, #4d6bfe)}' +
219
+ '.dts-act:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4d6bfe);outline-offset:1px}' +
220
+ '.dts-act svg{width:calc(17px + var(--dsh-content-font-delta,0px));height:calc(17px + var(--dsh-content-font-delta,0px));display:block}' +
221
+ '@keyframes dts-act-pulse{0%,100%{opacity:.35}50%{opacity:1}}' +
222
+ '.dts-act[data-busy] [data-wave]{animation:dts-act-pulse 1.1s ease-in-out infinite}' +
223
+ '.dts-act[data-busy] [data-wave="2"]{animation-delay:.22s}' +
224
+ // Right-click menu: just the recent lines, all of them, scrolled by wheel.
225
+ '.dts-menu{position:fixed;z-index:2147483000;box-sizing:border-box;min-width:210px;max-width:300px;max-height:min(64vh, 460px);overflow:hidden;padding:6px;display:flex;flex-direction:column;gap:2px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, #e8e8e8);background:var(--dsw-alias-bg-layer-2, #fff);box-shadow:0 12px 32px rgba(0,0,0,.16),0 2px 6px rgba(0,0,0,.06);font-size:13px;line-height:1.4}' +
226
+ '.dts-menu-item{display:flex;align-items:center;gap:8px;box-sizing:border-box;width:100%;padding:7px 10px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary, #111);font:inherit;text-align:left;cursor:pointer}' +
227
+ '.dts-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))}' +
228
+ '.dts-menu-item:disabled{opacity:.42;cursor:default}' +
229
+ '.dts-menu-item span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
230
+ // The list is the whole menu: no cap on how many lines, it scrolls instead.
231
+ '.dts-menu-list{display:flex;flex-direction:column;gap:2px;flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2, #ddd) transparent}' +
232
+ '.dts-menu-list::-webkit-scrollbar{width:8px}' +
233
+ '.dts-menu-list::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2, #ddd);border-radius:4px}' +
234
+ '.dts-menu-list::-webkit-scrollbar-track{background:transparent}' +
235
+ '.dts-menu-text{flex:none;width:100%;box-sizing:border-box;padding:6px 10px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary, #111);font:inherit;text-align:left;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
236
+ '.dts-menu-text:hover{background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))}' +
237
+ // Transport cluster: same row as the reply actions, parked after the clock.
238
+ // DSH fades that row out (opacity:0) until you hover the message, so force
239
+ // it back on while a clip is playing โ€” otherwise the controls vanish.
240
+ '[data-actions-reveal]:has(.dts-transport) [class*="actions"]{opacity:1 !important}' +
241
+ '.dts-transport{display:inline-flex;align-items:center;gap:2px;margin-left:4px;order:999}' +
242
+ '.dts-tbtn{box-sizing:border-box;width:27px;height:27px;display:inline-flex;align-items:center;justify-content:center;padding:4px;border:none;border-radius:7px;background:none;color:var(--dsw-alias-label-tertiary, #999);cursor:pointer;transition:background .15s ease,color .15s ease}' +
243
+ '.dts-tbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12));color:var(--dsw-alias-label-primary, #111)}' +
244
+ '.dts-tbtn:disabled{opacity:.38;cursor:default}' +
245
+ '.dts-tbtn svg{width:16px;height:16px;display:block}' +
213
246
  '.dts-grow{flex:1;min-width:100px}'
214
247
 
215
248
  const setCssId = 'dsh-tts/settings.module.css'
@@ -257,6 +290,9 @@ window.__ModuleLoader__.load({
257
290
  player.blocked = false
258
291
  playerChanged()
259
292
  drainQueue()
293
+ // Anything the host synthesised while the browser was blocking playback is
294
+ // still queued server-side; pull it once the gesture opened the gate.
295
+ try { pollPending() } catch { /* host not ready yet */ }
260
296
  }
261
297
 
262
298
  function useAutoplayGate() {
@@ -285,6 +321,13 @@ window.__ModuleLoader__.load({
285
321
  get autoplayBlocked() { return autoplay.blocked },
286
322
  }
287
323
  const seenIds = new Set()
324
+ // Reply <div data-turn-tail> the current playback belongs to. Set when the
325
+ // per-message speaker starts a clip; null for auto-speech, where the newest
326
+ // reply hosts the transport instead.
327
+ let activeTailEl = null
328
+ // False until /dsh-tts/status answers: before that the `enabled` default is
329
+ // not a real reading, so stream items must not be discarded.
330
+ let statusKnown = false
288
331
  let currentAudioResolve = null
289
332
  function playerChanged() {
290
333
  for (const listener of [...player.listeners]) {
@@ -375,6 +418,14 @@ window.__ModuleLoader__.load({
375
418
  const { audio, url } = item.prepared
376
419
  audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
377
420
  player.audio = audio
421
+ if (item.audioBase64 && item.kind !== 'chime') {
422
+ // Keep the last clip in memory so replay is instant (no round trip).
423
+ player.lastItem = {
424
+ text: item.text || '',
425
+ mime: item.mime || 'audio/mpeg',
426
+ audioBase64: item.audioBase64,
427
+ }
428
+ }
378
429
  if (item.text) player.lastText = item.text
379
430
  playerChanged()
380
431
  prebufferNext()
@@ -454,6 +505,7 @@ window.__ModuleLoader__.load({
454
505
  }
455
506
 
456
507
  function stopPlayback() {
508
+ activeTailEl = null
457
509
  removeGestureListeners()
458
510
  player.blocked = false
459
511
  setAutoplayBlocked(false)
@@ -560,7 +612,7 @@ window.__ModuleLoader__.load({
560
612
  try {
561
613
  const item = JSON.parse(e.data)
562
614
  if (!item || !item.id) return
563
- if (player.enabled === false) return
615
+ if (statusKnown && player.enabled === false) return
564
616
  player.after = item.id
565
617
  if (seenIds.has(item.id)) return
566
618
  seenIds.add(item.id)
@@ -579,7 +631,7 @@ window.__ModuleLoader__.load({
579
631
  try {
580
632
  const item = JSON.parse(e.data)
581
633
  if (!item) return
582
- if (player.enabled === false) return
634
+ if (statusKnown && player.enabled === false) return
583
635
  player.queue.push(item)
584
636
  drainQueue()
585
637
  } catch (_err) { /* ignore malformed chime payload */ }
@@ -637,6 +689,7 @@ window.__ModuleLoader__.load({
637
689
  if (!st.ok) return
638
690
  const meta = await st.json()
639
691
  player.enabled = !!(meta && meta.speakReplies)
692
+ statusKnown = true
640
693
  if (meta && typeof meta.rate === 'number') player.rate = meta.rate
641
694
  if (meta && meta.chime) player.chime = meta.chime
642
695
  if (meta && meta.roles) player.roles = meta.roles
@@ -720,6 +773,36 @@ window.__ModuleLoader__.load({
720
773
  })
721
774
  }
722
775
 
776
+ // Replay the most recent clip: from memory when we still hold it, else ask
777
+ // the host (its synthesis cache makes the second round a fast path).
778
+ function replayLast() {
779
+ const last = player.lastItem
780
+ if (last && last.audioBase64) {
781
+ playAudioItem({
782
+ id: `replay-${Date.now()}`,
783
+ text: last.text,
784
+ mime: last.mime || 'audio/mpeg',
785
+ audioBase64: last.audioBase64,
786
+ })
787
+ return true
788
+ }
789
+ if (player.lastText) {
790
+ playStandalone(player.lastText)
791
+ return true
792
+ }
793
+ const lists = recent.list()
794
+ const fallback = (lists.favs && lists.favs[0]) || (lists.items && lists.items[0])
795
+ if (fallback) {
796
+ playStandalone(fallback)
797
+ return true
798
+ }
799
+ return false
800
+ }
801
+
802
+ function isSpeakingNow() {
803
+ return !!player.audio || player.busy || player.queue.length > 0
804
+ }
805
+
723
806
  function previewSpeech(provider, model, voice, text) {
724
807
  stopPlayback()
725
808
  return clientFetch('/dsh-tts/preview', {
@@ -813,6 +896,9 @@ window.__ModuleLoader__.load({
813
896
  'dockFavorite': 'Favorite',
814
897
  'dockUnfavorite': 'Unfavorite',
815
898
  'dockFavorites': 'Favorite clips',
899
+ 'speakMessage': 'Read this reply aloud',
900
+ 'speakMessageStop': 'Stop reading this reply',
901
+ 'speakMessageEmpty': 'Nothing to read in this reply',
816
902
  'exportAudio': 'Export audio clip',
817
903
  'exportSuccess': 'Audio exported',
818
904
  'statusOnline': 'Host online',
@@ -1039,6 +1125,9 @@ window.__ModuleLoader__.load({
1039
1125
  'dockFavorite': 'ๆ”ถ่—',
1040
1126
  'dockUnfavorite': 'ๅ–ๆถˆๆ”ถ่—',
1041
1127
  'dockFavorites': 'ๆ”ถ่—่ฏญ้Ÿณ',
1128
+ 'speakMessage': 'ๆœ—่ฏป่ฟ™ๆกๅ›žๅค',
1129
+ 'speakMessageStop': 'ๅœๆญขๆœ—่ฏป่ฟ™ๆกๅ›žๅค',
1130
+ 'speakMessageEmpty': '่ฟ™ๆกๅ›žๅคๆฒกๆœ‰ๅฏๆœ—่ฏป็š„ๅ†…ๅฎน',
1042
1131
  'exportAudio': 'ๅฏผๅ‡บ่ฏญ้Ÿณ็‰‡ๆฎต',
1043
1132
  'exportSuccess': '้Ÿณ้ข‘ๅทฒๅฏผๅ‡บ',
1044
1133
  'statusOnline': 'ๆœๅŠกๅœจ็บฟ',
@@ -1848,6 +1937,7 @@ window.__ModuleLoader__.load({
1848
1937
  setCredentials(data && data.credentials ? data.credentials : {})
1849
1938
  if (data && data.revision !== undefined) setRevision(data.revision)
1850
1939
  player.enabled = !!cfg.speakReplies
1940
+ statusKnown = true
1851
1941
  }
1852
1942
 
1853
1943
  React.useEffect(() => {
@@ -2222,81 +2312,341 @@ window.__ModuleLoader__.load({
2222
2312
  )
2223
2313
  }
2224
2314
 
2225
- // Input-dock button: shows what is playing, stop control, and recents for replay.
2226
- function SpeakerControl(props) {
2227
- // A slot with `locale` injects translations into props; the fallback is the
2228
- // binder created at registration, in case the slot drops translations.
2229
- const t = (props && props.t) || fallbackDockText
2315
+ // Per-message speak button in the reply action strip (left of the feedback
2316
+ // thumbs). The slot only hands over the durable message id, so the reply
2317
+ // text is read back from the rendered turn: the same prose blocks the
2318
+ // built-in copy button copies.
2319
+ function replyTextFromTurn(el) {
2320
+ try {
2321
+ const tail = el && el.closest ? el.closest('[data-turn-tail]') : null
2322
+ if (!tail) return ''
2323
+ const turn = tail.getAttribute('data-turn-tail')
2324
+ // The tail sits in its own flow item, so climb until an ancestor also
2325
+ // holds rendered reply blocks, then keep only this turn's ones.
2326
+ let scope = tail.parentElement
2327
+ for (let hops = 0; scope && hops < 8; hops++) {
2328
+ if (scope.querySelector('[data-chat-flow-kind="assistant-step"]')) break
2329
+ scope = scope.parentElement
2330
+ }
2331
+ if (!scope) return ''
2332
+ const collect = (inProcess) => {
2333
+ const parts = []
2334
+ for (const node of scope.querySelectorAll('[data-chat-flow-kind="assistant-step"]')) {
2335
+ if (turn !== null && node.getAttribute('data-chat-turn') !== turn) continue
2336
+ if (node.querySelector('[data-turn-tail]')) continue
2337
+ const nestedProcess = !!node.closest('[data-turn-process-hidden]')
2338
+ if (nestedProcess !== inProcess) continue
2339
+ const text = String(node.innerText || node.textContent || '').trim()
2340
+ if (text) parts.push(text)
2341
+ }
2342
+ return parts
2343
+ }
2344
+ // Prose first; fall back to the compacted process answers only when the
2345
+ // turn kept the reply inside the process accordion.
2346
+ let parts = collect(false)
2347
+ if (!parts.length) parts = collect(true)
2348
+ return parts.join('\n\n').trim()
2349
+ } catch (unreadable) {
2350
+ return ''
2351
+ }
2352
+ }
2353
+
2354
+ /** Rounded-square speaker chip, styled after the reference design. */
2355
+ function speakGlyph() {
2356
+ return React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true', focusable: 'false' },
2357
+ React.createElement('path', {
2358
+ d: 'M11 5.6 6.7 9H3.6v6h3.1L11 18.4z',
2359
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
2360
+ }),
2361
+ React.createElement('path', {
2362
+ 'data-wave': '1',
2363
+ d: 'M14.7 9.3a3.8 3.8 0 0 1 0 5.4',
2364
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round',
2365
+ }),
2366
+ React.createElement('path', {
2367
+ 'data-wave': '2',
2368
+ d: 'M17.8 6.5a8.2 8.2 0 0 1 0 11',
2369
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round',
2370
+ }),
2371
+ )
2372
+ }
2373
+
2374
+ // Transport glyphs for the in-row playback cluster.
2375
+ function transportIcon(name) {
2376
+ const stroke = { stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round' }
2377
+ const kids = []
2378
+ if (name === 'pause') {
2379
+ kids.push(React.createElement('rect', { key: 'l', x: 7, y: 5, width: 3.2, height: 14, rx: 1, fill: 'currentColor' }))
2380
+ kids.push(React.createElement('rect', { key: 'r', x: 13.8, y: 5, width: 3.2, height: 14, rx: 1, fill: 'currentColor' }))
2381
+ } else if (name === 'play') {
2382
+ kids.push(React.createElement('path', { key: 'p', d: 'M8 5.6 19 12 8 18.4z', fill: 'currentColor' }))
2383
+ } else if (name === 'stop') {
2384
+ kids.push(React.createElement('rect', { key: 's', x: 6.5, y: 6.5, width: 11, height: 11, rx: 2, fill: 'currentColor' }))
2385
+ } else if (name === 'skip') {
2386
+ kids.push(React.createElement('path', { key: 't', d: 'M6.5 5.8 14 12l-7.5 6.2z', fill: 'currentColor' }))
2387
+ kids.push(React.createElement('rect', { key: 'b', x: 15.6, y: 5.8, width: 2.4, height: 12.4, rx: 1, fill: 'currentColor' }))
2388
+ } else {
2389
+ kids.push(React.createElement('path', { key: 'a', d: 'M12 4.5v9.4', ...stroke }))
2390
+ kids.push(React.createElement('path', { key: 'b', d: 'm8.4 10.4 3.6 3.6 3.6-3.6', ...stroke }))
2391
+ kids.push(React.createElement('path', { key: 'c', d: 'M5.5 17.6h13', ...stroke }))
2392
+ }
2393
+ return React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true', focusable: 'false' }, kids)
2394
+ }
2395
+
2396
+ /** Does this reply own the transport cluster right now? */
2397
+ function ownsTransport(tail) {
2398
+ if (!tail) return false
2399
+ if (activeTailEl && activeTailEl.isConnected) return tail === activeTailEl || tail.contains(activeTailEl)
2400
+ // Auto-speech has no explicit owner: the newest reply carries the controls.
2401
+ const all = document.querySelectorAll('[data-turn-tail]')
2402
+ return all.length > 0 && all[all.length - 1] === tail
2403
+ }
2404
+
2405
+ function MessageSpeakAction(props) {
2406
+ const t = (props && props.t) || ((key) => key)
2230
2407
  const p = usePlayer()
2231
- const [showList, setShowList] = React.useState(false)
2232
- const active = !!p.audio || p.queue.length > 0 || p.busy
2233
- const [, force] = React.useReducer((n) => n + 1, 0)
2234
- const lists = recent.list()
2235
- const hasHistory = (lists.items && lists.items.length > 0) || (lists.favs && lists.favs.length > 0)
2408
+ const btnRef = React.useRef(null)
2409
+ const menuRef = React.useRef(null)
2410
+ const textRef = React.useRef('')
2411
+ const seqRef = React.useRef(0)
2412
+ const [busy, setBusy] = React.useState(false)
2413
+ const [warn, setWarn] = React.useState(false)
2414
+ const [menu, setMenu] = React.useState(null)
2415
+ const active = !busy && !!p.audio && textRef.current !== '' && p.lastText === textRef.current
2416
+
2417
+ React.useEffect(() => {
2418
+ if (!busy) return undefined
2419
+ const guard = setTimeout(() => setBusy(false), 20000)
2420
+ return () => clearTimeout(guard)
2421
+ }, [busy])
2422
+
2423
+ // Close the context menu on an outside press, Esc, or any scroll.
2424
+ React.useEffect(() => {
2425
+ if (!menu) return undefined
2426
+ const onDown = (event) => {
2427
+ if (menuRef.current && menuRef.current.contains(event.target)) return
2428
+ if (btnRef.current && btnRef.current.contains(event.target)) return
2429
+ setMenu(null)
2430
+ }
2431
+ const onKey = (event) => { if (event.key === 'Escape') setMenu(null) }
2432
+ // Scrolling inside the menu (its recent list) must not dismiss it.
2433
+ const onScroll = (event) => {
2434
+ if (event && menuRef.current && event.target && menuRef.current.contains(event.target)) return
2435
+ setMenu(null)
2436
+ }
2437
+ window.addEventListener('pointerdown', onDown, true)
2438
+ window.addEventListener('keydown', onKey, true)
2439
+ window.addEventListener('scroll', onScroll, true)
2440
+ window.addEventListener('resize', onScroll)
2441
+ return () => {
2442
+ window.removeEventListener('pointerdown', onDown, true)
2443
+ window.removeEventListener('keydown', onKey, true)
2444
+ window.removeEventListener('scroll', onScroll, true)
2445
+ window.removeEventListener('resize', onScroll)
2446
+ }
2447
+ }, [menu])
2448
+
2449
+ // Keep the whole menu on screen: shift it up when it would run past the fold.
2450
+ React.useEffect(() => {
2451
+ if (!menu || !menuRef.current) return
2452
+ const rect = menuRef.current.getBoundingClientRect()
2453
+ const overflow = rect.bottom - (window.innerHeight - 8)
2454
+ if (overflow > 0) {
2455
+ setMenu((current) => (current ? { x: current.x, y: Math.max(8, current.y - overflow) } : current))
2456
+ }
2457
+ }, [menu])
2458
+
2459
+ const tailOf = () => (btnRef.current && btnRef.current.closest ? btnRef.current.closest('[data-turn-tail]') : null)
2460
+
2461
+ const speak = (text, seq, tailEl) => {
2462
+ stopPlayback()
2463
+ return clientFetch('/dsh-tts/speak', {
2464
+ method: 'POST',
2465
+ headers: { 'Content-Type': 'application/json' },
2466
+ body: JSON.stringify({ text, channel: 'web' }),
2467
+ timeoutMs: 30000,
2468
+ })
2469
+ .then((r) => r.json())
2470
+ .then((d) => {
2471
+ // A second click cancels the first request; never play stale audio.
2472
+ if (seqRef.current !== seq) return false
2473
+ if (!d || !d.ok || !d.audioBase64) return false
2474
+ activeTailEl = tailEl
2475
+ playAudioItem({
2476
+ id: `reply-${Date.now()}`,
2477
+ text,
2478
+ mime: d.mime || 'audio/mpeg',
2479
+ audioBase64: d.audioBase64,
2480
+ })
2481
+ return true
2482
+ })
2483
+ .catch((failed) => {
2484
+ console.warn('[dsh-tts] reply speak failed:', failed)
2485
+ return false
2486
+ })
2487
+ }
2488
+
2489
+ const onClick = () => {
2490
+ if (menu) { setMenu(null); return }
2491
+ if (busy) {
2492
+ seqRef.current += 1
2493
+ stopPlayback()
2494
+ setBusy(false)
2495
+ return
2496
+ }
2497
+ if (active) {
2498
+ stopPlayback()
2499
+ return
2500
+ }
2501
+ const tailEl = tailOf()
2502
+ const text = replyTextFromTurn(btnRef.current)
2503
+ if (!text) {
2504
+ setWarn(true)
2505
+ setTimeout(() => setWarn(false), 1600)
2506
+ return
2507
+ }
2508
+ // Already synthesised this clip? Play it from memory instead of the network.
2509
+ const last = player.lastItem
2510
+ if (last && last.audioBase64 && last.text === text) {
2511
+ textRef.current = text
2512
+ activeTailEl = tailEl
2513
+ playAudioItem({
2514
+ id: `replay-${Date.now()}`,
2515
+ text,
2516
+ mime: last.mime || 'audio/mpeg',
2517
+ audioBase64: last.audioBase64,
2518
+ })
2519
+ return
2520
+ }
2521
+ const seq = seqRef.current + 1
2522
+ seqRef.current = seq
2523
+ textRef.current = text
2524
+ setWarn(false)
2525
+ setBusy(true)
2526
+ speak(text, seq, tailEl).then((ok) => {
2527
+ if (seqRef.current !== seq) return
2528
+ setBusy(false)
2529
+ if (!ok) {
2530
+ setWarn(true)
2531
+ setTimeout(() => setWarn(false), 1600)
2532
+ }
2533
+ })
2534
+ }
2236
2535
 
2237
- // Hide only if completely idle and no recent/favorites history (#161)
2238
- if (!active && !hasHistory && !showList && !p.blocked) return null
2536
+ const openMenu = (event) => {
2537
+ event.preventDefault()
2538
+ event.stopPropagation()
2539
+ const box = btnRef.current ? btnRef.current.getBoundingClientRect() : { left: event.clientX, bottom: event.clientY }
2540
+ const x = Math.max(8, Math.min(Math.round(box.left) - 8, window.innerWidth - 312))
2541
+ // Open just under the button; the measuring effect above shifts the menu
2542
+ // up if the (now scrollable, possibly tall) list would run past the fold.
2543
+ const y = Math.max(8, Math.min(Math.round(box.bottom) + 6, window.innerHeight - 120))
2544
+ setMenu({ x, y })
2545
+ }
2239
2546
 
2240
- const row = (text, starred) => React.createElement('div', { className: 'dts-row', key: text.slice(0, 24) + starred },
2547
+ const run = (fn) => () => { setMenu(null); fn() }
2548
+
2549
+ const label = active ? t('speakMessageStop') : t('speakMessage')
2550
+ const title = warn ? t('speakMessageEmpty') : label
2551
+ const button = React.createElement('button', {
2552
+ ref: btnRef,
2553
+ type: 'button',
2554
+ className: 'dts-act',
2555
+ title,
2556
+ 'aria-label': title,
2557
+ 'aria-pressed': active || undefined,
2558
+ 'data-active': active || undefined,
2559
+ 'data-busy': busy || undefined,
2560
+ onClick,
2561
+ onContextMenu: openMenu,
2562
+ }, speakGlyph())
2563
+
2564
+ // Playback transport lives at the end of the same row, after the clock.
2565
+ const tail = tailOf()
2566
+ const speaking = isSpeakingNow()
2567
+ const transport = speaking && ownsTransport(tail) ? React.createElement('div', { className: 'dts-transport' },
2568
+ React.createElement('button', {
2569
+ type: 'button', className: 'dts-tbtn', title: p.paused ? t('dockResume') : t('dockPause'),
2570
+ 'aria-label': p.paused ? t('dockResume') : t('dockPause'),
2571
+ onClick: () => togglePause(),
2572
+ }, transportIcon(p.paused ? 'play' : 'pause')),
2241
2573
  React.createElement('button', {
2242
- type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
2243
- title: text,
2244
- onClick: () => playStandalone(text),
2245
- }, text.slice(0, 80)),
2574
+ type: 'button', className: 'dts-tbtn', title: t('dockStop'), 'aria-label': t('dockStop'),
2575
+ onClick: () => stopPlayback(),
2576
+ }, transportIcon('stop')),
2246
2577
  React.createElement('button', {
2247
- type: 'button', className: 'dts-link', title: 'โ˜…',
2248
- onClick: () => { recent.toggleFav(text); force() },
2249
- }, starred ? 'โ˜…' : 'โ˜†'),
2578
+ type: 'button', className: 'dts-tbtn', title: t('dockSkip'), 'aria-label': t('dockSkip'),
2579
+ onClick: () => skipCurrent(),
2580
+ }, transportIcon('skip')),
2250
2581
  React.createElement('button', {
2251
- type: 'button', className: 'dts-link', title: t('exportAudio') || 'Export',
2252
- onClick: () => exportAudioClip(text),
2253
- }, 'โค“'),
2582
+ type: 'button', className: 'dts-tbtn', title: t('exportAudio'), 'aria-label': t('exportAudio'),
2583
+ onClick: () => exportAudioClip(player.lastText),
2584
+ }, transportIcon('export')),
2585
+ ) : null
2586
+
2587
+ if (!menu) return React.createElement(React.Fragment, null, button, transport)
2588
+
2589
+ // Recents only, all of them: the menu scrolls when the list is longer than
2590
+ // the popup. Playback controls stay in the reply row, right of the clock.
2591
+ const history = (recent.list().items || [])
2592
+
2593
+ const row = (text, index) => React.createElement('button', {
2594
+ key: `recent-${index}`,
2595
+ type: 'button',
2596
+ className: 'dts-menu-text',
2597
+ title: text,
2598
+ onClick: run(() => playStandalone(text)),
2599
+ }, text.slice(0, 80))
2600
+
2601
+ const menuEl = React.createElement('div', {
2602
+ ref: menuRef,
2603
+ className: 'dts-menu',
2604
+ role: 'menu',
2605
+ style: { left: `${menu.x}px`, top: `${menu.y}px` },
2606
+ onContextMenu: (event) => { event.preventDefault(); event.stopPropagation() },
2607
+ },
2608
+ history.length
2609
+ ? React.createElement('div', {
2610
+ className: 'dts-menu-list',
2611
+ // Wheel over the list scrolls the list, not the conversation.
2612
+ onWheel: (event) => event.stopPropagation(),
2613
+ }, history.map((text, index) => row(text, index)))
2614
+ : React.createElement('div', { className: 'dts-menu-item', style: { opacity: 0.45, cursor: 'default' } },
2615
+ React.createElement('span', null, t('recentEmpty')),
2616
+ ),
2254
2617
  )
2618
+
2619
+ return React.createElement(React.Fragment, null, button, transport, menuEl)
2620
+ }
2621
+
2622
+ function registerMessageAction(ctx) {
2623
+ try {
2624
+ ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register(
2625
+ {
2626
+ name: 'conversation.chat.assistant-actions',
2627
+ id: 'dsh-tts-speak',
2628
+ // 10 is the feedback thumbs; a lower order keeps us to their left.
2629
+ order: 5,
2630
+ locale: NS,
2631
+ },
2632
+ MessageSpeakAction,
2633
+ ))
2634
+ } catch (seatUnavailable) {
2635
+ console.warn('[dsh-tts] reply action seat not available:', seatUnavailable && seatUnavailable.message)
2636
+ }
2637
+ }
2638
+
2639
+ // The dock keeps only the "sound is blocked" hint now: the transport moved
2640
+ // into the reply row, to the right of the message clock.
2641
+ function SpeakerControl(props) {
2642
+ const t = (props && props.t) || fallbackDockText
2643
+ const p = usePlayer()
2644
+ if (!p.blocked) return null
2255
2645
  return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
2256
- p.blocked ? React.createElement('button', {
2646
+ React.createElement('button', {
2257
2647
  type: 'button', className: 'dts-link dts-warn', title: t('unlockAudio') || 'Click to unlock audio',
2258
2648
  onClick: unlockAudio,
2259
- }, '๐Ÿ””') : null,
2260
- (hasHistory || active) ? React.createElement('button', {
2261
- type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
2262
- onClick: () => setShowList((v) => !v),
2263
- }, 'โ˜ฐ') : null,
2264
- showList ? React.createElement('div', {
2265
- className: 'dts-wrap',
2266
- style: { position: 'absolute', bottom: '36px', right: 0, zIndex: 30, maxHeight: '320px', overflowY: 'auto', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '12px', padding: '10px 12px', minWidth: '260px', flexDirection: 'column', gap: '6px', display: 'flex' },
2267
- },
2268
- lists.favs.length ? React.createElement('div', { className: 'dts-h' }, 'โ˜…') : null,
2269
- lists.favs.map((text) => row(text, true)),
2270
- React.createElement('div', { className: 'dts-h' }, 'โŸณ'),
2271
- lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
2272
- !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
2273
- ) : null,
2274
- active ? React.createElement(React.Fragment, null,
2275
- React.createElement('button', {
2276
- type: 'button',
2277
- className: 'dts-link',
2278
- title: p.paused ? t('dockResume') : t('dockPause'),
2279
- onClick: togglePause,
2280
- }, p.paused ? 'โ–ถ' : 'โšโš'),
2281
- React.createElement('button', {
2282
- type: 'button',
2283
- className: 'dts-link',
2284
- title: t('dockStop'),
2285
- onClick: stopPlayback,
2286
- }, 'โ– '),
2287
- React.createElement('button', {
2288
- type: 'button',
2289
- className: 'dts-link',
2290
- title: t('dockSkip') || 'Skip',
2291
- onClick: skipCurrent,
2292
- }, 'โญ'),
2293
- React.createElement('button', {
2294
- type: 'button',
2295
- className: 'dts-link',
2296
- title: t('exportAudio') || 'Export clip',
2297
- onClick: () => exportAudioClip(player.lastText),
2298
- }, 'โค“'),
2299
- ) : null,
2649
+ }, '๐Ÿ””'),
2300
2650
  )
2301
2651
  }
2302
2652
 
@@ -2416,10 +2766,20 @@ window.__ModuleLoader__.load({
2416
2766
  exports.apply = function apply(ctx) {
2417
2767
  registerSettings(ctx)
2418
2768
  registerSpeaker(ctx)
2769
+ registerMessageAction(ctx)
2419
2770
  ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
2420
2771
  ctx.effect(() => {
2421
2772
  if (typeof window === 'undefined') return () => {}
2422
2773
  const onKey = (e) => {
2774
+ // Shift+Space: stop while speaking, otherwise replay the last clip
2775
+ // (memory first, then the host cache) so a finished reply is one key away.
2776
+ if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && (e.code === 'Space' || e.key === ' ')) {
2777
+ e.preventDefault()
2778
+ if (e.repeat) return
2779
+ if (isSpeakingNow()) stopPlayback()
2780
+ else replayLast()
2781
+ return
2782
+ }
2423
2783
  if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
2424
2784
  else if (e.altKey && (e.code === 'KeyS' || e.key === 's' || e.key === 'S')) { stopPlayback(); e.preventDefault() }
2425
2785
  else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
@@ -2429,6 +2789,9 @@ window.__ModuleLoader__.load({
2429
2789
  }, 'dsh-tts: hotkeys')
2430
2790
  ctx.effect(() => startSseStream(), 'dsh-tts: SSE audio stream')
2431
2791
  ctx.effect(() => {
2792
+ // Prime the status flag right away: autoplay used to wait out the first
2793
+ // interval tick, which dropped early stream items on a fresh start.
2794
+ pollPending()
2432
2795
  const timer = setInterval(pollPending, 1000)
2433
2796
  return () => clearInterval(timer)
2434
2797
  }, 'dsh-tts: poll pending audio')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.33",
3
+ "version": "0.4.34",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",