@burdenoff/website-sdk 2026.1105.3 → 2026.1106.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.
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunk3AIAJ2L4_js = require('../chunk-3AIAJ2L4.js');
3
+ var chunkZ2UP22GV_js = require('../chunk-Z2UP22GV.js');
4
4
  var chunkN7E6Z2GF_js = require('../chunk-N7E6Z2GF.js');
5
5
  var chunkC6OJIHNO_js = require('../chunk-C6OJIHNO.js');
6
6
  require('../chunk-ZSTFX7FV.js');
@@ -72,15 +72,23 @@ function readSttOffer(value) {
72
72
  return { token, expiresAt };
73
73
  }
74
74
  var MIC_MAX_PRESS_SECONDS = 30;
75
+ var AVATAR_MIC_PAUSE_MIN_MS = 2e3;
76
+ var AVATAR_MIC_PAUSE_MAX_MS = 2500;
75
77
  var UNREADABLE_MIC = { mode: "unreadable" };
76
78
  function readMicOffer(value) {
77
79
  if (!isRecord(value)) return UNREADABLE_MIC;
78
80
  const keys = Object.keys(value).sort();
79
- const { mode, maxPressSeconds } = value;
80
- if (keys.length !== 2 || keys[0] !== "maxPressSeconds" || keys[1] !== "mode" || mode !== "browser" || maxPressSeconds !== MIC_MAX_PRESS_SECONDS) {
81
+ const { mode, maxPressSeconds, pauseMs } = value;
82
+ if (keys[0] !== "maxPressSeconds" || keys[1] !== "mode" || mode !== "browser" || maxPressSeconds !== MIC_MAX_PRESS_SECONDS) {
81
83
  return UNREADABLE_MIC;
82
84
  }
83
- return { mode: "browser", maxPressSeconds: MIC_MAX_PRESS_SECONDS };
85
+ if (keys.length === 2) {
86
+ return { mode: "browser", maxPressSeconds: MIC_MAX_PRESS_SECONDS };
87
+ }
88
+ if (keys.length !== 3 || keys[2] !== "pauseMs" || typeof pauseMs !== "number" || !Number.isSafeInteger(pauseMs) || pauseMs < AVATAR_MIC_PAUSE_MIN_MS || pauseMs > AVATAR_MIC_PAUSE_MAX_MS) {
89
+ return UNREADABLE_MIC;
90
+ }
91
+ return { mode: "browser", maxPressSeconds: MIC_MAX_PRESS_SECONDS, pauseMs };
84
92
  }
85
93
  function readAvailability(status, body) {
86
94
  if (status !== 200 || !isRecord(body)) return INERT;
@@ -92,7 +100,7 @@ function readAvailability(status, body) {
92
100
  const { consentVersion, maxSessionSeconds } = body;
93
101
  const botCheck = readBotCheck(body.botCheck);
94
102
  const questions = readQuestions(body.questions);
95
- if (available !== true || typeof consentVersion !== "string" || !chunk3AIAJ2L4_js.isAvatarVersion(consentVersion) || typeof maxSessionSeconds !== "number" || !Number.isInteger(maxSessionSeconds) || maxSessionSeconds < 1 || botCheck === null || questions === null) {
103
+ if (available !== true || typeof consentVersion !== "string" || !chunkZ2UP22GV_js.isAvatarVersion(consentVersion) || typeof maxSessionSeconds !== "number" || !Number.isInteger(maxSessionSeconds) || maxSessionSeconds < 1 || botCheck === null || questions === null) {
96
104
  return INERT;
97
105
  }
98
106
  const stt = readSttOffer(body.stt);
@@ -110,10 +118,13 @@ function readAvailability(status, body) {
110
118
  };
111
119
  }
112
120
  const meaning = available ? void 0 : AVAILABILITY_REASONS.get(reason);
113
- return meaning === void 0 || meaning === "inert" ? INERT : {
121
+ if (meaning === void 0 || meaning === "inert") return INERT;
122
+ const named = body.consentVersion;
123
+ return {
114
124
  kind: "refused",
115
125
  reason: meaning,
116
- questions: body.questions === "any" ? "any" : "suggested"
126
+ questions: body.questions === "any" ? "any" : "suggested",
127
+ ...typeof named === "string" && chunkZ2UP22GV_js.isAvatarVersion(named) ? { consentVersion: named } : {}
117
128
  };
118
129
  }
119
130
  var CREATE_REFUSALS = /* @__PURE__ */ new Map([
@@ -218,7 +229,7 @@ async function requestAvailability(target) {
218
229
  }
219
230
  }
220
231
  async function requestAvailabilityAnswer(target) {
221
- if (!chunk3AIAJ2L4_js.isAvatarSite(target.site) || !LOCALE.test(target.locale)) return INERT;
232
+ if (!chunkZ2UP22GV_js.isAvatarSite(target.site) || !LOCALE.test(target.locale)) return INERT;
222
233
  const query = new URLSearchParams({
223
234
  site: target.site,
224
235
  locale: target.locale
@@ -1083,10 +1094,31 @@ var CONSENT = /* @__PURE__ */ new Map([
1083
1094
  micNotice: BROWSER_MIC_NOTICE_VERSION
1084
1095
  }
1085
1096
  }
1097
+ ],
1098
+ [
1099
+ // Stage C3 (BOFF-9797, the operator's final decision of 2026-11-06, "nothing at all"): the voice conversation with NO
1100
+ // notice in the Avatar tab. Typed questions, Stage B's three minutes, Stage D's answers after the first, the
1101
+ // browser's own recognizer (AVATAR_WEB_MIC=browser) with the server's pause, and no bot check (alpha and prod both
1102
+ // run it with AVATAR_WEB_BOT_CHECK=off). No words, so nothing to agree to: the server must record no consent for it
1103
+ // and hold no notice digest for it, and the create names it as a protocol token only. Speech processing and the
1104
+ // question sent by itself are the site's privacy policy's to describe. PROD waits for the privacy reviewer's
1105
+ // approval of this experience. (The
1106
+ // notice versions web-consent-2026-11-23-v and -24-v, which no environment ever ran, are retired with the dialog
1107
+ // that showed them: this SDK has no copy for them, so a server naming one gets the Avatar tab as it was.)
1108
+ "web-voice-2026-11-25-n",
1109
+ {
1110
+ en: {
1111
+ notice: "none",
1112
+ botCheck: "off",
1113
+ questions: "any",
1114
+ followUps: true,
1115
+ voice: { pauseMs: 2e3 }
1116
+ }
1117
+ }
1086
1118
  ]
1087
1119
  ]);
1088
1120
  function avatarConsentCopy(version, locale) {
1089
- if (!chunk3AIAJ2L4_js.isAvatarLiveLocaleReviewed(locale)) return null;
1121
+ if (!chunkZ2UP22GV_js.isAvatarLiveLocaleReviewed(locale)) return null;
1090
1122
  return CONSENT.get(version)?.[locale] ?? null;
1091
1123
  }
1092
1124
  var MIC_EN = {
@@ -1110,9 +1142,33 @@ var MIC_EN = {
1110
1142
  };
1111
1143
  var MIC_STRINGS = { en: MIC_EN };
1112
1144
  function avatarMicStrings(locale) {
1113
- if (!chunk3AIAJ2L4_js.isAvatarLiveLocaleReviewed(locale)) return null;
1145
+ if (!chunkZ2UP22GV_js.isAvatarLiveLocaleReviewed(locale)) return null;
1114
1146
  return MIC_STRINGS[locale] ?? null;
1115
1147
  }
1148
+ var VOICE_EN = {
1149
+ talk: "Talk",
1150
+ talkInterrupt: "Stop the avatar and talk",
1151
+ stopListening: "Stop listening",
1152
+ start: "Start",
1153
+ cancelled: "The avatar didn't start. Press Start to try again.",
1154
+ starting: "Starting the microphone\u2026",
1155
+ listening: "Listening\u2026",
1156
+ pausing: "Still listening \u2014 take your time",
1157
+ sending: "Sending your question\u2026",
1158
+ again: "The mic listens again after the answer.",
1159
+ off: "Voice is off. Tap Talk to speak, or type your question.",
1160
+ inBox: "Voice is off. Your words are in the box: edit them or press Send.",
1161
+ atLimit: "The mic stopped at thirty seconds. Your words are in the box: press Send to ask.",
1162
+ noQuestion: "No question was heard. Tap Talk to try again, or type your question.",
1163
+ tapNext: "Tap Talk to ask your next question.",
1164
+ headline: "Tap Talk and ask. The avatar answers aloud.",
1165
+ placeholder: "Or type\u2026"
1166
+ };
1167
+ var VOICE_STRINGS = { en: VOICE_EN };
1168
+ function avatarVoiceStrings(locale) {
1169
+ if (!chunkZ2UP22GV_js.isAvatarLiveLocaleReviewed(locale)) return null;
1170
+ return VOICE_STRINGS[locale] ?? null;
1171
+ }
1116
1172
  var AVATAR_MIC_NOTICE_VERSION = "web-mic-2026-11-05";
1117
1173
  var MIC_NOTICE = /* @__PURE__ */ new Map([
1118
1174
  [
@@ -1146,7 +1202,7 @@ var MIC_NOTICE = /* @__PURE__ */ new Map([
1146
1202
  ]
1147
1203
  ]);
1148
1204
  function avatarMicNotice(version, locale) {
1149
- if (!chunk3AIAJ2L4_js.isAvatarLiveLocaleReviewed(locale)) return null;
1205
+ if (!chunkZ2UP22GV_js.isAvatarLiveLocaleReviewed(locale)) return null;
1150
1206
  return MIC_NOTICE.get(version)?.[locale] ?? null;
1151
1207
  }
1152
1208
  var PRIVACY_HREF = "/privacy#live-avatar";
@@ -1190,6 +1246,7 @@ function canUseBrowserMic() {
1190
1246
  if (typeof recognizer !== "function") return false;
1191
1247
  const agent = navigator.userAgent ?? "";
1192
1248
  if (/\bFirefox\//.test(agent) || /\bFxiOS\//.test(agent)) return false;
1249
+ if (/\bOPR\//.test(agent)) return false;
1193
1250
  return !("brave" in navigator);
1194
1251
  }
1195
1252
  var REPLAY_PRIVATE = "rr-block rr-mask";
@@ -1207,20 +1264,33 @@ function noticeOf(source) {
1207
1264
  return source.kind === "stream" ? AVATAR_MIC_NOTICE_VERSION : source.notice;
1208
1265
  }
1209
1266
  function micKeyOf(source, locale) {
1210
- return source.kind === "stream" ? `stream ${source.base}` : `browser ${BROWSER_MIC_LANGUAGES[locale]?.lang ?? ""}`;
1267
+ if (source.kind === "stream") return `stream ${source.base}`;
1268
+ const lang = BROWSER_MIC_LANGUAGES[locale]?.lang ?? "";
1269
+ return source.pauseMs === void 0 ? `browser ${lang}` : `browser ${lang} pause ${source.pauseMs}`;
1270
+ }
1271
+ function underWay(state) {
1272
+ return state.name === "starting" || state.name === "listening" || state.name === "finishing";
1211
1273
  }
1212
1274
  function useAvatarMic({
1213
1275
  enabled,
1214
1276
  locale,
1215
1277
  source,
1216
1278
  onText,
1217
- onStart
1279
+ onStart,
1280
+ onTurn,
1281
+ onPress,
1282
+ echoOf,
1283
+ interruptible = false,
1284
+ note = null
1218
1285
  }) {
1219
1286
  const S = avatarMicStrings(locale);
1220
1287
  const noticeVersion = source === null ? null : noticeOf(source);
1221
- const copy = noticeVersion === null ? null : avatarMicNotice(noticeVersion, locale);
1222
1288
  const kind = source?.kind ?? null;
1223
1289
  const micKey = source === null ? null : micKeyOf(source, locale);
1290
+ const paused = source?.kind === "browser" && source.pauseMs !== void 0;
1291
+ const conversation = paused && noticeVersion !== null && avatarConsentCopy(noticeVersion, locale)?.notice === "none";
1292
+ const copy = noticeVersion === null || paused ? null : avatarMicNotice(noticeVersion, locale);
1293
+ const V = conversation ? avatarVoiceStrings(locale) : null;
1224
1294
  const uid = react.useId();
1225
1295
  const noticeTitleId = `${uid}-mic-notice`;
1226
1296
  const [withdrawn, setWithdrawn] = react.useState({});
@@ -1230,13 +1300,19 @@ function useAvatarMic({
1230
1300
  []
1231
1301
  );
1232
1302
  const usable = source === null ? false : source.kind === "stream" ? supported.stream : supported.browser && BROWSER_MIC_LANGUAGES[locale] !== void 0;
1233
- const possible = enabled && kind !== null && usable && S !== null && copy !== null;
1303
+ const showable = kind !== null && usable && S !== null && (paused ? conversation && V !== null : copy !== null);
1304
+ const possible = enabled && showable;
1234
1305
  const withdrawnReason = kind === null ? null : withdrawn[kind] ?? null;
1235
1306
  const offered = possible && withdrawnReason === null;
1236
1307
  const [state, setState] = react.useState(IDLE);
1237
1308
  const [noticeOpen, setNoticeOpen] = react.useState(false);
1238
1309
  const agreedRef = react.useRef(null);
1239
- const [loading2, setLoading] = react.useState(false);
1310
+ const [loading2, setLoadingState] = react.useState(false);
1311
+ const loadingRef = react.useRef(false);
1312
+ const setLoading = react.useCallback((value) => {
1313
+ loadingRef.current = value;
1314
+ setLoadingState(value);
1315
+ }, []);
1240
1316
  const [loadFailed, setLoadFailed] = react.useState(false);
1241
1317
  const [now, setNow] = react.useState(() => Date.now());
1242
1318
  const micRef = react.useRef(
@@ -1246,15 +1322,39 @@ function useAvatarMic({
1246
1322
  const buttonRef = react.useRef(null);
1247
1323
  const allowRef = react.useRef(null);
1248
1324
  const usedRef = react.useRef(null);
1249
- const latest = react.useRef({ source, locale, noticeVersion, onText, onStart });
1250
- latest.current = { source, locale, noticeVersion, onText, onStart };
1325
+ const gestureRef = react.useRef(false);
1326
+ const [, setUsedTick] = react.useState(0);
1327
+ const latest = react.useRef({
1328
+ source,
1329
+ locale,
1330
+ noticeVersion,
1331
+ onText,
1332
+ onStart,
1333
+ onTurn,
1334
+ onPress,
1335
+ echoOf,
1336
+ conversation,
1337
+ offered
1338
+ });
1339
+ latest.current = {
1340
+ source,
1341
+ locale,
1342
+ noticeVersion,
1343
+ onText,
1344
+ onStart,
1345
+ onTurn,
1346
+ onPress,
1347
+ echoOf,
1348
+ conversation,
1349
+ offered
1350
+ };
1251
1351
  const cancel = react.useCallback(() => {
1252
1352
  seqRef.current += 1;
1253
1353
  setLoading(false);
1254
1354
  setLoadFailed(false);
1255
1355
  setNoticeOpen(false);
1256
1356
  micRef.current?.mic.cancel();
1257
- }, []);
1357
+ }, [setLoading]);
1258
1358
  react.useEffect(() => {
1259
1359
  if (!offered) cancel();
1260
1360
  }, [offered, cancel]);
@@ -1292,11 +1392,22 @@ function useAvatarMic({
1292
1392
  });
1293
1393
  } else {
1294
1394
  const language = BROWSER_MIC_LANGUAGES[latest.current.locale] ?? EN_IN;
1395
+ const { pauseMs } = wanted;
1295
1396
  mic = loaded.createBrowserAvatarMic({
1296
1397
  lang: language.lang,
1297
1398
  fallbackLang: language.fallbackLang,
1298
1399
  maxPressMs: LIMIT_SECONDS * 1e3,
1299
- onState: setState
1400
+ onState: setState,
1401
+ // The voice conversation: each press ends itself on the server's pause. The avatar's own words heard back
1402
+ // are looked for only in a press the conversation started by itself: one the visitor's own tap started runs
1403
+ // while the avatar is quiet, or muted here by that tap (a Talk while it speaks interrupts it), so its words are
1404
+ // the visitor's, whatever of the answer they repeat ("Delivery is free across Tamil Nadu?").
1405
+ ...pauseMs === void 0 ? {} : {
1406
+ endpoint: {
1407
+ pauseMs,
1408
+ echoOf: () => gestureRef.current ? null : latest.current.echoOf?.() ?? null
1409
+ }
1410
+ }
1300
1411
  });
1301
1412
  }
1302
1413
  micRef.current = { key, kind: wanted.kind, mic };
@@ -1304,37 +1415,90 @@ function useAvatarMic({
1304
1415
  },
1305
1416
  []
1306
1417
  );
1307
- const press = react.useCallback(() => {
1308
- const wanted = latest.current.source;
1309
- if (wanted === null) return;
1310
- seqRef.current += 1;
1311
- const seq = seqRef.current;
1312
- setLoadFailed(false);
1313
- const held = micRef.current;
1314
- const ready = wanted.kind !== "browser" ? null : held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? held.mic : loadedMic !== null ? micOf(loadedMic, wanted) : null;
1315
- if (ready !== null) {
1316
- setLoading(false);
1317
- ready.start();
1318
- return;
1319
- }
1320
- setLoading(true);
1321
- const arriving = held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? Promise.resolve(held.mic) : loadAvatarMic().then((loaded) => micOf(loaded, wanted));
1322
- void arriving.then(
1323
- (mic) => {
1324
- if (seqRef.current !== seq) return;
1325
- setLoading(false);
1326
- mic.start();
1327
- },
1328
- () => {
1329
- if (seqRef.current !== seq) return;
1418
+ const press = react.useCallback(
1419
+ (gesture) => {
1420
+ const wanted = latest.current.source;
1421
+ if (wanted === null) return;
1422
+ seqRef.current += 1;
1423
+ const seq = seqRef.current;
1424
+ setLoadFailed(false);
1425
+ gestureRef.current = gesture;
1426
+ if (latest.current.conversation) latest.current.onPress?.(gesture);
1427
+ const held = micRef.current;
1428
+ const ready = wanted.kind !== "browser" ? null : held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? held.mic : loadedMic !== null ? micOf(loadedMic, wanted) : null;
1429
+ if (ready !== null) {
1330
1430
  setLoading(false);
1331
- setLoadFailed(true);
1431
+ ready.start();
1432
+ return;
1332
1433
  }
1333
- );
1334
- }, [micOf]);
1434
+ setLoading(true);
1435
+ const arriving = held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? Promise.resolve(held.mic) : loadAvatarMic().then((loaded) => micOf(loaded, wanted));
1436
+ void arriving.then(
1437
+ (mic) => {
1438
+ if (seqRef.current !== seq) return;
1439
+ setLoading(false);
1440
+ mic.start();
1441
+ },
1442
+ () => {
1443
+ if (seqRef.current !== seq) return;
1444
+ setLoading(false);
1445
+ setLoadFailed(true);
1446
+ if (latest.current.conversation) {
1447
+ latest.current.onTurn?.({
1448
+ by: "failed",
1449
+ text: "",
1450
+ echo: false,
1451
+ gesture,
1452
+ failure: "failed"
1453
+ });
1454
+ }
1455
+ }
1456
+ );
1457
+ },
1458
+ [micOf, setLoading]
1459
+ );
1335
1460
  const recording = loading2 || state.name === "starting" || state.name === "listening";
1336
1461
  const finishing = kind === "browser" && state.name === "finishing";
1462
+ const preload = react.useCallback(() => {
1463
+ const seq = seqRef.current;
1464
+ void loadAvatarMic().then(
1465
+ (loaded) => {
1466
+ const wanted = latest.current.source;
1467
+ if (seqRef.current === seq && wanted?.kind === "browser") {
1468
+ micOf(loaded, wanted);
1469
+ }
1470
+ },
1471
+ () => void 0
1472
+ );
1473
+ }, [micOf]);
1474
+ const pressing = react.useCallback(() => {
1475
+ const held = micRef.current?.mic.state() ?? IDLE;
1476
+ return loadingRef.current || underWay(held) || (held.name === "done" || held.name === "failed") && held !== usedRef.current;
1477
+ }, []);
1478
+ const stopListening = react.useCallback(() => {
1479
+ const held = micRef.current?.mic.state() ?? IDLE;
1480
+ let text = "";
1481
+ if (held.name === "listening" || held.name === "finishing") {
1482
+ text = held.text;
1483
+ } else if ((held.name === "done" || held.name === "failed") && held !== usedRef.current) {
1484
+ usedRef.current = held;
1485
+ text = held.text;
1486
+ }
1487
+ const gesture = gestureRef.current;
1488
+ cancel();
1489
+ latest.current.onTurn?.({ by: "tap", text, echo: false, gesture });
1490
+ }, [cancel]);
1337
1491
  const tap = react.useCallback(() => {
1492
+ if (latest.current.conversation) {
1493
+ if (pressing()) {
1494
+ stopListening();
1495
+ return;
1496
+ }
1497
+ latest.current.onStart?.();
1498
+ agreedRef.current = latest.current.noticeVersion;
1499
+ press(true);
1500
+ return;
1501
+ }
1338
1502
  if (state.name === "listening" || state.name === "starting") {
1339
1503
  micRef.current?.mic.stop();
1340
1504
  return;
@@ -1347,30 +1511,56 @@ function useAvatarMic({
1347
1511
  latest.current.onStart?.();
1348
1512
  if (agreedRef.current !== latest.current.noticeVersion) {
1349
1513
  setNoticeOpen(true);
1350
- const seq = seqRef.current;
1351
- void loadAvatarMic().then(
1352
- (loaded) => {
1353
- const wanted = latest.current.source;
1354
- if (seqRef.current === seq && wanted?.kind === "browser") {
1355
- micOf(loaded, wanted);
1356
- }
1357
- },
1358
- () => void 0
1359
- );
1514
+ preload();
1360
1515
  return;
1361
1516
  }
1362
- press();
1363
- }, [cancel, finishing, loading2, micOf, press, state.name]);
1517
+ press(true);
1518
+ }, [
1519
+ cancel,
1520
+ finishing,
1521
+ loading2,
1522
+ preload,
1523
+ press,
1524
+ pressing,
1525
+ state.name,
1526
+ stopListening
1527
+ ]);
1364
1528
  const allow = react.useCallback(() => {
1365
1529
  agreedRef.current = latest.current.noticeVersion;
1366
1530
  setNoticeOpen(false);
1367
1531
  buttonRef.current?.focus();
1368
- press();
1532
+ press(true);
1369
1533
  }, [press]);
1370
1534
  const notNow = react.useCallback(() => {
1371
1535
  setNoticeOpen(false);
1372
1536
  buttonRef.current?.focus();
1373
1537
  }, []);
1538
+ const preparing = conversation && offered;
1539
+ react.useEffect(() => {
1540
+ if (preparing) preload();
1541
+ }, [preparing, preload]);
1542
+ const listen = react.useCallback(() => {
1543
+ if (!latest.current.conversation || !latest.current.offered || // Never before the visitor's own Talk on this page, under this version.
1544
+ agreedRef.current !== latest.current.noticeVersion || // A press under way, or one that has ended and whose outcome has not gone to the view yet (still that press: a
1545
+ // question that ended on its pause is about to be sent): the mic must not open over it.
1546
+ pressing()) {
1547
+ return false;
1548
+ }
1549
+ press(false);
1550
+ return true;
1551
+ }, [press, pressing]);
1552
+ const heard = react.useCallback(() => {
1553
+ const held = micRef.current?.mic.state() ?? IDLE;
1554
+ return held.name === "listening" || held.name === "finishing" ? held.text : "";
1555
+ }, []);
1556
+ const dropLoopPress = react.useCallback(() => {
1557
+ const held = micRef.current?.mic.state() ?? IDLE;
1558
+ if (!latest.current.conversation || gestureRef.current || !loadingRef.current && !underWay(held)) {
1559
+ return false;
1560
+ }
1561
+ cancel();
1562
+ return true;
1563
+ }, [cancel]);
1374
1564
  react.useEffect(() => {
1375
1565
  if (noticeOpen) allowRef.current?.focus();
1376
1566
  }, [noticeOpen]);
@@ -1382,28 +1572,71 @@ function useAvatarMic({
1382
1572
  return () => clearInterval(timer);
1383
1573
  }, [listeningSince]);
1384
1574
  react.useEffect(() => {
1575
+ const voice = latest.current.conversation;
1385
1576
  if (state.name === "done" && state !== usedRef.current) {
1386
1577
  usedRef.current = state;
1387
- if (state.text !== "") latest.current.onText(state.text);
1578
+ if (voice) {
1579
+ latest.current.onTurn?.({
1580
+ by: state.by ?? (state.limit ? "limit" : "tap"),
1581
+ text: state.text,
1582
+ echo: state.echo === true,
1583
+ gesture: gestureRef.current
1584
+ });
1585
+ setUsedTick((tick) => tick + 1);
1586
+ } else if (state.text !== "") {
1587
+ latest.current.onText(state.text);
1588
+ }
1388
1589
  return;
1389
1590
  }
1390
1591
  const pressed = micRef.current?.kind;
1391
- if (state.name === "failed" && pressed !== void 0 && FOR_GOOD[pressed].has(state.reason)) {
1592
+ if (state.name === "failed" && voice && state !== usedRef.current) {
1593
+ usedRef.current = state;
1594
+ const gesture = gestureRef.current;
1595
+ if (gesture && pressed !== void 0 && FOR_GOOD[pressed].has(state.reason)) {
1596
+ const reason = state.reason;
1597
+ setWithdrawn((held) => ({ ...held, [pressed]: reason }));
1598
+ }
1599
+ latest.current.onTurn?.({
1600
+ by: "failed",
1601
+ text: state.text,
1602
+ echo: false,
1603
+ gesture,
1604
+ failure: state.reason
1605
+ });
1606
+ setUsedTick((tick) => tick + 1);
1607
+ return;
1608
+ }
1609
+ if (state.name === "failed" && !voice && pressed !== void 0 && FOR_GOOD[pressed].has(state.reason)) {
1392
1610
  const reason = state.reason;
1393
1611
  setWithdrawn((held) => ({ ...held, [pressed]: reason }));
1394
1612
  }
1395
1613
  }, [state]);
1614
+ const outcomeDue = (state.name === "done" || state.name === "failed") && state !== usedRef.current;
1615
+ const busy = loading2 || state.name === "starting" || state.name === "listening" || state.name === "finishing" || outcomeDue;
1616
+ const voiceControls = {
1617
+ mode: conversation ? "conversation" : "editable",
1618
+ speaking: conversation && offered && state.name === "listening" && state.genuine === true,
1619
+ busy,
1620
+ pressing,
1621
+ listen,
1622
+ heard,
1623
+ dropLoopPress
1624
+ };
1625
+ if (conversation && showable && V !== null && S !== null) {
1626
+ return conversationControls(V, S);
1627
+ }
1396
1628
  if (!possible || S === null || copy === null) {
1397
1629
  return {
1398
1630
  button: null,
1399
1631
  notice: null,
1400
1632
  lines: null,
1401
1633
  cancel,
1402
- recording: false
1634
+ recording: false,
1635
+ ...voiceControls
1403
1636
  };
1404
1637
  }
1405
1638
  const status = withdrawnReason !== null ? S[FAILURE_TEXT[withdrawnReason]] : loading2 ? S.micStarting : loadFailed ? S.micFailed : state.name === "starting" ? S.micStarting : state.name === "listening" ? S.micListening : state.name === "finishing" ? S.micFinishing : state.name === "done" ? state.text === "" ? S.micNoSpeech : state.limit ? S.micDoneLimit : S.micDone : state.name === "failed" ? S[FAILURE_TEXT[state.reason]] : "";
1406
- const heard = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1639
+ const heardText = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1407
1640
  const button = !offered ? null : /* @__PURE__ */ jsxRuntime.jsx(
1408
1641
  "button",
1409
1642
  {
@@ -1425,62 +1658,11 @@ function useAvatarMic({
1425
1658
  children: recording ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Square, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, { "aria-hidden": "true", className: "h-4 w-4" })
1426
1659
  }
1427
1660
  );
1428
- const notice = offered && noticeOpen ? /* @__PURE__ */ jsxRuntime.jsxs(
1429
- "section",
1430
- {
1431
- "aria-labelledby": noticeTitleId,
1432
- "data-boff-explore": "avatar-live-mic-notice",
1433
- className: "w-full rounded-lg border border-border p-3 text-start",
1434
- children: [
1435
- /* @__PURE__ */ jsxRuntime.jsx("h3", { id: noticeTitleId, className: "text-sm font-semibold", children: copy.title }),
1436
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.processing }),
1437
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.storage }),
1438
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.text }),
1439
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.limits }),
1440
- /* @__PURE__ */ jsxRuntime.jsx(
1441
- "a",
1442
- {
1443
- href: PRIVACY_HREF,
1444
- target: "_blank",
1445
- rel: "noopener noreferrer",
1446
- "data-boff-explore": "avatar-live-mic-privacy",
1447
- className: "mt-1 inline-block text-xs font-medium text-primary underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1448
- children: copy.privacyLink
1449
- }
1450
- ),
1451
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mt-2 flex flex-wrap gap-2", children: [
1452
- /* @__PURE__ */ jsxRuntime.jsx(
1453
- "button",
1454
- {
1455
- ref: allowRef,
1456
- type: "button",
1457
- "data-boff-explore": "avatar-live-mic-allow",
1458
- className: "inline-flex min-h-11 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1459
- onClick: allow,
1460
- children: S.micAllow
1461
- }
1462
- ),
1463
- /* @__PURE__ */ jsxRuntime.jsx(
1464
- "button",
1465
- {
1466
- type: "button",
1467
- "data-boff-explore": "avatar-live-mic-not-now",
1468
- className: "inline-flex min-h-11 items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1469
- onClick: notNow,
1470
- children: S.micNotNow
1471
- }
1472
- )
1473
- ] })
1474
- ]
1475
- }
1476
- ) : null;
1661
+ const notice = offered && noticeOpen ? noticeCard(copy, S.micAllow, S.micNotNow) : null;
1477
1662
  const listening = offered && state.name === "listening";
1478
1663
  const lines = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1479
1664
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1480
- listening ? /* @__PURE__ */ jsxRuntime.jsxs("span", { "aria-hidden": "true", className: "relative flex h-2 w-2 shrink-0", children: [
1481
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75 motion-reduce:animate-none" }),
1482
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-primary" })
1483
- ] }) : null,
1665
+ listening ? listeningDot : null,
1484
1666
  /* @__PURE__ */ jsxRuntime.jsx("p", { role: "status", "data-boff-explore": "avatar-live-mic-status", children: status }),
1485
1667
  listening ? /* @__PURE__ */ jsxRuntime.jsx(
1486
1668
  "span",
@@ -1497,7 +1679,69 @@ function useAvatarMic({
1497
1679
  }
1498
1680
  ) : null
1499
1681
  ] }),
1500
- heard === "" ? null : /* @__PURE__ */ jsxRuntime.jsx(
1682
+ heardLine(heardText)
1683
+ ] });
1684
+ return {
1685
+ button,
1686
+ notice,
1687
+ lines,
1688
+ cancel,
1689
+ recording: offered && state.name === "listening",
1690
+ ...voiceControls
1691
+ };
1692
+ function noticeCard(words, allowLabel, notNowLabel) {
1693
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1694
+ "section",
1695
+ {
1696
+ "aria-labelledby": noticeTitleId,
1697
+ "data-boff-explore": "avatar-live-mic-notice",
1698
+ className: "w-full rounded-lg border border-border p-3 text-start",
1699
+ children: [
1700
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { id: noticeTitleId, className: "text-sm font-semibold", children: words.title }),
1701
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.processing }),
1702
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.storage }),
1703
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.text }),
1704
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.limits }),
1705
+ /* @__PURE__ */ jsxRuntime.jsx(
1706
+ "a",
1707
+ {
1708
+ href: PRIVACY_HREF,
1709
+ target: "_blank",
1710
+ rel: "noopener noreferrer",
1711
+ "data-boff-explore": "avatar-live-mic-privacy",
1712
+ className: "mt-1 inline-block text-xs font-medium text-primary underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1713
+ children: words.privacyLink
1714
+ }
1715
+ ),
1716
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mt-2 flex flex-wrap gap-2", children: [
1717
+ /* @__PURE__ */ jsxRuntime.jsx(
1718
+ "button",
1719
+ {
1720
+ ref: allowRef,
1721
+ type: "button",
1722
+ "data-boff-explore": "avatar-live-mic-allow",
1723
+ className: "inline-flex min-h-11 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1724
+ onClick: allow,
1725
+ children: allowLabel
1726
+ }
1727
+ ),
1728
+ /* @__PURE__ */ jsxRuntime.jsx(
1729
+ "button",
1730
+ {
1731
+ type: "button",
1732
+ "data-boff-explore": "avatar-live-mic-not-now",
1733
+ className: "inline-flex min-h-11 items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1734
+ onClick: notNow,
1735
+ children: notNowLabel
1736
+ }
1737
+ )
1738
+ ] })
1739
+ ]
1740
+ }
1741
+ );
1742
+ }
1743
+ function heardLine(text) {
1744
+ return text === "" ? null : /* @__PURE__ */ jsxRuntime.jsx(
1501
1745
  "p",
1502
1746
  {
1503
1747
  "data-boff-explore": "avatar-live-mic-heard",
@@ -1507,18 +1751,70 @@ function useAvatarMic({
1507
1751
  REPLAY_PRIVATE,
1508
1752
  "line-clamp-3 break-words text-sm italic text-muted-foreground"
1509
1753
  ),
1510
- children: heard
1754
+ children: text
1511
1755
  }
1512
- )
1513
- ] });
1514
- return {
1515
- button,
1516
- notice,
1517
- lines,
1518
- cancel,
1519
- recording: offered && state.name === "listening"
1520
- };
1756
+ );
1757
+ }
1758
+ function conversationControls(V2, S2) {
1759
+ const going = loading2 || state.name === "starting" || state.name === "listening" || state.name === "finishing";
1760
+ const label = busy ? V2.stopListening : V2.talk;
1761
+ const name = busy ? V2.stopListening : interruptible ? V2.talkInterrupt : V2.talk;
1762
+ const talk = !offered ? null : /* @__PURE__ */ jsxRuntime.jsxs(
1763
+ "button",
1764
+ {
1765
+ ref: buttonRef,
1766
+ type: "button",
1767
+ "aria-label": name,
1768
+ title: name,
1769
+ "data-boff-explore": "avatar-live-mic",
1770
+ "data-mic": loading2 ? "starting" : state.name,
1771
+ "data-mic-source": "browser",
1772
+ "data-mic-mode": "conversation",
1773
+ className: chunkC6OJIHNO_js.cn(
1774
+ "inline-flex h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md border px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1775
+ busy ? "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground" : "border-primary bg-primary text-primary-foreground hover:opacity-90"
1776
+ ),
1777
+ onClick: tap,
1778
+ children: [
1779
+ busy ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Square, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, { "aria-hidden": "true", className: "h-4 w-4" }),
1780
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: label })
1781
+ ]
1782
+ }
1783
+ );
1784
+ const pressLine = !offered ? null : loading2 || state.name === "starting" ? V2.starting : state.name === "listening" ? state.pausing === true ? V2.pausing : V2.listening : state.name === "finishing" ? state.limit ? S2.micFinishing : V2.sending : null;
1785
+ const noteLine = note === null ? "" : note.kind === "line" ? V2[note.line] : S2[FAILURE_TEXT[note.reason]];
1786
+ const line = withdrawnReason !== null ? S2[FAILURE_TEXT[withdrawnReason]] : pressLine ?? noteLine;
1787
+ const heardNow = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1788
+ const listeningNow = offered && state.name === "listening";
1789
+ const shown = possible || line !== "";
1790
+ return {
1791
+ button: talk,
1792
+ notice: null,
1793
+ lines: !shown ? null : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1794
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1795
+ listeningNow ? listeningDot : null,
1796
+ /* @__PURE__ */ jsxRuntime.jsx(
1797
+ "p",
1798
+ {
1799
+ role: "status",
1800
+ "data-boff-explore": "avatar-live-mic-status",
1801
+ "data-voice": going ? "pressing" : note?.kind ?? "idle",
1802
+ children: line
1803
+ }
1804
+ )
1805
+ ] }),
1806
+ heardLine(heardNow)
1807
+ ] }),
1808
+ cancel,
1809
+ recording: offered && state.name === "listening",
1810
+ ...voiceControls
1811
+ };
1812
+ }
1521
1813
  }
1814
+ var listeningDot = /* @__PURE__ */ jsxRuntime.jsxs("span", { "aria-hidden": "true", className: "relative flex h-2 w-2 shrink-0", children: [
1815
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75 motion-reduce:animate-none" }),
1816
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-primary" })
1817
+ ] });
1522
1818
 
1523
1819
  // src/components/explore-avatar-turnstile.ts
1524
1820
  var SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
@@ -1647,6 +1943,9 @@ var LISTENING_MARK_SPACING_MS = 5e3;
1647
1943
  var BUSY_RETRY_MS = 2e3;
1648
1944
  var BUSY_RETRY_MAX_MS = 8e3;
1649
1945
  var MAX_BUSY_RETRIES = 24;
1946
+ var PAUSE_NOTICE_TOLERANCE_MS = 500;
1947
+ var MAX_ECHO_DISCARDS = 2;
1948
+ var LISTEN_AGAIN_MIN_LEFT_MS = 2e4;
1650
1949
  var SERVICE_MAX_AGE_MS = 15 * 6e4;
1651
1950
  var ANSWER_OFFER_MS = SERVICE_MAX_AGE_MS - 6e4;
1652
1951
  var MAX_TIMER_MS = 2147483647;
@@ -1656,6 +1955,29 @@ var USED_ON_KEY = "boff.explore.avatarUsedOn";
1656
1955
  var PRIMARY = '[data-avatar-primary], [data-boff-explore="panel-continue-chat"]';
1657
1956
  var IDLE2 = { name: "idle" };
1658
1957
  var INERT2 = { name: "inert" };
1958
+ var VOICE_OFF = {
1959
+ on: false,
1960
+ arm: null,
1961
+ echoes: 0,
1962
+ note: null
1963
+ };
1964
+ function lineNote(line) {
1965
+ return { kind: "line", line };
1966
+ }
1967
+ function sameNote(a, b) {
1968
+ if (a === null || b === null) return a === b;
1969
+ return a.kind === "line" ? b.kind === "line" && a.line === b.line : b.kind === "failed" && a.reason === b.reason;
1970
+ }
1971
+ var SESSION_REFUSALS = /* @__PURE__ */ new Set([
1972
+ "no_time",
1973
+ "max_answers",
1974
+ "limits",
1975
+ "paused"
1976
+ ]);
1977
+ function playedOutNow(handle) {
1978
+ const run = handle.followUps;
1979
+ return run !== null && !run.voice && run.quiet === null && handle.playedOut && !run.owed;
1980
+ }
1659
1981
  function stopAttending(handle) {
1660
1982
  if (handle.unheard !== null) clearTimeout(handle.unheard);
1661
1983
  if (handle.tail !== null) clearTimeout(handle.tail);
@@ -1773,9 +2095,15 @@ var NO_MIC = { kind: "none" };
1773
2095
  function micChoiceOf(result, locale) {
1774
2096
  if (result.kind !== "available") return NO_MIC;
1775
2097
  const { offer } = result;
1776
- const micNotice = avatarConsentCopy(offer.consentVersion, locale)?.micNotice;
1777
- if (offer.mic !== void 0) {
1778
- return offer.mic.mode === "browser" && micNotice !== void 0 ? { kind: "browser", notice: micNotice } : NO_MIC;
2098
+ const { mic } = offer;
2099
+ const copy = avatarConsentCopy(offer.consentVersion, locale);
2100
+ if (copy?.notice === "none") {
2101
+ const pauseMs = mic?.mode === "browser" ? mic.pauseMs : void 0;
2102
+ return pauseMs !== void 0 && Math.abs(pauseMs - copy.voice.pauseMs) <= PAUSE_NOTICE_TOLERANCE_MS && avatarVoiceStrings(locale) !== null ? { kind: "browser", notice: offer.consentVersion, pauseMs } : NO_MIC;
2103
+ }
2104
+ const micNotice = copy?.micNotice;
2105
+ if (mic !== void 0) {
2106
+ return mic.mode === "browser" && micNotice !== void 0 && mic.pauseMs === void 0 ? { kind: "browser", notice: micNotice } : NO_MIC;
1779
2107
  }
1780
2108
  return offer.stt !== void 0 && micNotice === void 0 ? { kind: "stream", offer: { ...offer.stt, receivedAt: Date.now() } } : NO_MIC;
1781
2109
  }
@@ -1875,6 +2203,7 @@ function AvatarLiveView({
1875
2203
  conversation
1876
2204
  }) {
1877
2205
  const S = avatarLiveStrings(locale);
2206
+ const voiceWords = avatarVoiceStrings(locale);
1878
2207
  const uid = react.useId();
1879
2208
  const consentTitleId = `${uid}-consent`;
1880
2209
  const [questions, setQuestions] = react.useState("suggested");
@@ -1903,12 +2232,16 @@ function AvatarLiveView({
1903
2232
  const offerUntil = answerId === null ? null : exchange.offerUntil;
1904
2233
  const [phase, setPhase] = react.useState(IDLE2);
1905
2234
  const [blocked, setBlocked] = react.useState(false);
2235
+ const blockedRef = react.useRef(false);
2236
+ blockedRef.current = blocked;
1906
2237
  const [now, setNow] = react.useState(() => Date.now());
1907
2238
  const [draft, setDraft] = react.useState("");
1908
2239
  const chatMaxLength = conversation?.catalog.limits.maxMessageLength;
1909
2240
  const askMaxLength = chatMaxLength !== void 0 && Number.isFinite(chatMaxLength) && chatMaxLength >= 1 ? Math.min(ASK_MAX_LENGTH, Math.floor(chatMaxLength)) : ASK_MAX_LENGTH;
1910
2241
  const [note, setNote] = react.useState(null);
1911
2242
  const [usedToday, setUsedToday] = react.useState(false);
2243
+ const [voiceOffered, setVoiceOffered] = react.useState(false);
2244
+ const [noNotice, setNoNotice] = react.useState(false);
1912
2245
  const showing = active && !off;
1913
2246
  const behindChat = open && !active && !off;
1914
2247
  const phaseRef = react.useRef(IDLE2);
@@ -1929,6 +2262,34 @@ function AvatarLiveView({
1929
2262
  const textBoxRef = react.useRef(false);
1930
2263
  const [micChoice, setMicChoice] = react.useState(NO_MIC);
1931
2264
  const micCancelRef = react.useRef(() => void 0);
2265
+ const micControlRef = react.useRef(null);
2266
+ const voiceRef = react.useRef(VOICE_OFF);
2267
+ const [, setVoiceTick] = react.useState(0);
2268
+ const voiceSet = react.useCallback((next) => {
2269
+ voiceRef.current = { ...voiceRef.current, ...next };
2270
+ setVoiceTick((tick) => tick + 1);
2271
+ }, []);
2272
+ const askMaxLengthRef = react.useRef(ASK_MAX_LENGTH);
2273
+ const voiceOff = react.useCallback(
2274
+ (note2, keep) => {
2275
+ const run = voiceRef.current;
2276
+ if (!run.on && run.arm === null) {
2277
+ if (!sameNote(run.note, note2)) voiceSet({ note: note2 });
2278
+ return;
2279
+ }
2280
+ if (keep) {
2281
+ const heard = micControlRef.current?.heard() ?? "";
2282
+ if (heard !== "") {
2283
+ setDraft(
2284
+ (current) => withHeardText(current, heard, askMaxLengthRef.current)
2285
+ );
2286
+ }
2287
+ }
2288
+ micCancelRef.current();
2289
+ voiceSet({ ...VOICE_OFF, note: note2 });
2290
+ },
2291
+ [voiceSet]
2292
+ );
1932
2293
  const sendDueRef = react.useRef(() => void 0);
1933
2294
  const checkRef = react.useRef(null);
1934
2295
  const askedLocale = react.useRef(locale);
@@ -1985,6 +2346,8 @@ function AvatarLiveView({
1985
2346
  handle.done = true;
1986
2347
  stopAttending(handle);
1987
2348
  sessionRef.current = null;
2349
+ voiceOff(null, true);
2350
+ if (audioRef.current !== null) audioRef.current.muted = false;
1988
2351
  if (reason !== null) {
1989
2352
  sendEnd({
1990
2353
  base: handle.base,
@@ -2000,7 +2363,7 @@ function AvatarLiveView({
2000
2363
  setBlocked(false);
2001
2364
  go({ name: "ended", how });
2002
2365
  },
2003
- [go]
2366
+ [go, voiceOff]
2004
2367
  );
2005
2368
  const goLive = react.useCallback(() => {
2006
2369
  const current = phaseRef.current;
@@ -2073,6 +2436,18 @@ function AvatarLiveView({
2073
2436
  },
2074
2437
  []
2075
2438
  );
2439
+ const unmute = react.useCallback((handle) => {
2440
+ const run = handle.followUps;
2441
+ if (run === null || !run.muted) return;
2442
+ run.muted = false;
2443
+ if (audioRef.current !== null) audioRef.current.muted = false;
2444
+ }, []);
2445
+ const holdMute = react.useCallback(() => {
2446
+ const run = sessionRef.current?.followUps ?? null;
2447
+ const audio = audioRef.current;
2448
+ if (run === null || !run.muted || audio === null || audio.muted) return;
2449
+ audio.muted = true;
2450
+ }, []);
2076
2451
  const whenQuiet = react.useCallback(
2077
2452
  (handle, delay) => {
2078
2453
  const run = handle.followUps;
@@ -2086,10 +2461,12 @@ function AvatarLiveView({
2086
2461
  }
2087
2462
  if (run.owed) {
2088
2463
  run.owed = false;
2464
+ run.owedWait = true;
2089
2465
  wait(OWED_VOICE_WAIT_MS);
2090
2466
  return;
2091
2467
  }
2092
2468
  handle.playedOut = true;
2469
+ if (!run.mutedUnheard) unmute(handle);
2093
2470
  showFollowUp(handle, { speaking: false });
2094
2471
  attend();
2095
2472
  sendDueRef.current(handle);
@@ -2097,7 +2474,7 @@ function AvatarLiveView({
2097
2474
  };
2098
2475
  wait(delay);
2099
2476
  },
2100
- [attend, showFollowUp]
2477
+ [attend, showFollowUp, unmute]
2101
2478
  );
2102
2479
  const heardVoice = react.useCallback(
2103
2480
  (handle, speaking) => {
@@ -2113,7 +2490,10 @@ function AvatarLiveView({
2113
2490
  run.heard = true;
2114
2491
  run.starts += 1;
2115
2492
  run.owed = false;
2493
+ run.owedWait = false;
2494
+ run.mutedUnheard = false;
2116
2495
  handle.playedOut = false;
2496
+ if (voiceRef.current.on) micControlRef.current?.dropLoopPress();
2117
2497
  showFollowUp(handle, { speaking: true });
2118
2498
  },
2119
2499
  [showFollowUp, whenQuiet]
@@ -2180,6 +2560,7 @@ function AvatarLiveView({
2180
2560
  }
2181
2561
  run.due = null;
2182
2562
  run.answers += 1;
2563
+ unmute(handle);
2183
2564
  const heardSince = run.starts !== starts;
2184
2565
  run.owed = !heardSince;
2185
2566
  const playing = !heardSince || run.voice || run.quiet !== null;
@@ -2201,7 +2582,7 @@ function AvatarLiveView({
2201
2582
  if (run.owed && run.heard && !run.voice) whenQuiet(handle, 0);
2202
2583
  })();
2203
2584
  },
2204
- [attend, showFollowUp, whenQuiet]
2585
+ [attend, showFollowUp, unmute, whenQuiet]
2205
2586
  );
2206
2587
  sendDueRef.current = sendDue;
2207
2588
  const readFollowUp = react.useCallback(
@@ -2238,18 +2619,25 @@ function AvatarLiveView({
2238
2619
  return;
2239
2620
  }
2240
2621
  if (result.kind === "refused") {
2622
+ if (result.consentVersion !== void 0) {
2623
+ setNoNotice(
2624
+ avatarConsentCopy(result.consentVersion, locale2)?.notice === "none"
2625
+ );
2626
+ }
2241
2627
  setMode(result.questions);
2242
2628
  go({ name: "refused", reason: result.reason });
2243
2629
  return;
2244
2630
  }
2245
2631
  setUsedToday(usedTodayHere());
2246
2632
  const copy = avatarConsentCopy(result.offer.consentVersion, locale2);
2633
+ setVoiceOffered(copy?.voice !== void 0);
2634
+ setNoNotice(copy?.notice === "none");
2247
2635
  if (copy === null || copy.botCheck !== result.offer.botCheck || copy.questions !== result.offer.questions) {
2248
2636
  setMode("suggested");
2249
2637
  go(INERT2);
2250
2638
  return;
2251
2639
  }
2252
- if (result.offer.botCheck === "turnstile" && chunk3AIAJ2L4_js.turnstileSiteKeyOf(live2) === null) {
2640
+ if (result.offer.botCheck === "turnstile" && chunkZ2UP22GV_js.turnstileSiteKeyOf(live2) === null) {
2253
2641
  setMode("suggested");
2254
2642
  go(INERT2);
2255
2643
  return;
@@ -2298,13 +2686,16 @@ function AvatarLiveView({
2298
2686
  heard: false,
2299
2687
  starts: 0,
2300
2688
  owed: true,
2689
+ owedWait: false,
2301
2690
  quiet: null,
2302
2691
  asked: null,
2303
2692
  due: null,
2304
2693
  retry: null,
2305
2694
  sent: /* @__PURE__ */ new Set([context.messageId]),
2306
2695
  seq: 0,
2307
- listenedAt: null
2696
+ listenedAt: null,
2697
+ muted: false,
2698
+ mutedUnheard: false
2308
2699
  } : null;
2309
2700
  sessionRef.current = {
2310
2701
  session,
@@ -2323,6 +2714,7 @@ function AvatarLiveView({
2323
2714
  followUps,
2324
2715
  done: false
2325
2716
  };
2717
+ if (audioRef.current !== null) audioRef.current.muted = false;
2326
2718
  setBlocked(false);
2327
2719
  setNow(received);
2328
2720
  go({
@@ -2410,7 +2802,7 @@ function AvatarLiveView({
2410
2802
  return;
2411
2803
  }
2412
2804
  const refusedFrom = exchangeAtStart.offerUntil - ANSWER_OFFER_MS + SERVICE_MAX_AGE_MS;
2413
- const siteKey = offer.botCheck === "turnstile" ? chunk3AIAJ2L4_js.turnstileSiteKeyOf(live2) : null;
2805
+ const siteKey = offer.botCheck === "turnstile" ? chunkZ2UP22GV_js.turnstileSiteKeyOf(live2) : null;
2414
2806
  if (offer.botCheck === "turnstile" && (siteKey === null || !canBindBotCheck())) {
2415
2807
  go(INERT2);
2416
2808
  return;
@@ -2578,6 +2970,7 @@ function AvatarLiveView({
2578
2970
  [applyAvailability, bindingHolds, cancelWaiting, exchangeNow, launch]
2579
2971
  );
2580
2972
  const stop = react.useCallback(() => {
2973
+ voiceOff(null, true);
2581
2974
  micCancelRef.current();
2582
2975
  const current = phaseRef.current;
2583
2976
  if (current.name === "waiting") {
@@ -2597,9 +2990,10 @@ function AvatarLiveView({
2597
2990
  if (current.name === "pending" || current.name === "live") {
2598
2991
  finish("stopped", "user");
2599
2992
  }
2600
- }, [cancelStart, cancelWaiting, finish, go, reoffer]);
2993
+ }, [cancelStart, cancelWaiting, finish, go, reoffer, voiceOff]);
2601
2994
  const leave = react.useCallback(
2602
2995
  (event) => {
2996
+ voiceOff(sessionRef.current === null ? null : lineNote("tapNext"), false);
2603
2997
  micCancelRef.current();
2604
2998
  const current = phaseRef.current;
2605
2999
  const reason = event === "user" || event === "chat" ? "user" : "page_hide";
@@ -2640,7 +3034,7 @@ function AvatarLiveView({
2640
3034
  return;
2641
3035
  }
2642
3036
  },
2643
- [cancelStart, cancelWaiting, finish, go, goLive, reoffer]
3037
+ [cancelStart, cancelWaiting, finish, go, goLive, reoffer, voiceOff]
2644
3038
  );
2645
3039
  react.useEffect(() => {
2646
3040
  if (!showing) {
@@ -2754,6 +3148,7 @@ function AvatarLiveView({
2754
3148
  const wasLive = phaseRef.current.name === "live";
2755
3149
  switch (event.type) {
2756
3150
  case "speaker-track":
3151
+ holdMute();
2757
3152
  if (event.kind !== "video") {
2758
3153
  handle.voiceArrived = true;
2759
3154
  attend();
@@ -2763,6 +3158,7 @@ function AvatarLiveView({
2763
3158
  if (!inSight()) goLive();
2764
3159
  return;
2765
3160
  case "playback-blocked":
3161
+ holdMute();
2766
3162
  setBlocked(event.blocked);
2767
3163
  handle.soundHeld = event.sound ?? event.blocked;
2768
3164
  attend();
@@ -2793,7 +3189,7 @@ function AvatarLiveView({
2793
3189
  return;
2794
3190
  }
2795
3191
  },
2796
- [attend, finish, goLive, heardVoice, inSight]
3192
+ [attend, finish, goLive, heardVoice, holdMute, inSight]
2797
3193
  );
2798
3194
  react.useEffect(() => {
2799
3195
  if (phase.name !== "pending") return;
@@ -2910,6 +3306,7 @@ function AvatarLiveView({
2910
3306
  wait?.check?.abort();
2911
3307
  const handle = sessionRef.current;
2912
3308
  sessionRef.current = null;
3309
+ if (audioRef.current !== null) audioRef.current.muted = false;
2913
3310
  if (handle && !handle.done) {
2914
3311
  handle.done = true;
2915
3312
  stopAttending(handle);
@@ -2926,7 +3323,8 @@ function AvatarLiveView({
2926
3323
  []
2927
3324
  );
2928
3325
  const any = questions === "any";
2929
- const textMode = any && (usedToday && (phase.name === "needs-answer" || phase.name === "consent" || phase.name === "declined" || phase.name === "ended") || phase.name === "refused" && phase.reason === "limits");
3326
+ const usedHere = usedToday && !voiceOffered;
3327
+ const textMode = any && (usedHere && (phase.name === "needs-answer" || phase.name === "consent" || phase.name === "declined" || phase.name === "ended") || phase.name === "refused" && phase.reason === "limits");
2930
3328
  textBoxRef.current = textMode;
2931
3329
  const askState = !any ? void 0 : textMode ? "text" : phase.name === "needs-answer" ? kind === "empty" ? "ready" : kind : phase.name;
2932
3330
  const followUp = phase.name === "live" ? phase.session.followUp : null;
@@ -2969,14 +3367,146 @@ function AvatarLiveView({
2969
3367
  return;
2970
3368
  }
2971
3369
  const run = handle.followUps;
3370
+ const voiced = micControlRef.current?.mode === "conversation";
2972
3371
  tell(handle, "interrupt");
3372
+ if (voiced) {
3373
+ run.muted = true;
3374
+ run.mutedUnheard = run.owed || run.owedWait;
3375
+ if (audioRef.current !== null) audioRef.current.muted = true;
3376
+ }
2973
3377
  if (run.quiet !== null) clearTimeout(run.quiet);
2974
3378
  run.quiet = null;
2975
3379
  run.owed = false;
2976
3380
  handle.playedOut = true;
2977
3381
  showFollowUp(handle, { speaking: false });
2978
- }, [showFollowUp, tell]);
2979
- const micSource = micChoice.kind === "browser" ? { kind: "browser", notice: micChoice.notice } : micChoice.kind === "stream" && base !== null ? {
3382
+ if (voiced && !run.voice) whenQuiet(handle, QUIET_PLAYED_OUT_MS);
3383
+ }, [showFollowUp, tell, whenQuiet]);
3384
+ const firstArmable = react.useCallback(() => {
3385
+ const current = phaseRef.current;
3386
+ return sessionRef.current === null && !textBoxRef.current && current.name === "needs-answer" && current.offer.copy.notice === "none" && exchangeNow().kind === "empty";
3387
+ }, [exchangeNow]);
3388
+ const armFirst = react.useCallback(
3389
+ (stage, askSeq) => {
3390
+ const current = phaseRef.current;
3391
+ if (current.name !== "needs-answer") return null;
3392
+ const { live: live2, locale: locale2 } = latest.current;
3393
+ unlockSound(audioRef.current);
3394
+ const module = loadModule(live2.load);
3395
+ module.catch(() => void 0);
3396
+ return {
3397
+ stage,
3398
+ consentVersion: current.offer.consentVersion,
3399
+ botCheck: current.offer.botCheck,
3400
+ site: live2.site,
3401
+ locale: locale2,
3402
+ path: pathNow(),
3403
+ module,
3404
+ askSeq
3405
+ };
3406
+ },
3407
+ [pathNow]
3408
+ );
3409
+ const onMicPress = react.useCallback(
3410
+ (gesture) => {
3411
+ const run = voiceRef.current;
3412
+ if (run.on && run.note === null && !gesture) return;
3413
+ const arm = gesture && firstArmable() ? armFirst("pressing", null) : null;
3414
+ voiceSet({
3415
+ on: true,
3416
+ note: null,
3417
+ ...gesture ? { echoes: 0, arm } : {}
3418
+ });
3419
+ },
3420
+ [armFirst, firstArmable, voiceSet]
3421
+ );
3422
+ const echoOf = react.useCallback(() => {
3423
+ const current = phaseRef.current;
3424
+ return current.name === "live" && current.session.followUp !== null && sessionRef.current?.followUps ? current.session.captions.text : null;
3425
+ }, []);
3426
+ const askRef = react.useRef(() => null);
3427
+ const askReasonRef = react.useRef(null);
3428
+ const draftRef = react.useRef("");
3429
+ draftRef.current = draft;
3430
+ askMaxLengthRef.current = askMaxLength;
3431
+ const autoSend = react.useCallback(
3432
+ (text) => {
3433
+ const question = text.trim();
3434
+ const refuse = () => {
3435
+ onMicText(text);
3436
+ voiceOff(lineNote("inBox"), false);
3437
+ };
3438
+ if (question === "" || askReasonRef.current !== null || sendingRef.current || question.length > askMaxLengthRef.current || draftRef.current.trim() !== "") {
3439
+ refuse();
3440
+ return;
3441
+ }
3442
+ const sending = askRef.current(question);
3443
+ if (sending === null) {
3444
+ refuse();
3445
+ return;
3446
+ }
3447
+ const arm = voiceRef.current.arm;
3448
+ voiceSet({
3449
+ echoes: 0,
3450
+ note: lineNote("sending"),
3451
+ ...arm === null ? {} : { arm: { ...arm, stage: "asked", askSeq: askSeqRef.current } }
3452
+ });
3453
+ void sending.then(
3454
+ (asked) => {
3455
+ if (!asked) {
3456
+ voiceOff(lineNote("off"), false);
3457
+ return;
3458
+ }
3459
+ const run = voiceRef.current;
3460
+ if (run.on && sameNote(run.note, lineNote("sending"))) {
3461
+ voiceSet({ note: lineNote("again") });
3462
+ }
3463
+ },
3464
+ () => voiceOff(lineNote("off"), false)
3465
+ );
3466
+ },
3467
+ [onMicText, voiceOff, voiceSet]
3468
+ );
3469
+ const onMicTurn = react.useCallback(
3470
+ (turn) => {
3471
+ const run = voiceRef.current;
3472
+ if (!run.on) return;
3473
+ if (turn.by === "pause" && turn.echo) {
3474
+ if (run.echoes >= MAX_ECHO_DISCARDS || run.arm !== null) {
3475
+ voiceOff(lineNote("off"), false);
3476
+ } else {
3477
+ voiceSet({ echoes: run.echoes + 1, note: null });
3478
+ }
3479
+ return;
3480
+ }
3481
+ if (turn.by === "pause") {
3482
+ autoSend(turn.text);
3483
+ return;
3484
+ }
3485
+ if (turn.by === "silence") {
3486
+ voiceOff(lineNote("noQuestion"), false);
3487
+ return;
3488
+ }
3489
+ if (turn.text.trim() !== "") {
3490
+ onMicText(turn.text);
3491
+ voiceOff(lineNote(turn.by === "limit" ? "atLimit" : "inBox"), false);
3492
+ return;
3493
+ }
3494
+ if (turn.by === "failed") {
3495
+ voiceOff(
3496
+ turn.gesture ? { kind: "failed", reason: turn.failure ?? "failed" } : lineNote("tapNext"),
3497
+ false
3498
+ );
3499
+ return;
3500
+ }
3501
+ voiceOff(lineNote("off"), false);
3502
+ },
3503
+ [autoSend, onMicText, voiceOff, voiceSet]
3504
+ );
3505
+ const micSource = micChoice.kind === "browser" ? {
3506
+ kind: "browser",
3507
+ notice: micChoice.notice,
3508
+ ...micChoice.pauseMs === void 0 ? {} : { pauseMs: micChoice.pauseMs }
3509
+ } : micChoice.kind === "stream" && base !== null ? {
2980
3510
  kind: "stream",
2981
3511
  base,
2982
3512
  offer: micChoice.offer,
@@ -2994,10 +3524,17 @@ function AvatarLiveView({
2994
3524
  locale,
2995
3525
  source: micSource,
2996
3526
  onText: onMicText,
2997
- onStart: onMicStart
3527
+ onStart: onMicStart,
3528
+ onTurn: onMicTurn,
3529
+ onPress: onMicPress,
3530
+ echoOf,
3531
+ interruptible: followUp?.speaking === true,
3532
+ note: voiceRef.current.note
2998
3533
  });
2999
3534
  micCancelRef.current = mic.cancel;
3000
- const micRecording = mic.recording;
3535
+ micControlRef.current = mic;
3536
+ askReasonRef.current = askReason;
3537
+ const micRecording = mic.mode === "conversation" ? mic.speaking : mic.recording;
3001
3538
  react.useEffect(() => {
3002
3539
  const handle = sessionRef.current;
3003
3540
  if (!micRecording || handle === null || handle.followUps === null || handle.done) {
@@ -3035,7 +3572,7 @@ function AvatarLiveView({
3035
3572
  const run = handle.followUps;
3036
3573
  const current = phaseRef.current;
3037
3574
  if (run === null || handle.done || current.name !== "live" || current.session.followUp?.pending !== null || !followUpBox(current, run, conversation2.getConversationToken())) {
3038
- return;
3575
+ return null;
3039
3576
  }
3040
3577
  sendingRef.current = true;
3041
3578
  run.seq += 1;
@@ -3043,17 +3580,17 @@ function AvatarLiveView({
3043
3580
  setDraft("");
3044
3581
  tell(handle, "asking");
3045
3582
  showFollowUp(handle, { pending: "asking", unread: null });
3046
- void conversation2.send(question, [], { surface: "AVATAR" }).then((added) => {
3583
+ return conversation2.send(question, [], { surface: "AVATAR" }).then((added) => {
3047
3584
  if (added === null) {
3048
3585
  setDraft((current2) => current2 === "" ? question : current2);
3049
3586
  }
3050
3587
  if (sessionRef.current !== handle || handle.done || run.seq !== seq) {
3051
- return;
3588
+ return false;
3052
3589
  }
3053
3590
  if (added === null || added.conversationToken !== run.token) {
3054
3591
  showFollowUp(handle, { pending: null });
3055
3592
  attend();
3056
- return;
3593
+ return false;
3057
3594
  }
3058
3595
  run.asked = {
3059
3596
  messageId: added.userMessageId,
@@ -3061,6 +3598,7 @@ function AvatarLiveView({
3061
3598
  answerId: null
3062
3599
  };
3063
3600
  showFollowUp(handle, { pending: "asking" });
3601
+ return true;
3064
3602
  }).finally(() => {
3065
3603
  sendingRef.current = false;
3066
3604
  });
@@ -3072,37 +3610,135 @@ function AvatarLiveView({
3072
3610
  const conversation2 = conversationRef.current;
3073
3611
  const question = text.trim();
3074
3612
  if (conversation2 === null || question === "" || sendingRef.current) {
3075
- return;
3613
+ return null;
3076
3614
  }
3077
3615
  const spoken = !textBoxRef.current;
3078
3616
  const handle = sessionRef.current;
3079
3617
  if (spoken && handle !== null && handle.followUps !== null) {
3080
- askFollowUp(handle, conversation2, question);
3081
- return;
3618
+ return askFollowUp(handle, conversation2, question);
3082
3619
  }
3083
- if (spoken && conversation2.messages.length > 0) return;
3620
+ if (spoken && conversation2.messages.length > 0) return null;
3084
3621
  sendingRef.current = true;
3085
3622
  setAskedHere(null);
3086
3623
  const seq = askSeqRef.current;
3087
3624
  setNote(null);
3088
3625
  setDraft("");
3089
- void conversation2.send(question, [], spoken ? { surface: "AVATAR" } : {}).then((added) => {
3626
+ return conversation2.send(question, [], spoken ? { surface: "AVATAR" } : {}).then((added) => {
3090
3627
  if (added === null) {
3091
3628
  setDraft((current) => current === "" ? question : current);
3092
3629
  }
3093
- if (!spoken || added === null || askSeqRef.current !== seq || added.conversationToken !== (conversationRef.current?.getConversationToken() ?? null)) {
3094
- return;
3630
+ if (added === null || spoken && (askSeqRef.current !== seq || added.conversationToken !== (conversationRef.current?.getConversationToken() ?? null))) {
3631
+ return false;
3095
3632
  }
3633
+ if (!spoken) return true;
3096
3634
  setAskedHere({
3097
3635
  messageId: added.userMessageId,
3098
3636
  token: added.conversationToken
3099
3637
  });
3638
+ return true;
3100
3639
  }).finally(() => {
3101
3640
  sendingRef.current = false;
3102
3641
  });
3103
3642
  },
3104
3643
  [askFollowUp, setAskedHere]
3105
3644
  );
3645
+ askRef.current = ask;
3646
+ const askTyped = react.useCallback(
3647
+ (text) => {
3648
+ voiceOff(null, false);
3649
+ const armable = firstArmable();
3650
+ const sending = ask(text);
3651
+ if (sending !== null && armable) {
3652
+ voiceSet({ arm: armFirst("asked", askSeqRef.current) });
3653
+ }
3654
+ return sending;
3655
+ },
3656
+ [armFirst, ask, firstArmable, voiceOff, voiceSet]
3657
+ );
3658
+ const onDraftChange = react.useCallback(
3659
+ (value) => {
3660
+ setDraft(value);
3661
+ const run = voiceRef.current;
3662
+ if (run.on || run.arm !== null) voiceOff(lineNote("off"), true);
3663
+ },
3664
+ [voiceOff]
3665
+ );
3666
+ const armVoiceStart = react.useCallback(() => {
3667
+ const arm = voiceRef.current.arm;
3668
+ if (arm === null || arm.stage !== "asked") return;
3669
+ const current = phaseRef.current;
3670
+ const exchange2 = exchangeNow();
3671
+ const drop = () => voiceSet({ arm: null });
3672
+ if (arm.askSeq !== askSeqRef.current || current.name !== "needs-answer" && current.name !== "consent") {
3673
+ drop();
3674
+ return;
3675
+ }
3676
+ if (sendingRef.current || exchange2.kind === "asking" && (!exchange2.askedHere || exchange2.pendingAnswerId === null)) {
3677
+ return;
3678
+ }
3679
+ const answerId2 = exchange2.kind === "asking" ? exchange2.pendingAnswerId : exchange2.kind === "answer" ? exchange2.answerId : null;
3680
+ const token2 = conversationRef.current?.getConversationToken() ?? null;
3681
+ const { live: live2, locale: locale2 } = latest.current;
3682
+ const path = pathNow();
3683
+ if (answerId2 === null || !exchange2.askedHere || exchange2.question === null || token2 === null || !isAvatarConversationToken(token2) || arm.consentVersion !== current.offer.consentVersion || arm.botCheck !== current.offer.botCheck || arm.site !== live2.site || arm.locale !== locale2 || arm.path !== path) {
3684
+ drop();
3685
+ return;
3686
+ }
3687
+ voiceSet({ arm: null });
3688
+ waitRef.current = {
3689
+ bound: {
3690
+ token: token2,
3691
+ questionId: exchange2.question.id,
3692
+ answerId: answerId2,
3693
+ site: live2.site,
3694
+ locale: locale2,
3695
+ path,
3696
+ consentVersion: arm.consentVersion,
3697
+ botCheck: arm.botCheck
3698
+ },
3699
+ module: arm.module,
3700
+ check: null
3701
+ };
3702
+ setNote(null);
3703
+ go({ name: "waiting", offer: current.offer });
3704
+ }, [exchangeNow, go, pathNow, voiceSet]);
3705
+ react.useEffect(() => {
3706
+ armVoiceStart();
3707
+ });
3708
+ react.useEffect(() => {
3709
+ const run = voiceRef.current;
3710
+ if (!run.on && run.arm === null) return;
3711
+ const current = phaseRef.current;
3712
+ const live2 = current.name === "live" ? current.session.followUp : null;
3713
+ const keeps = (mic.mode === "conversation" || !run.on) && !textMode && (current.name === "needs-answer" && (sendingRef.current || kind === "empty" || kind === "asking" || kind === "answer") || current.name === "consent" || current.name === "waiting" || current.name === "bot-check" || current.name === "creating" || current.name === "pending" || current.name === "live" && followUpAsks && live2 !== null && !live2.closed && (live2.unread === null || !SESSION_REFUSALS.has(live2.unread)));
3714
+ if (!keeps) voiceOff(lineNote("off"), true);
3715
+ });
3716
+ react.useEffect(() => {
3717
+ const run = voiceRef.current;
3718
+ const controls = micControlRef.current;
3719
+ const handle = sessionRef.current;
3720
+ const followUps = handle?.followUps ?? null;
3721
+ const current = phaseRef.current;
3722
+ if (!run.on || controls === null || controls.mode !== "conversation" || handle === null || followUps === null || handle.done || current.name !== "live") {
3723
+ return;
3724
+ }
3725
+ const shownFollowUp = current.session.followUp;
3726
+ if (shownFollowUp === null || shownFollowUp.speaking || shownFollowUp.pending !== null || shownFollowUp.closed || shownFollowUp.unread !== null && SESSION_REFUSALS.has(shownFollowUp.unread) || !followUpBox(
3727
+ current,
3728
+ followUps,
3729
+ conversationRef.current?.getConversationToken() ?? null
3730
+ )) {
3731
+ return;
3732
+ }
3733
+ if (sendingRef.current || followUps.asked !== null || followUps.due !== null || !playedOutNow(handle) || blockedRef.current || !inSight() || controls.pressing()) {
3734
+ return;
3735
+ }
3736
+ if (handle.endsAt - Date.now() < LISTEN_AGAIN_MIN_LEFT_MS) {
3737
+ voiceOff(lineNote("off"), false);
3738
+ return;
3739
+ }
3740
+ controls.listen();
3741
+ });
3106
3742
  react.useEffect(() => {
3107
3743
  const handle = sessionRef.current;
3108
3744
  const run = handle?.followUps ?? null;
@@ -3177,6 +3813,8 @@ function AvatarLiveView({
3177
3813
  check();
3178
3814
  }
3179
3815
  }, [check, exchangeNow, go, setAskedHere]);
3816
+ const voiceTalk = mic.mode === "conversation" && mic.button !== null && voiceWords !== null;
3817
+ const voiceArming = voiceRef.current.arm?.stage === "asked";
3180
3818
  react.useEffect(() => {
3181
3819
  if (!active) return;
3182
3820
  const focused = document.activeElement;
@@ -3192,8 +3830,12 @@ function AvatarLiveView({
3192
3830
  ]);
3193
3831
  const onPlaying = goLive;
3194
3832
  const enablePlayback = react.useCallback(() => {
3195
- void sessionRef.current?.joined?.enablePlayback().catch(() => void 0);
3196
- }, []);
3833
+ const room = sessionRef.current?.joined ?? null;
3834
+ if (room === null) return;
3835
+ const resumed = room.enablePlayback();
3836
+ holdMute();
3837
+ void resumed.catch(() => void 0).then(holdMute);
3838
+ }, [holdMute]);
3197
3839
  if (off || phase.name === "inert") {
3198
3840
  return (
3199
3841
  // `display: contents`: the earlier Avatar view's picture, line and button lay out in the pane exactly as before.
@@ -3312,7 +3954,7 @@ function AvatarLiveView({
3312
3954
  ]
3313
3955
  }
3314
3956
  );
3315
- const consentActions = (whenReady) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
3957
+ const consentActions = (whenReady, label = whenReady ? S.startWhenReady : S.start) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
3316
3958
  /* @__PURE__ */ jsxRuntime.jsx(
3317
3959
  "button",
3318
3960
  {
@@ -3321,7 +3963,7 @@ function AvatarLiveView({
3321
3963
  "data-boff-explore": whenReady ? "avatar-live-start-when-ready" : "avatar-live-start",
3322
3964
  className: primaryButton,
3323
3965
  onClick: whenReady ? startWhenReady : start,
3324
- children: whenReady ? S.startWhenReady : S.start
3966
+ children: label
3325
3967
  }
3326
3968
  ),
3327
3969
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -3365,7 +4007,14 @@ function AvatarLiveView({
3365
4007
  className: "absolute inset-0 h-full w-full object-cover"
3366
4008
  }
3367
4009
  ),
3368
- any ? null : /* @__PURE__ */ jsxRuntime.jsx("audio", { ref: audioRef, "data-boff-explore": "avatar-live-audio" })
4010
+ any ? null : /* @__PURE__ */ jsxRuntime.jsx(
4011
+ "audio",
4012
+ {
4013
+ ref: audioRef,
4014
+ "data-boff-explore": "avatar-live-audio",
4015
+ onVolumeChange: holdMute
4016
+ }
4017
+ )
3369
4018
  ]
3370
4019
  }
3371
4020
  );
@@ -3498,20 +4147,24 @@ function AvatarLiveView({
3498
4147
  switch (shown.kind) {
3499
4148
  case "empty":
3500
4149
  status = note === "new-chat" ? S.newChatStarted : "";
3501
- lead = /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-base font-semibold", children: S.askHeadline });
4150
+ lead = /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-base font-semibold", children: voiceTalk && voiceWords !== null ? voiceWords.headline : S.askHeadline });
3502
4151
  box = "spoken";
3503
4152
  break;
3504
4153
  case "asking":
3505
4154
  case "answer": {
3506
- const card = shown.askedHere && shown.pendingAnswerId !== null;
3507
- status = note === "cancelled" ? S.armedCancelled : S.gettingAnswer;
3508
- if (card) media2 = stillPicture(true);
4155
+ const card = shown.askedHere && shown.pendingAnswerId !== null && !voiceArming;
4156
+ const words = phase.offer.copy.notice === "none" ? null : phase.offer.copy;
4157
+ status = note === "cancelled" ? words === null && voiceWords !== null ? voiceWords.cancelled : S.armedCancelled : S.gettingAnswer;
4158
+ if (card && words !== null) media2 = stillPicture(true);
3509
4159
  body2 = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3510
4160
  questionLine,
3511
4161
  activityLine,
3512
- card ? consentCard(phase.offer.copy) : null
4162
+ card && words !== null ? consentCard(words) : null
3513
4163
  ] });
3514
- actions2 = card ? consentActions(true) : null;
4164
+ actions2 = card ? consentActions(
4165
+ true,
4166
+ words === null ? voiceWords?.start ?? S.start : S.startWhenReady
4167
+ ) : null;
3515
4168
  break;
3516
4169
  }
3517
4170
  case "text-only":
@@ -3543,6 +4196,16 @@ function AvatarLiveView({
3543
4196
  }
3544
4197
  break;
3545
4198
  case "consent":
4199
+ if (voiceArming) {
4200
+ status = S.armed;
4201
+ body2 = questionLine;
4202
+ break;
4203
+ }
4204
+ if (phase.offer.copy.notice === "none") {
4205
+ body2 = questionLine;
4206
+ actions2 = consentActions(false, voiceWords?.start ?? S.start);
4207
+ break;
4208
+ }
3546
4209
  media2 = stillPicture(true);
3547
4210
  body2 = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3548
4211
  questionLine,
@@ -3634,7 +4297,7 @@ function AvatarLiveView({
3634
4297
  "aria-disabled": reason !== null ? "true" : void 0,
3635
4298
  className: "min-h-11 rounded-full border border-border bg-background px-3 py-2 text-start text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:transition-none",
3636
4299
  onClick: () => {
3637
- if (reason === null) ask(prompt);
4300
+ if (reason === null) void askTyped(prompt);
3638
4301
  },
3639
4302
  children: prompt
3640
4303
  },
@@ -3662,7 +4325,7 @@ function AvatarLiveView({
3662
4325
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-muted-foreground", children: S.newChatNote })
3663
4326
  ] }) : null,
3664
4327
  chips,
3665
- box !== null && usedToday && phase.name !== "live" ? /* @__PURE__ */ jsxRuntime.jsx(
4328
+ box !== null && usedHere && phase.name !== "live" ? /* @__PURE__ */ jsxRuntime.jsx(
3666
4329
  "p",
3667
4330
  {
3668
4331
  "data-boff-explore": "avatar-live-used-today",
@@ -3676,13 +4339,16 @@ function AvatarLiveView({
3676
4339
  {
3677
4340
  value: draft,
3678
4341
  maxLength: askMaxLength,
3679
- onChange: setDraft,
3680
- onAsk: ask,
4342
+ onChange: onDraftChange,
4343
+ onAsk: askTyped,
3681
4344
  reason,
3682
4345
  label: S.askLabel,
3683
- placeholder: S.askPlaceholder,
4346
+ placeholder: box === "spoken" && voiceTalk && voiceWords !== null ? (
4347
+ // None while the mic listens: its line says so, and the box is at its narrowest then.
4348
+ mic.busy ? "" : voiceWords.placeholder
4349
+ ) : S.askPlaceholder,
3684
4350
  send,
3685
- footer: S.askFooter,
4351
+ footer: noNotice ? null : S.askFooter,
3686
4352
  noFiles: S.askNoFiles,
3687
4353
  hint: box === "spoken" ? S.needsAnswerAny : null,
3688
4354
  touchFirst,
@@ -3720,7 +4386,14 @@ function AvatarLiveView({
3720
4386
  actions2
3721
4387
  ] }),
3722
4388
  foot,
3723
- /* @__PURE__ */ jsxRuntime.jsx("audio", { ref: audioRef, "data-boff-explore": "avatar-live-audio" })
4389
+ /* @__PURE__ */ jsxRuntime.jsx(
4390
+ "audio",
4391
+ {
4392
+ ref: audioRef,
4393
+ "data-boff-explore": "avatar-live-audio",
4394
+ onVolumeChange: holdMute
4395
+ }
4396
+ )
3724
4397
  ]
3725
4398
  }
3726
4399
  );
@@ -3752,6 +4425,7 @@ function AvatarLiveView({
3752
4425
  actions = startOver ? startOverActions : continueButton;
3753
4426
  break;
3754
4427
  case "consent":
4428
+ if (phase.offer.copy.notice === "none") break;
3755
4429
  media = stillPicture(true);
3756
4430
  body = consentCard(phase.offer.copy);
3757
4431
  actions = consentActions(false);
@@ -3844,6 +4518,7 @@ function AskBox({
3844
4518
  const footerId = `${uid}-footer`;
3845
4519
  const [refusedFiles, setRefusedFiles] = react.useState(false);
3846
4520
  const empty = value.trim() === "";
4521
+ const describedBy = [hint === null ? null : hintId, footer === null ? null : footerId].filter((id) => id !== null).join(" ") || void 0;
3847
4522
  const submit = () => {
3848
4523
  if (reason !== null || empty) return;
3849
4524
  onAsk(value);
@@ -3886,7 +4561,7 @@ function AskBox({
3886
4561
  rows: 1,
3887
4562
  maxLength,
3888
4563
  "aria-label": label,
3889
- "aria-describedby": hint === null ? footerId : `${hintId} ${footerId}`,
4564
+ "aria-describedby": describedBy,
3890
4565
  placeholder,
3891
4566
  dir: "auto",
3892
4567
  "data-avatar-primary": touchFirst ? void 0 : "",
@@ -3939,7 +4614,7 @@ function AskBox({
3939
4614
  }
3940
4615
  ) : null,
3941
4616
  hint === null ? null : /* @__PURE__ */ jsxRuntime.jsx("p", { id: hintId, className: "sr-only", children: hint }),
3942
- /* @__PURE__ */ jsxRuntime.jsx("p", { id: footerId, className: "text-xs text-muted-foreground", children: footer })
4617
+ footer === null ? null : /* @__PURE__ */ jsxRuntime.jsx("p", { id: footerId, className: "text-xs text-muted-foreground", children: footer })
3943
4618
  ]
3944
4619
  }
3945
4620
  );