@burdenoff/website-sdk 2026.1102.3 → 2026.1104.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.
package/README.md CHANGED
@@ -420,10 +420,24 @@ publishes audio, video or data, and it sends the avatar nothing to say.
420
420
  limits, region, language, paused, a question the avatar cannot read) is one
421
421
  short sentence and "Continue in Chat". A network or CORS failure, or a route
422
422
  that is not there, shows the view as it was before.
423
- - **Ending.** Stop, switching to Chat, closing the panel, hiding the page, and
424
- the live avatar going off under a session (the panel's locale changing to one
423
+ - **Ending.** Stop, closing the panel, leaving the page (`pagehide`), and the
424
+ live avatar going off under a session (the panel's locale changing to one
425
425
  whose strings have not been reviewed) all end the session with a `keepalive`
426
- request and leave the room. The end is a simple CORS request: its body is a
426
+ request and leave the room. Once the create has been sent (connecting,
427
+ joining, speaking), switching to the Chat tab (or pressing Continue in Chat)
428
+ and hiding the page (another browser tab, another app) do **not** end it: the
429
+ Avatar pane is only hidden, the room's video and audio stay attached, and the
430
+ avatar speaks to the end the server planned for it, which a hidden page
431
+ cannot extend. Coming back to the Avatar tab shows the session as it is, with
432
+ the countdown to the server's end, and asks nothing again. Before the create,
433
+ nothing paid exists, so the Chat tab still calls off an availability check, a
434
+ "Start when ready" and a bot check, and a hidden page still calls off a
435
+ "Start when ready" and returns a bot check to the consent card, all with
436
+ nothing created. While a session is created, joined or played, Chat's own
437
+ read-aloud and dictation are held (`ExplorePage`'s `voiceHold`): a reading
438
+ under way stops, and both controls stay where they are, `aria-disabled`, with
439
+ the avatar's status line as their title, until the session ends; nothing in
440
+ Chat ends the session. The end is a simple CORS request: its body is a
427
441
  string and the SDK sets no `content-type`, so the browser sends it as
428
442
  `text/plain;charset=UTF-8` (which the gateway accepts for this route), and on
429
443
  production nothing else is added, so no preflight has to complete while the
@@ -510,9 +524,11 @@ publishes audio, video or data, and it sends the avatar nothing to say.
510
524
  of the same host for the validation check.
511
525
  - **Not verified in this package.** Every test here uses fakes: nothing reaches
512
526
  Turnstile, the gateway, the avatar service or the media room. Left to the
513
- alpha test: real playback on iPhone Safari and Android Chrome; that
514
- `page_hide` ends actually arrive from those browsers; and that the receive-only
515
- token has `canPublish=false` and `canPublishData=false`, and a worker RPC
527
+ alpha test: real playback on iPhone Safari and Android Chrome; that the voice
528
+ keeps playing behind the Chat tab and in a background browser tab there (a
529
+ browser may suspend it; the server still ends the session at its planned
530
+ end); that `page_hide` ends actually arrive from those browsers; and that the
531
+ receive-only token has `canPublish=false` and `canPublishData=false`, and a worker RPC
516
532
  addressed to the viewer is dropped without disconnecting it (the media client
517
533
  answers incoming RPC requests on its own, so "never publishes" holds only as
518
534
  far as the token's permissions do).
@@ -9,6 +9,11 @@ interface AvatarLiveViewProps {
9
9
  live: ExplorePanelAvatarLive;
10
10
  /** The Avatar view is showing: the panel is open and its Avatar tab is selected. */
11
11
  active: boolean;
12
+ /**
13
+ * The panel is open, whichever tab it shows. With `active` it tells the Chat tab (open, not active: the pane is only
14
+ * hidden, and a session under way keeps playing) from the panel closing (not open: what is under way ends).
15
+ */
16
+ open: boolean;
12
17
  locale: ExploreLocale;
13
18
  /** The product the panel speaks for, when it is one product's own; null on the portfolio's. */
14
19
  productName: string | null;
@@ -19,8 +24,18 @@ interface AvatarLiveViewProps {
19
24
  };
20
25
  /** The Avatar tab as it was before the live avatar: shown whenever the live avatar is not offered or not reachable. */
21
26
  fallback: ReactNode;
22
- /** Back to the Chat tab. Leaving the Avatar tab ends a session, like any other way out of it. */
27
+ /**
28
+ * Back to the Chat tab. Like the Chat tab itself, it calls off a start that has not been sent yet, and leaves a
29
+ * session under way speaking.
30
+ */
23
31
  onContinueInChat: () => void;
32
+ /**
33
+ * Told whenever the avatar's voice starts or stops being the page's: its status line while a session is being
34
+ * created, joined or played ("Connecting to the live avatar…", "The avatar is speaking."), and null otherwise (and
35
+ * when the view goes). The panel holds Chat's own read-aloud and dictation meanwhile, so the page never speaks in two
36
+ * voices and the microphone never takes down the avatar's.
37
+ */
38
+ onVoiceHold?: (line: string | null) => void;
24
39
  /** "Continue in Chat" in the panel's language: the panel's own word for it, so this chunk carries none of the panel's. */
25
40
  continueInChat: string;
26
41
  /**
@@ -51,6 +66,6 @@ interface AvatarLiveViewProps {
51
66
  /** The panel's conversation, null when there is no provider. */
52
67
  conversation: AvatarConversation | null;
53
68
  }
54
- declare function AvatarLiveView({ live, active, locale, productName, still, fallback, onContinueInChat, continueInChat, newChat, send, tryAsking, prompts, touchFirst, currentPath, off, base, headers, conversation, }: AvatarLiveViewProps): react.JSX.Element;
69
+ declare function AvatarLiveView({ live, active, open, locale, productName, still, fallback, onContinueInChat, onVoiceHold, continueInChat, newChat, send, tryAsking, prompts, touchFirst, currentPath, off, base, headers, conversation, }: AvatarLiveViewProps): react.JSX.Element;
55
70
 
56
71
  export { AvatarLiveView, type AvatarLiveViewProps };
@@ -9,6 +9,11 @@ interface AvatarLiveViewProps {
9
9
  live: ExplorePanelAvatarLive;
10
10
  /** The Avatar view is showing: the panel is open and its Avatar tab is selected. */
11
11
  active: boolean;
12
+ /**
13
+ * The panel is open, whichever tab it shows. With `active` it tells the Chat tab (open, not active: the pane is only
14
+ * hidden, and a session under way keeps playing) from the panel closing (not open: what is under way ends).
15
+ */
16
+ open: boolean;
12
17
  locale: ExploreLocale;
13
18
  /** The product the panel speaks for, when it is one product's own; null on the portfolio's. */
14
19
  productName: string | null;
@@ -19,8 +24,18 @@ interface AvatarLiveViewProps {
19
24
  };
20
25
  /** The Avatar tab as it was before the live avatar: shown whenever the live avatar is not offered or not reachable. */
21
26
  fallback: ReactNode;
22
- /** Back to the Chat tab. Leaving the Avatar tab ends a session, like any other way out of it. */
27
+ /**
28
+ * Back to the Chat tab. Like the Chat tab itself, it calls off a start that has not been sent yet, and leaves a
29
+ * session under way speaking.
30
+ */
23
31
  onContinueInChat: () => void;
32
+ /**
33
+ * Told whenever the avatar's voice starts or stops being the page's: its status line while a session is being
34
+ * created, joined or played ("Connecting to the live avatar…", "The avatar is speaking."), and null otherwise (and
35
+ * when the view goes). The panel holds Chat's own read-aloud and dictation meanwhile, so the page never speaks in two
36
+ * voices and the microphone never takes down the avatar's.
37
+ */
38
+ onVoiceHold?: (line: string | null) => void;
24
39
  /** "Continue in Chat" in the panel's language: the panel's own word for it, so this chunk carries none of the panel's. */
25
40
  continueInChat: string;
26
41
  /**
@@ -51,6 +66,6 @@ interface AvatarLiveViewProps {
51
66
  /** The panel's conversation, null when there is no provider. */
52
67
  conversation: AvatarConversation | null;
53
68
  }
54
- declare function AvatarLiveView({ live, active, locale, productName, still, fallback, onContinueInChat, continueInChat, newChat, send, tryAsking, prompts, touchFirst, currentPath, off, base, headers, conversation, }: AvatarLiveViewProps): react.JSX.Element;
69
+ declare function AvatarLiveView({ live, active, open, locale, productName, still, fallback, onContinueInChat, onVoiceHold, continueInChat, newChat, send, tryAsking, prompts, touchFirst, currentPath, off, base, headers, conversation, }: AvatarLiveViewProps): react.JSX.Element;
55
70
 
56
71
  export { AvatarLiveView, type AvatarLiveViewProps };
@@ -1026,11 +1026,13 @@ function clock(seconds) {
1026
1026
  function AvatarLiveView({
1027
1027
  live,
1028
1028
  active,
1029
+ open,
1029
1030
  locale,
1030
1031
  productName,
1031
1032
  still,
1032
1033
  fallback,
1033
1034
  onContinueInChat,
1035
+ onVoiceHold,
1034
1036
  continueInChat,
1035
1037
  newChat,
1036
1038
  send,
@@ -1078,9 +1080,19 @@ function AvatarLiveView({
1078
1080
  const askMaxLength = chatMaxLength !== void 0 && Number.isFinite(chatMaxLength) && chatMaxLength >= 1 ? Math.min(ASK_MAX_LENGTH, Math.floor(chatMaxLength)) : ASK_MAX_LENGTH;
1079
1081
  const [note, setNote] = react.useState(null);
1080
1082
  const [usedToday, setUsedToday] = react.useState(false);
1083
+ const showing = active && !off;
1084
+ const behindChat = open && !active && !off;
1081
1085
  const phaseRef = react.useRef(IDLE);
1082
- const latest = react.useRef({ live, locale, base, headers, off, currentPath });
1083
- latest.current = { live, locale, base, headers, off, currentPath };
1086
+ const latest = react.useRef({
1087
+ live,
1088
+ locale,
1089
+ base,
1090
+ headers,
1091
+ off,
1092
+ currentPath,
1093
+ showing
1094
+ });
1095
+ latest.current = { live, locale, base, headers, off, currentPath, showing };
1084
1096
  const conversationRef = react.useRef(conversation);
1085
1097
  conversationRef.current = conversation;
1086
1098
  const waitRef = react.useRef(null);
@@ -1099,6 +1111,10 @@ function AvatarLiveView({
1099
1111
  phaseRef.current = next;
1100
1112
  setPhase(next);
1101
1113
  }, []);
1114
+ const inSight = react.useCallback(
1115
+ () => latest.current.showing && (typeof document === "undefined" || document.visibilityState !== "hidden"),
1116
+ []
1117
+ );
1102
1118
  const setMode = react.useCallback((next) => {
1103
1119
  questionsRef.current = next;
1104
1120
  setQuestions(next);
@@ -1153,6 +1169,17 @@ function AvatarLiveView({
1153
1169
  },
1154
1170
  [go]
1155
1171
  );
1172
+ const goLive = react.useCallback(() => {
1173
+ const current = phaseRef.current;
1174
+ const handle = sessionRef.current;
1175
+ if (current.name !== "pending" || !handle) return;
1176
+ handle.speakerArrived = true;
1177
+ if (questionsRef.current === "any") {
1178
+ noteUsedToday();
1179
+ setUsedToday(true);
1180
+ }
1181
+ go({ name: "live", session: current.session });
1182
+ }, [go]);
1156
1183
  const cancelStart = react.useCallback((reason) => {
1157
1184
  const run = startRef.current;
1158
1185
  if (!run) return;
@@ -1489,9 +1516,11 @@ function AvatarLiveView({
1489
1516
  }
1490
1517
  }, [cancelStart, cancelWaiting, finish, go, reoffer]);
1491
1518
  const leave = react.useCallback(
1492
- (reason) => {
1519
+ (event) => {
1493
1520
  const current = phaseRef.current;
1521
+ const reason = event === "user" || event === "chat" ? "user" : "page_hide";
1494
1522
  const how = reason === "user" ? "stopped" : "interrupted";
1523
+ const keepsSession = event === "chat" || event === "hidden";
1495
1524
  switch (current.name) {
1496
1525
  case "checking": {
1497
1526
  if (reason !== "user") return;
@@ -1509,26 +1538,36 @@ function AvatarLiveView({
1509
1538
  go(reason === "user" ? IDLE : reoffer(current.offer));
1510
1539
  return;
1511
1540
  case "creating":
1541
+ if (keepsSession) return;
1512
1542
  cancelStart(reason);
1513
1543
  go({ name: "ended", how });
1514
1544
  return;
1515
1545
  case "pending":
1516
1546
  case "live":
1547
+ if (keepsSession) {
1548
+ if (current.name === "pending" && sessionRef.current?.speakerArrived) {
1549
+ goLive();
1550
+ }
1551
+ return;
1552
+ }
1517
1553
  finish(how, reason);
1518
1554
  return;
1519
1555
  default:
1520
1556
  return;
1521
1557
  }
1522
1558
  },
1523
- [cancelStart, cancelWaiting, finish, go, reoffer]
1559
+ [cancelStart, cancelWaiting, finish, go, goLive, reoffer]
1524
1560
  );
1525
- const showing = active && !off;
1526
1561
  react.useEffect(() => {
1527
1562
  if (!showing) {
1528
- leave("user");
1563
+ leave(behindChat ? "chat" : "user");
1529
1564
  return;
1530
1565
  }
1531
1566
  const current = phaseRef.current;
1567
+ if (current.name === "creating" || current.name === "pending" || current.name === "live") {
1568
+ setNow(Date.now());
1569
+ return;
1570
+ }
1532
1571
  const offered = offeredNow();
1533
1572
  const used = offered !== null && claimedRef.current.has(offered);
1534
1573
  if (current.name === "ended" && used) return;
@@ -1541,7 +1580,7 @@ function AvatarLiveView({
1541
1580
  current.name === "checking" && checkRef.current === null) {
1542
1581
  check();
1543
1582
  }
1544
- }, [showing, check, leave, offeredNow]);
1583
+ }, [showing, behindChat, check, leave, offeredNow]);
1545
1584
  react.useEffect(() => {
1546
1585
  if (askedLocale.current === locale) return;
1547
1586
  askedLocale.current = locale;
@@ -1628,7 +1667,9 @@ function AvatarLiveView({
1628
1667
  const wasLive = phaseRef.current.name === "live";
1629
1668
  switch (event.type) {
1630
1669
  case "speaker-track":
1631
- if (event.kind === "video") handle.speakerArrived = true;
1670
+ if (event.kind !== "video") return;
1671
+ handle.speakerArrived = true;
1672
+ if (!inSight()) goLive();
1632
1673
  return;
1633
1674
  case "playback-blocked":
1634
1675
  setBlocked(event.blocked);
@@ -1645,7 +1686,7 @@ function AvatarLiveView({
1645
1686
  return;
1646
1687
  }
1647
1688
  },
1648
- [finish]
1689
+ [finish, goLive, inSight]
1649
1690
  );
1650
1691
  react.useEffect(() => {
1651
1692
  if (phase.name !== "pending") return;
@@ -1719,11 +1760,23 @@ function AvatarLiveView({
1719
1760
  const timer = setInterval(() => setNow(Date.now()), 1e3);
1720
1761
  return () => clearInterval(timer);
1721
1762
  }, [ticking]);
1763
+ const voiceLine = phase.name === "live" ? S.speaking : phase.name === "creating" || phase.name === "pending" ? S.connecting : null;
1764
+ const onVoiceHoldRef = react.useRef(onVoiceHold);
1765
+ onVoiceHoldRef.current = onVoiceHold;
1766
+ react.useEffect(() => {
1767
+ onVoiceHoldRef.current?.(voiceLine);
1768
+ }, [voiceLine]);
1769
+ react.useEffect(() => () => onVoiceHoldRef.current?.(null), []);
1722
1770
  react.useEffect(() => {
1723
1771
  if (typeof window === "undefined") return;
1724
1772
  const onPageHide = () => leave("page_hide");
1725
1773
  const onVisibility = () => {
1726
- if (document.visibilityState === "hidden") leave("page_hide");
1774
+ if (document.visibilityState === "hidden") {
1775
+ leave("hidden");
1776
+ return;
1777
+ }
1778
+ const current = phaseRef.current.name;
1779
+ if (current === "pending" || current === "live") setNow(Date.now());
1727
1780
  };
1728
1781
  window.addEventListener("pagehide", onPageHide);
1729
1782
  document.addEventListener("visibilitychange", onVisibility);
@@ -1829,17 +1882,7 @@ function AvatarLiveView({
1829
1882
  if (focused && focused !== document.body && focused.isConnected) return;
1830
1883
  rootRef.current?.querySelector(PRIMARY)?.focus();
1831
1884
  }, [active, phase.name, askState, shown.pendingAnswerId, blocked]);
1832
- const onPlaying = react.useCallback(() => {
1833
- const current = phaseRef.current;
1834
- const handle = sessionRef.current;
1835
- if (current.name !== "pending" || !handle) return;
1836
- handle.speakerArrived = true;
1837
- if (questionsRef.current === "any") {
1838
- noteUsedToday();
1839
- setUsedToday(true);
1840
- }
1841
- go({ name: "live", session: current.session });
1842
- }, [go]);
1885
+ const onPlaying = goLive;
1843
1886
  const enablePlayback = react.useCallback(() => {
1844
1887
  void sessionRef.current?.joined?.enablePlayback().catch(() => void 0);
1845
1888
  }, []);