@goodandready/dsh-voice 0.9.30 → 0.9.31

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/lib/client.js CHANGED
@@ -132,6 +132,14 @@ window.__ModuleLoader__.load({
132
132
  'bargeIn': 'Barge-in',
133
133
  'polishSend': 'Polish whole draft before sending',
134
134
  'polishSendHint': 'Run the composed draft through the model right before sending',
135
+ 'editablePreview': 'Editable preview before send',
136
+ 'editablePreviewHint': 'Show recognized text before sending with editing, comparison and abort options.',
137
+ 'editPreview': 'Edit',
138
+ 'sendNow': 'Send',
139
+ 'cancelPreview': 'Cancel',
140
+ 'rawText': 'Raw',
141
+ 'polishedText': 'Polished',
142
+ 'sessionMismatch': 'Different chat active; canceled to prevent leak.',
135
143
  'sessionCommands': 'Voice session commands',
136
144
  'sessionCommandsHint': '"send", "cancel", "stop", "continue" act on the session instead of becoming text',
137
145
  'polishBaseUrl': 'Offline polish endpoint',
@@ -350,6 +358,14 @@ window.__ModuleLoader__.load({
350
358
  'bargeIn': '语音插话打断 (Barge-in)',
351
359
  'polishSend': '发送前润色整篇草稿',
352
360
  'polishSendHint': '发送给智能体前对整个输入框草稿进行一次整体润色',
361
+ 'editablePreview': '发送前可编辑预览',
362
+ 'editablePreviewHint': '发送前显示识别文本,支持编辑、润色对比和撤销。',
363
+ 'editPreview': '编辑',
364
+ 'sendNow': '发送',
365
+ 'cancelPreview': '取消',
366
+ 'rawText': '原文',
367
+ 'polishedText': '润色',
368
+ 'sessionMismatch': '当前会话已切换,已拦截发送以防止串话。',
353
369
  'sessionCommands': '会话语音控制指令',
354
370
  'sessionCommandsHint': '"send"、"cancel"、"stop"、"continue" 执行操作而非转为文本',
355
371
  'polishBaseUrl': '离线润色服务端点',
@@ -518,7 +534,20 @@ window.__ModuleLoader__.load({
518
534
  '.dvo-progress-bar{height:6px;width:100%;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden}' +
519
535
  '.dvo-progress-fill{height:100%;background:var(--dsw-alias-state-info-primary);transition:width 150ms ease}' +
520
536
  '.dvo-jargon-table{width:100%;margin-top:6px;border-collapse:collapse;font-size:12px}' +
521
- '.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}'
537
+ '.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}' +
538
+ '.dvo-pill-preview{display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-radius:18px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box}' +
539
+ '.dvo-preview-header{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%}' +
540
+ '.dvo-preview-tabs{display:inline-flex;align-items:center;gap:4px;background:var(--dsw-alias-bg-layer-2);padding:2px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1)}' +
541
+ '.dvo-tab-btn{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:500;padding:3px 10px;border-radius:10px;cursor:pointer;transition:all 150ms ease}' +
542
+ '.dvo-tab-btn:hover{color:var(--dsw-alias-label-primary)}' +
543
+ '.dvo-tab-btn-active{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 1px 3px color-mix(in srgb, var(--dsw-alias-label-primary) 15%, transparent)}' +
544
+ '.dvo-preview-body{display:flex;align-items:center;gap:8px;width:100%}' +
545
+ '.dvo-preview-text{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);line-height:1.4;word-break:break-word;max-height:80px;overflow-y:auto;padding:2px 0}' +
546
+ '.dvo-preview-input{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;outline:none;font-family:inherit;width:100%}' +
547
+ '.dvo-preview-input:focus{border-color:var(--dsw-alias-state-info-primary)}' +
548
+ '.dvo-btn-send{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:none;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);transition:opacity 150ms ease}' +
549
+ '.dvo-btn-send:hover{opacity:0.9}' +
550
+ '.dvo-warn-banner{font-size:12px;color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-error-primary);padding:4px 8px;border-radius:6px;margin-top:4px}'
522
551
  const cssId = 'dsh-voice/client.module.css'
523
552
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
524
553
  const tag = document.createElement('style')
@@ -543,7 +572,7 @@ window.__ModuleLoader__.load({
543
572
  inputActions: null,
544
573
  input: null,
545
574
  settings: {
546
- vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0,
575
+ vadSilenceMs: 700, autoSendMs: 4000, editablePreview: true, stream: false, streamChunkMs: 1200, vadAdapt: 0,
547
576
  noiseSuppression: true, contextGlossary: true,
548
577
  gatedTurnTaking: true, autoSendVisualRing: true, voiceActions: true,
549
578
  techJargonCorrection: true, jargonDictionary: [], liveInterimPreview: true,
@@ -1458,7 +1487,11 @@ voice._chainsPromise = null
1458
1487
  if (targetActions && typeof targetActions.captureInsertion === 'function') {
1459
1488
  try { span = targetActions.captureInsertion() } catch (_) {}
1460
1489
  }
1461
- const target = { actions: targetActions, input: targetInput, span }
1490
+ const initialDraft = (targetInput && typeof targetInput.draft === 'string') ? targetInput.draft : ''
1491
+ const targetSession = voice.session || (targetInput && targetInput.session)
1492
+ const sessionId = (targetSession && (targetSession.sessionId || targetSession.id))
1493
+ || voice.currentSessionId || ''
1494
+ const target = { actions: targetActions, input: targetInput, session: targetSession, span, initialDraft, sessionId }
1462
1495
  voice._activeTarget = target
1463
1496
 
1464
1497
  if (voice.isTtsSpeaking) {
@@ -1539,7 +1572,12 @@ voice._chainsPromise = null
1539
1572
  if (mode === 'message') {
1540
1573
  const ok = appendDraft(said, target)
1541
1574
  if (ok !== false) {
1542
- voice.set({ phase: 'pending', pending: { text: said, target, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs } })
1575
+ const usePreview = voice.settings.editablePreview !== false
1576
+ const autoMs = Number(voice.settings.autoSendMs) || 4000
1577
+ const pendingState = usePreview
1578
+ ? initPendingPreview(said, target, { autoSendMs: autoMs })
1579
+ : { text: said, target, totalMs: autoMs, leftMs: autoMs }
1580
+ voice.set({ phase: 'pending', pending: pendingState })
1543
1581
  } else {
1544
1582
  voice.set({ phase: 'idle' })
1545
1583
  }
@@ -1610,7 +1648,24 @@ voice._chainsPromise = null
1610
1648
  if (voice.lastNote) voice.lastNote.text = text
1611
1649
  if (mode === 'message') {
1612
1650
  if (ok !== false) {
1613
- voice.set({ phase: 'pending', pending: { text, target, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
1651
+ const usePreview = voice.settings.editablePreview !== false
1652
+ let polishedText = text
1653
+ if (usePreview && voice.settings.polishSend) {
1654
+ try {
1655
+ const pRes = await fetch('/dsh-voice/polish', {
1656
+ method: 'POST',
1657
+ headers: { 'content-type': 'application/json' },
1658
+ body: JSON.stringify({ text }),
1659
+ })
1660
+ const pData = await pRes.json()
1661
+ if (pData && pData.text) polishedText = pData.text
1662
+ } catch (_) {}
1663
+ }
1664
+ const autoMs = Number(voice.settings.autoSendMs) || 4000
1665
+ const pendingState = usePreview
1666
+ ? initPendingPreview(text, target, { autoSendMs: autoMs, polishedText, audioUrl: noteUrl, polishSend: voice.settings.polishSend })
1667
+ : { text, target, totalMs: autoMs, leftMs: autoMs, audioUrl: noteUrl }
1668
+ voice.set({ phase: 'pending', pending: pendingState })
1614
1669
  } else {
1615
1670
  voice.set({ phase: 'idle' })
1616
1671
  }
@@ -1658,38 +1713,11 @@ voice._chainsPromise = null
1658
1713
  }
1659
1714
 
1660
1715
  function submitPending() {
1661
- const pending = voice.pending
1662
- if (!pending) return
1663
- const text = pending.text
1664
- const target = pending.target || voice._activeTarget
1665
- const actions = (target && target.actions) || voice.inputActions
1666
- voice.pending = null
1667
- voice.set({ phase: 'idle' })
1716
+ submitPendingPreview(voice)
1717
+ }
1668
1718
 
1669
- if (voice.settings.polishSend) {
1670
- fetch('/dsh-voice/polish', {
1671
- method: 'POST',
1672
- headers: { 'content-type': 'application/json' },
1673
- body: JSON.stringify({ text }),
1674
- })
1675
- .then((res) => res.json())
1676
- .then((data) => {
1677
- const polished = (data && data.text) || text
1678
- if (actions && typeof actions.setDraft === 'function') {
1679
- actions.setDraft(polished)
1680
- }
1681
- if (actions && typeof actions.submit === 'function') {
1682
- actions.submit()
1683
- }
1684
- })
1685
- .catch(() => {
1686
- if (actions && typeof actions.submit === 'function') {
1687
- actions.submit()
1688
- }
1689
- })
1690
- } else if (actions && typeof actions.submit === 'function') {
1691
- actions.submit()
1692
- }
1719
+ function cancelPending() {
1720
+ cancelPendingPreview(voice)
1693
1721
  }
1694
1722
 
1695
1723
  function runSessionCommand(cmd) {
@@ -1700,8 +1728,7 @@ voice._chainsPromise = null
1700
1728
  }
1701
1729
 
1702
1730
  function keepPending() {
1703
- voice.pending = null
1704
- voice.set({ phase: 'idle' })
1731
+ keepPendingPreview(voice)
1705
1732
  }
1706
1733
  // ----------------------------------------------------------- components
1707
1734
  function VoiceButtons(props) {
@@ -1930,6 +1957,258 @@ voice._chainsPromise = null
1930
1957
  }
1931
1958
  return cleanup
1932
1959
  }
1960
+ // ------------------------------------------------------------------ 43-preview
1961
+ // Editable preview state machine, session pinning & UI component (#170).
1962
+
1963
+ function initPendingPreview(text, target, options = {}) {
1964
+ const autoMs = Number(options.autoSendMs) || 4000
1965
+ const polishedText = options.polishedText || text
1966
+ const isPolished = options.polishSend && polishedText && polishedText !== text
1967
+ const initialDraft = (target && typeof target.initialDraft === 'string')
1968
+ ? target.initialDraft
1969
+ : ((target && target.input && typeof target.input.draft === 'string') ? target.input.draft : '')
1970
+ const sessionId = (target && target.sessionId) || ''
1971
+
1972
+ return {
1973
+ text,
1974
+ rawText: text,
1975
+ polishedText,
1976
+ activeText: isPolished ? polishedText : text,
1977
+ selectedTab: isPolished ? 'polished' : 'raw',
1978
+ isEditing: false,
1979
+ target,
1980
+ sessionId,
1981
+ initialDraft,
1982
+ totalMs: autoMs,
1983
+ leftMs: autoMs,
1984
+ paused: false,
1985
+ audioUrl: options.audioUrl || '',
1986
+ sessionMismatch: false,
1987
+ }
1988
+ }
1989
+
1990
+ function cancelPendingPreview(voice) {
1991
+ const pending = voice.pending
1992
+ if (!pending) {
1993
+ voice.set({ phase: 'idle' })
1994
+ return
1995
+ }
1996
+ const target = pending.target || voice._activeTarget
1997
+ const actions = (target && target.actions) || voice.inputActions
1998
+
1999
+ // Restore untouched composer draft if preview was active
2000
+ if (pending.rawText !== undefined && pending.initialDraft !== undefined) {
2001
+ if (actions && typeof actions.setDraft === 'function') {
2002
+ try { actions.setDraft(pending.initialDraft) } catch (_) {}
2003
+ }
2004
+ }
2005
+ voice.pending = null
2006
+ voice.set({ phase: 'idle' })
2007
+ }
2008
+
2009
+ function keepPendingPreview(voice) {
2010
+ const pending = voice.pending
2011
+ if (!pending) {
2012
+ voice.set({ phase: 'idle' })
2013
+ return
2014
+ }
2015
+ const target = pending.target || voice._activeTarget
2016
+ const actions = (target && target.actions) || voice.inputActions
2017
+
2018
+ if (pending.rawText !== undefined) {
2019
+ // Insert final edited text into composer draft without auto-sending
2020
+ const initial = pending.initialDraft || ''
2021
+ const textToInsert = pending.activeText || ''
2022
+ const full = initial
2023
+ ? (initial.endsWith(' ') || initial.endsWith('\n') ? (initial + textToInsert) : (initial + ' ' + textToInsert))
2024
+ : textToInsert
2025
+ if (actions && typeof actions.setDraft === 'function') {
2026
+ try { actions.setDraft(full) } catch (_) {}
2027
+ }
2028
+ }
2029
+ voice.pending = null
2030
+ voice.set({ phase: 'idle' })
2031
+ }
2032
+
2033
+ function submitPendingPreview(voice) {
2034
+ const pending = voice.pending
2035
+ if (!pending) return
2036
+ const target = pending.target || voice._activeTarget
2037
+ const actions = (target && target.actions) || voice.inputActions
2038
+
2039
+ // Session guard (#170): verify that current session matches initiating session
2040
+ const currentSessionId = (target && target.session && (target.session.sessionId || target.session.id))
2041
+ || (voice.session && (voice.session.sessionId || voice.session.id))
2042
+ || voice.currentSessionId || ''
2043
+
2044
+ if (pending.sessionId && currentSessionId && pending.sessionId !== currentSessionId) {
2045
+ pending.sessionMismatch = true
2046
+ pending.paused = true
2047
+ voice.notify()
2048
+ return
2049
+ }
2050
+
2051
+ const textToSend = (pending.rawText !== undefined ? pending.activeText : pending.text) || ''
2052
+ voice.pending = null
2053
+ voice.set({ phase: 'idle' })
2054
+
2055
+ if (pending.rawText !== undefined) {
2056
+ const initial = pending.initialDraft || ''
2057
+ const full = initial
2058
+ ? (initial.endsWith(' ') || initial.endsWith('\n') ? (initial + textToSend) : (initial + ' ' + textToSend))
2059
+ : textToSend
2060
+ if (actions && typeof actions.setDraft === 'function') {
2061
+ try { actions.setDraft(full) } catch (_) {}
2062
+ }
2063
+ if (actions && typeof actions.submit === 'function') {
2064
+ try { actions.submit() } catch (_) {}
2065
+ }
2066
+ } else {
2067
+ // Legacy path without rich preview
2068
+ if (voice.settings.polishSend) {
2069
+ fetch('/dsh-voice/polish', {
2070
+ method: 'POST',
2071
+ headers: { 'content-type': 'application/json' },
2072
+ body: JSON.stringify({ text: textToSend }),
2073
+ })
2074
+ .then((res) => res.json())
2075
+ .then((data) => {
2076
+ const polished = (data && data.text) || textToSend
2077
+ if (actions && typeof actions.setDraft === 'function') actions.setDraft(polished)
2078
+ if (actions && typeof actions.submit === 'function') actions.submit()
2079
+ })
2080
+ .catch(() => {
2081
+ if (actions && typeof actions.submit === 'function') actions.submit()
2082
+ })
2083
+ } else if (actions && typeof actions.submit === 'function') {
2084
+ actions.submit()
2085
+ }
2086
+ }
2087
+ }
2088
+
2089
+ function renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef) {
2090
+ const p = voice.pending
2091
+ const total = p.totalMs || Number(voice.settings.autoSendMs) || 4000
2092
+ const leftMs = Math.max(0, p.leftMs || 0)
2093
+ const left = Math.ceil(leftMs / 1000)
2094
+ const frac = total > 0 ? Math.max(0, Math.min(1, leftMs / total)) : 0
2095
+ const circ = 2 * Math.PI * 14
2096
+ const off = circ * (1 - frac)
2097
+ const hasPolish = p.polishedText && p.polishedText !== p.rawText
2098
+
2099
+ return React.createElement('div', { className: 'dvo-pill-preview', role: 'region', 'aria-label': t('editablePreview') },
2100
+ // Header row: tabs, audio playback, status/countdown, cancel button
2101
+ React.createElement('div', { className: 'dvo-preview-header' },
2102
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2103
+ hasPolish
2104
+ ? React.createElement('div', { className: 'dvo-preview-tabs' },
2105
+ React.createElement('button', {
2106
+ type: 'button',
2107
+ className: 'dvo-tab-btn' + (p.selectedTab === 'raw' ? ' dvo-tab-btn-active' : ''),
2108
+ onClick: () => {
2109
+ p.selectedTab = 'raw'
2110
+ p.activeText = p.rawText
2111
+ voice.notify()
2112
+ },
2113
+ }, t('rawText')),
2114
+ React.createElement('button', {
2115
+ type: 'button',
2116
+ className: 'dvo-tab-btn' + (p.selectedTab === 'polished' ? ' dvo-tab-btn-active' : ''),
2117
+ onClick: () => {
2118
+ p.selectedTab = 'polished'
2119
+ p.activeText = p.polishedText
2120
+ voice.notify()
2121
+ },
2122
+ }, t('polishedText')),
2123
+ )
2124
+ : React.createElement('span', { className: 'dvo-count' }, t('rawText')),
2125
+ p.audioUrl
2126
+ ? React.createElement('div', { className: 'dvo-audio-wrap' },
2127
+ React.createElement('button', {
2128
+ type: 'button', className: 'dvo-audio-play',
2129
+ title: isPlaying ? t('pause') : t('play'),
2130
+ 'aria-label': isPlaying ? t('pause') : t('play'),
2131
+ onClick: () => {
2132
+ const el = audioRef.current
2133
+ if (!el) return
2134
+ if (el.paused) { el.play().catch(() => {}); setIsPlaying(true) }
2135
+ else { el.pause(); setIsPlaying(false) }
2136
+ },
2137
+ }, isPlaying ? pauseIcon() : playIcon()),
2138
+ React.createElement('audio', {
2139
+ ref: audioRef, src: p.audioUrl,
2140
+ onEnded: () => setIsPlaying(false),
2141
+ onPause: () => setIsPlaying(false),
2142
+ onPlay: () => setIsPlaying(true),
2143
+ }),
2144
+ React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
2145
+ )
2146
+ : null,
2147
+ ),
2148
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2149
+ (!p.paused && !p.isEditing && v.settings.autoSendVisualRing !== false)
2150
+ ? React.createElement('svg', { className: 'dvo-silence-ring', viewBox: '0 0 34 34' },
2151
+ React.createElement('circle', { cx: 17, cy: 17, r: 14, className: 'dvo-ring-bg' }),
2152
+ React.createElement('circle', {
2153
+ cx: 17, cy: 17, r: 14, className: 'dvo-ring-fg',
2154
+ strokeDasharray: `${circ} ${circ}`,
2155
+ strokeDashoffset: off,
2156
+ }),
2157
+ )
2158
+ : null,
2159
+ React.createElement('span', { className: 'dvo-count' },
2160
+ (p.paused || p.isEditing) ? '' : (left + t('secondsShort'))
2161
+ ),
2162
+ React.createElement('button', {
2163
+ type: 'button', className: 'dvo-pbtn', title: t('cancelPreview'),
2164
+ 'aria-label': t('cancelPreview'),
2165
+ onClick: () => { cancelPending(); focusComposer() },
2166
+ }, xIcon()),
2167
+ ),
2168
+ ),
2169
+ // Body row: editable input or preview text, Edit button, Send button
2170
+ React.createElement('div', { className: 'dvo-preview-body' },
2171
+ p.isEditing
2172
+ ? React.createElement('input', {
2173
+ type: 'text',
2174
+ className: 'dvo-preview-input',
2175
+ value: p.activeText,
2176
+ autoFocus: true,
2177
+ onChange: (e) => {
2178
+ p.activeText = e.target.value
2179
+ voice.notify()
2180
+ },
2181
+ onKeyDown: (e) => {
2182
+ if (e.key === 'Enter' && !e.shiftKey) {
2183
+ e.preventDefault()
2184
+ submitPending()
2185
+ }
2186
+ },
2187
+ })
2188
+ : React.createElement('span', { className: 'dvo-preview-text' }, p.activeText),
2189
+ !p.isEditing
2190
+ ? React.createElement('button', {
2191
+ type: 'button', className: 'dvo-pbtn', title: t('editPreview'),
2192
+ 'aria-label': t('editPreview'),
2193
+ onClick: () => {
2194
+ p.isEditing = true
2195
+ p.paused = true
2196
+ voice.notify()
2197
+ },
2198
+ }, editIcon())
2199
+ : null,
2200
+ React.createElement('button', {
2201
+ type: 'button', className: 'dvo-btn-send', title: t('sendNow'),
2202
+ 'aria-label': t('sendNow'),
2203
+ onClick: () => { submitPending(); focusComposer() },
2204
+ }, sendIcon()),
2205
+ ),
2206
+ // Mismatch warning banner
2207
+ p.sessionMismatch
2208
+ ? React.createElement('div', { className: 'dvo-warn-banner' }, t('sessionMismatch'))
2209
+ : null,
2210
+ )
2211
+ }
1933
2212
  // ----------------------------------------------------------- visualizers
1934
2213
  let _accentColorCache = { color: '', expires: 0 }
1935
2214
  let _softColorCache = { color: '', expires: 0 }
@@ -2071,6 +2350,8 @@ voice._chainsPromise = null
2071
2350
  const [isPlaying, setIsPlaying] = React.useState(false)
2072
2351
  voice.inputActions = props.inputActions
2073
2352
  voice.input = props.input
2353
+ voice.session = props.session
2354
+ voice.currentSessionId = (props.session && (props.session.sessionId || props.session.id)) || voice.currentSessionId || ''
2074
2355
  const ctx = props.ctx
2075
2356
 
2076
2357
  // VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
@@ -2194,6 +2475,10 @@ voice._chainsPromise = null
2194
2475
  else voice.notify()
2195
2476
  return
2196
2477
  }
2478
+ if (p.paused || p.isEditing) {
2479
+ // Confirmation or editing mode freezes auto-send countdown (#170)
2480
+ return
2481
+ }
2197
2482
  p.leftMs -= tick
2198
2483
  if (p.leftMs <= 0) { submitPending(); return }
2199
2484
  voice.notify()
@@ -2279,6 +2564,9 @@ voice._chainsPromise = null
2279
2564
  }
2280
2565
 
2281
2566
  if (v.phase === 'pending') {
2567
+ if (voice.pending && voice.pending.rawText !== undefined) {
2568
+ return renderEditablePreviewPill(voice, v, isPlaying, setIsPlaying, audioRef)
2569
+ }
2282
2570
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
2283
2571
  if (voice.pending && voice.pending.undoOnly) {
2284
2572
  // Cancel window for delayed dictation insert (#29-5).
@@ -2433,7 +2721,7 @@ voice._chainsPromise = null
2433
2721
  ))
2434
2722
  const offDock = ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
2435
2723
  { name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, locale: NS, label: () => t('recordSlot') },
2436
- (props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, ctx: ctx }),
2724
+ (props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, session: props.session, ctx: ctx }),
2437
2725
  ))
2438
2726
  return () => {
2439
2727
  if (typeof offRight === 'function') offRight()
@@ -3491,6 +3779,7 @@ voice._chainsPromise = null
3491
3779
  wakeWord: String(val.wakeWord || ''),
3492
3780
  bargeIn: !!val.bargeIn,
3493
3781
  polishSend: !!(val.message && val.message.polishSend),
3782
+ editablePreview: val.message ? val.message.editablePreview !== false : true,
3494
3783
  sessionCommands: !!(val.message && val.message.sessionCommands),
3495
3784
  polishBaseUrl: String(val.polishBaseUrl || ''),
3496
3785
  noiseSuppression: val.noiseSuppression !== false,
@@ -4041,6 +4330,12 @@ voice._chainsPromise = null
4041
4330
  cancelCurrent,
4042
4331
  stopCurrent,
4043
4332
  submitPending,
4333
+ cancelPending,
4334
+ keepPending,
4335
+ initPendingPreview,
4336
+ cancelPendingPreview,
4337
+ keepPendingPreview,
4338
+ submitPendingPreview,
4044
4339
  loadWebGpuWhisper,
4045
4340
  isWebGpuSupported,
4046
4341
  isWasmSupported,