@oxvo/ai-live-assist 7.5.8 → 7.5.9

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.
@@ -1309,6 +1309,8 @@ class VoicePresenceView {
1309
1309
  const previous = this.audio.srcObject;
1310
1310
  const streamChanged = previous !== stream;
1311
1311
  const analysisChanged = !this.outputAnalyserMatchesStream(stream);
1312
+ if (!streamChanged && !analysisChanged)
1313
+ return;
1312
1314
  if (streamChanged) {
1313
1315
  this.playbackGeneration += 1;
1314
1316
  this.playbackResumePending = false;
@@ -1329,9 +1331,11 @@ class VoicePresenceView {
1329
1331
  this.emitPlaybackState("waiting");
1330
1332
  this.syncPresenceFromPlayback();
1331
1333
  }
1332
- this.audio.srcObject = stream;
1333
- if (streamChanged)
1334
+ // Track-set changes can rebuild analysis without reloading ongoing playout.
1335
+ if (streamChanged) {
1336
+ this.audio.srcObject = stream;
1334
1337
  this.bindPlaybackState();
1338
+ }
1335
1339
  if (previous &&
1336
1340
  typeof MediaStream !== "undefined" &&
1337
1341
  previous instanceof MediaStream &&
@@ -1344,6 +1348,8 @@ class VoicePresenceView {
1344
1348
  this.bindRemoteAudioTrackState(stream);
1345
1349
  this.resumeOutputAudioContext();
1346
1350
  this.startOutputEvidenceSampling();
1351
+ if (!streamChanged)
1352
+ return;
1347
1353
  const generation = this.playbackGeneration;
1348
1354
  void this.audio.play().catch(() => {
1349
1355
  if (!this.playbackSourceIsCurrent(generation, stream))
@@ -34,7 +34,7 @@ export declare const normalizeAiGuideOpenInput: (input?: AiGuideOpenInput) => Re
34
34
  export declare const resolveAiGuideProtocolVersion: (bootstrap: Pick<BootstrapConfig, "experienceMode" | "minimumProtocolVersion" | "protocol">) => 1 | 2;
35
35
  export default class AiLiveAssist {
36
36
  private readonly options;
37
- readonly version = "7.5.8";
37
+ readonly version = "7.5.9";
38
38
  private readonly host;
39
39
  private readonly guideAdmission;
40
40
  private readonly standaloneAdmissionPending;
package/lib/context.d.ts CHANGED
@@ -27,6 +27,7 @@ export declare class PageContextCollector {
27
27
  private targetIds;
28
28
  private pageScrollIds;
29
29
  private navigationTargetBindings;
30
+ private navigationDisclosureLinks;
30
31
  private fieldTargetBindings;
31
32
  private fieldAppliedRevisions;
32
33
  private textIds;
@@ -70,6 +71,8 @@ export declare class PageContextCollector {
70
71
  private type;
71
72
  private select;
72
73
  private submit;
74
+ private exposedNavigationLinks;
75
+ private expandedState;
73
76
  private safeState;
74
77
  private scrollTargetIntoView;
75
78
  private waitForScrollSettlement;
package/lib/context.js CHANGED
@@ -17,7 +17,7 @@ const RELEASED_NAVIGATION_SETTLEMENT_TIMEOUT_MS = 5000;
17
17
  const FRAMER_GENERATED_PAGE_SELECTOR = '[data-framer-generated-page]';
18
18
  const FRAMER_TOGGLE_ROOT_SELECTOR = '[data-framer-name="Toggle"]';
19
19
  const FRAMER_TOGGLE_CONTROL_SELECTOR = 'div[name="Toggle Switch"][data-highlight="true"][tabindex]';
20
- const ACTIONABLE_MUTATION_SELECTOR = `a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="option"],[role="checkbox"],[role="radio"],[role="switch"],[role="combobox"],[role="listbox"],[role="treeitem"],[role="slider"],[role="spinbutton"],[role="searchbox"],[role="textbox"],${FRAMER_TOGGLE_CONTROL_SELECTOR}`;
20
+ const ACTIONABLE_MUTATION_SELECTOR = `a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="option"],[role="checkbox"],[role="radio"],[role="switch"],[role="combobox"],[role="listbox"],[role="treeitem"],[role="slider"],[role="spinbutton"],[role="searchbox"],[role="textbox"],[data-highlight="true"][tabindex],${FRAMER_TOGGLE_CONTROL_SELECTOR}`;
21
21
  const MAX_FRAMER_TOGGLE_LABEL = 120;
22
22
  const PRIVATE_SELECTOR = [
23
23
  '[data-oxvo-ai-live-assist-root]',
@@ -77,6 +77,7 @@ const navigationTargetBinding = (target) => canonicalJson({
77
77
  autocomplete: target.autocomplete ?? null,
78
78
  semanticRegion: target.semanticRegion ?? null,
79
79
  ...(target.navigationMenu === true ? { navigationMenu: true } : {}),
80
+ ...(target.navigation ? { navigation: target.navigation } : {}),
80
81
  destinationPath: target.destinationPath ?? null,
81
82
  sensitivity: target.sensitivity,
82
83
  protectedRegion: target.protectedRegion ?? null,
@@ -122,6 +123,7 @@ const actionTargetFingerprintBinding = (target) => canonicalJson({
122
123
  autocomplete: target.autocomplete ?? null,
123
124
  semanticRegion: target.semanticRegion ?? null,
124
125
  ...(target.navigationMenu === true ? { navigationMenu: true } : {}),
126
+ ...(target.navigation ? { navigation: target.navigation } : {}),
125
127
  destinationPath: target.destinationPath ?? null,
126
128
  sensitivity: target.sensitivity,
127
129
  protectedRegion: target.protectedRegion ?? null,
@@ -218,8 +220,27 @@ const interactionPoint = (element, trustedGuideRoot = null) => {
218
220
  return null;
219
221
  };
220
222
  const isCovered = (element, trustedGuideRoot = null) => interactionPoint(element, trustedGuideRoot) === null;
223
+ const FRAMER_NAVIGATION_CONTROL_SELECTOR = 'div[data-highlight="true"][tabindex]';
224
+ const framerNavigationRoot = (element) => {
225
+ if (!(element instanceof HTMLElement) || !element.matches(FRAMER_NAVIGATION_CONTROL_SELECTOR) ||
226
+ element.tabIndex < 0 || element.hasAttribute('role') || element.hasAttribute('aria-pressed') ||
227
+ element.hasAttribute('aria-checked') || !element.getAttribute('data-framer-name')?.trim() ||
228
+ !element.closest(FRAMER_GENERATED_PAGE_SELECTOR) || element.closest(`form,${PRIVATE_SELECTOR}`) ||
229
+ element.querySelector('a,button,input,select,textarea,summary,form,[role],[tabindex],[contenteditable]') ||
230
+ element.textContent?.trim())
231
+ return null;
232
+ const nav = element.closest('nav,[role="navigation"]');
233
+ if (!nav)
234
+ return null;
235
+ const candidates = nav.querySelectorAll(FRAMER_NAVIGATION_CONTROL_SELECTOR);
236
+ if (candidates.length > MAX_TARGETS)
237
+ return null;
238
+ const rendered = Array.from(candidates).filter(candidate => isRendered(candidate) &&
239
+ candidate.closest('nav,[role="navigation"]') === nav);
240
+ return rendered.length === 1 && rendered[0] === element ? nav : null;
241
+ };
221
242
  const isNavigationMenu = (element, role) => {
222
- if (role !== 'button' || literalBooleanAttribute(element, 'aria-expanded') === undefined ||
243
+ if (role !== 'button' || (literalBooleanAttribute(element, 'aria-expanded') === undefined && !framerNavigationRoot(element)) ||
223
244
  element.hasAttribute('aria-pressed') || element.hasAttribute('aria-checked') ||
224
245
  element instanceof HTMLButtonElement && element.form !== null && element.type !== 'button')
225
246
  return false;
@@ -242,27 +263,31 @@ const sameNavigationDestination = (actual, expected) => {
242
263
  const left = new URL(actual), right = new URL(expected);
243
264
  return left.origin === right.origin && left.pathname === right.pathname && left.search === right.search;
244
265
  };
245
- const safeDestinationPath = (element) => {
246
- // An empty href is a control, not authority to navigate to the current URL.
247
- if (!(element instanceof HTMLAnchorElement) || !element.getAttribute('href')?.trim())
248
- return null;
266
+ const publicNavigation = (element) => {
267
+ if (!(element instanceof HTMLAnchorElement) || !element.getAttribute('href')?.trim() ||
268
+ !supportsCurrentTabNavigation(element))
269
+ return undefined;
249
270
  try {
250
271
  const destination = new URL(element.href, window.location.href);
251
- if (destination.origin !== window.location.origin ||
252
- destination.username !== '' || destination.password !== '' ||
253
- destination.search ||
254
- destination.hash ||
255
- !destination.pathname.startsWith('/') ||
256
- destination.pathname.startsWith('//') ||
257
- destination.pathname.length > 1024) {
258
- return null;
259
- }
260
- return destination.pathname;
272
+ if (!['http:', 'https:'].includes(destination.protocol) ||
273
+ (destination.origin !== window.location.origin && destination.protocol !== 'https:') ||
274
+ destination.username || destination.password || destination.search || destination.hash ||
275
+ destination.href.length > 2048 || destination.pathname.length > 1024 ||
276
+ !/^\/(?:[A-Za-z0-9_~-]+(?:\.[A-Za-z0-9_~-]+)*\/?)*$/.test(destination.pathname))
277
+ return undefined;
278
+ return { url: destination.href, target: linkBrowsingTarget(element) === '_blank' ? '_blank' : '_self' };
261
279
  }
262
280
  catch {
263
- return null;
281
+ return undefined;
264
282
  }
265
283
  };
284
+ const safeDestinationPath = (element) => {
285
+ const navigation = publicNavigation(element);
286
+ if (!navigation)
287
+ return null;
288
+ const destination = new URL(navigation.url);
289
+ return destination.origin === window.location.origin ? destination.pathname : null;
290
+ };
266
291
  const sensitivityFor = (element, name) => {
267
292
  const input = element instanceof HTMLInputElement ? element : null;
268
293
  const type = input?.type.toLowerCase() ?? '';
@@ -296,6 +321,8 @@ const sensitivityFor = (element, name) => {
296
321
  const roleFor = (element) => {
297
322
  if (framerToggleDescriptor(element))
298
323
  return 'checkbox';
324
+ if (framerNavigationRoot(element))
325
+ return 'button';
299
326
  const explicit = normalize(element.getAttribute('role') ?? '', 64);
300
327
  if (explicit)
301
328
  return explicit;
@@ -363,6 +390,7 @@ const nameFor = (element) => {
363
390
  return normalize(semanticName ||
364
391
  associatedLabel ||
365
392
  attributeName ||
393
+ (framerNavigationRoot(element) ? safeAttribute('data-framer-name', 120) : '') ||
366
394
  (buttonValue && sensitivityFor(element, buttonValue) === 'none' ? buttonValue : '') ||
367
395
  (safeDestinationPath(element) === '/' ? 'Home' : ''), 300);
368
396
  };
@@ -764,6 +792,7 @@ export class PageContextCollector {
764
792
  this.targetIds = new WeakMap();
765
793
  this.pageScrollIds = new WeakMap();
766
794
  this.navigationTargetBindings = new WeakMap();
795
+ this.navigationDisclosureLinks = new WeakMap();
767
796
  this.fieldTargetBindings = new WeakMap();
768
797
  this.fieldAppliedRevisions = new WeakMap();
769
798
  this.textIds = new WeakMap();
@@ -987,6 +1016,9 @@ export class PageContextCollector {
987
1016
  'data-framer-name',
988
1017
  'disabled',
989
1018
  'href',
1019
+ 'target',
1020
+ 'download',
1021
+ 'data-highlight',
990
1022
  'hidden',
991
1023
  'inert',
992
1024
  'role',
@@ -1033,6 +1065,7 @@ export class PageContextCollector {
1033
1065
  document.removeEventListener('change', this.handleControlStateChange, true);
1034
1066
  this.restoreNavigationHooks();
1035
1067
  this.targets.clear();
1068
+ this.navigationDisclosureLinks = new WeakMap();
1036
1069
  this.executingActionId = null;
1037
1070
  this.pendingNavigation = null;
1038
1071
  this.pendingCapture = false;
@@ -1073,7 +1106,8 @@ export class PageContextCollector {
1073
1106
  continue;
1074
1107
  const role = roleFor(element);
1075
1108
  const hasStatefulButtonAttribute = role === 'button' &&
1076
- (element.hasAttribute('aria-expanded') || element.hasAttribute('aria-pressed'));
1109
+ (element.hasAttribute('aria-expanded') || element.hasAttribute('aria-pressed') ||
1110
+ Boolean(framerNavigationRoot(element)));
1077
1111
  const hasUnsupportedMixedPressedState = role === 'button' &&
1078
1112
  element.getAttribute('aria-pressed')?.trim().toLowerCase() === 'mixed';
1079
1113
  if (hasUnsupportedMixedPressedState ||
@@ -1110,7 +1144,8 @@ export class PageContextCollector {
1110
1144
  : accessibleNameSourceSensitivity(element);
1111
1145
  const visible = isVisible(element);
1112
1146
  const destinationPath = safeDestinationPath(element);
1113
- const expanded = literalBooleanAttribute(element, 'aria-expanded');
1147
+ const navigation = publicNavigation(element);
1148
+ const expanded = this.expandedState(element);
1114
1149
  const pressed = literalBooleanAttribute(element, 'aria-pressed');
1115
1150
  const checked = checkedStateFor(element);
1116
1151
  const targetIdentity = {
@@ -1127,6 +1162,7 @@ export class PageContextCollector {
1127
1162
  ...(semanticRegion ? { semanticRegion } : {}),
1128
1163
  ...(this.statefulButtonActionProof && isNavigationMenu(element, role) ? { navigationMenu: true } : {}),
1129
1164
  ...(destinationPath ? { destinationPath } : {}),
1165
+ ...(navigation ? { navigation } : {}),
1130
1166
  sensitivity,
1131
1167
  protectedRegion: sensitivity !== 'none' || regionAssessment.matchedRuleDigests.length > 0,
1132
1168
  frameOrigin: 'same_origin',
@@ -1144,7 +1180,7 @@ export class PageContextCollector {
1144
1180
  };
1145
1181
  const priorTargetId = this.targetIds.get(element);
1146
1182
  const boundNavigation = targetIdentity.consequence === 'navigation' &&
1147
- typeof targetIdentity.destinationPath === 'string';
1183
+ targetIdentity.navigation !== undefined;
1148
1184
  const binding = boundNavigation ? navigationTargetBinding(targetIdentity) : null;
1149
1185
  const priorBinding = this.navigationTargetBindings.get(element);
1150
1186
  const boundField = isNativeEditableField(element) && sensitivity === 'none' &&
@@ -1416,12 +1452,13 @@ export class PageContextCollector {
1416
1452
  grant.targetId.startsWith(`${BOUND_FIELD_TARGET_PREFIX}_`) &&
1417
1453
  Boolean(current && isNativeEditableField(current.element) && this.fieldTargetBindings.has(current.element));
1418
1454
  const stablePageScroll = (current) => grant.action === 'scroll' && Boolean(current?.pageScroll && current.element === document.scrollingElement);
1455
+ const stableNavigation = (current) => (grant.action === 'click' || grant.action === 'scroll') &&
1456
+ grant.targetId.startsWith(`${BOUND_NAVIGATION_TARGET_PREFIX}_`) &&
1457
+ current?.element instanceof HTMLAnchorElement &&
1458
+ current.target.consequence === 'navigation' && current.target.navigation !== undefined;
1419
1459
  const revisionMatchesOrStableTarget = () => grant.domRevision === this.revision ||
1420
1460
  (grant.domRevision < this.revision &&
1421
- (stableField(handle) || stablePageScroll(handle) || ((grant.action === 'click' || grant.action === 'scroll') &&
1422
- handle?.element instanceof HTMLAnchorElement &&
1423
- handle.target.consequence === 'navigation' &&
1424
- typeof handle.target.destinationPath === 'string')));
1461
+ (stableField(handle) || stablePageScroll(handle) || stableNavigation(handle)));
1425
1462
  if (expiresAt <= Date.now() ||
1426
1463
  grant.pageId !== this.pageId ||
1427
1464
  grant.pageUrl !== safePageUrl() ||
@@ -1458,7 +1495,7 @@ export class PageContextCollector {
1458
1495
  grant.pageUrl !== safePageUrl() ||
1459
1496
  grant.policyVersion !== this.policyVersion ||
1460
1497
  !revisionMatchesOrStableTarget() ||
1461
- (handle !== fingerprintedHandle && !((stableField(handle) || stablePageScroll(handle)) &&
1498
+ (handle !== fingerprintedHandle && !((stableField(handle) || stablePageScroll(handle) || stableNavigation(handle)) &&
1462
1499
  handle?.element === fingerprintedHandle.element &&
1463
1500
  actionTargetFingerprintBinding(handle.target) === actionTargetFingerprintBinding(fingerprintedHandle.target))) ||
1464
1501
  !handle ||
@@ -1544,6 +1581,9 @@ export class PageContextCollector {
1544
1581
  this.releasedNavigation = null;
1545
1582
  this.finishReleasedNavigationSettlement('rejected');
1546
1583
  let postActionSnapshot = null;
1584
+ let navigationReceipt;
1585
+ const disclosureRoot = handle.target.navigationMenu ? framerNavigationRoot(element) : null;
1586
+ const priorDisclosureLinks = disclosureRoot ? this.exposedNavigationLinks(disclosureRoot) : null;
1547
1587
  try {
1548
1588
  switch (grant.action) {
1549
1589
  case 'scroll': {
@@ -1654,7 +1694,7 @@ export class PageContextCollector {
1654
1694
  element.focus({ preventScroll: false });
1655
1695
  break;
1656
1696
  case 'click':
1657
- this.click(element, handle, grant.actionId, grant.desiredChecked, grant.desiredExpanded, grant.desiredPressed, grant.destinationPath);
1697
+ navigationReceipt = this.click(element, handle, grant.actionId, grant.desiredChecked, grant.desiredExpanded, grant.desiredPressed, grant.destinationPath);
1658
1698
  break;
1659
1699
  case 'type':
1660
1700
  this.type(element, grant.value ?? '');
@@ -1676,9 +1716,28 @@ export class PageContextCollector {
1676
1716
  if (grant.action !== 'scroll') {
1677
1717
  await new Promise((resolve) => setTimeout(resolve, 80));
1678
1718
  }
1719
+ if (handle.target.navigationMenu && grant.action === 'click') {
1720
+ const deadline = Date.now() + 1000;
1721
+ for (let frames = 0; frames < 60 && Date.now() < deadline && element.isConnected && !executionStale(); frames++) {
1722
+ if (disclosureRoot && priorDisclosureLinks) {
1723
+ const added = new Set([...this.exposedNavigationLinks(disclosureRoot)]
1724
+ .filter(link => !priorDisclosureLinks.has(link)));
1725
+ if (added.size)
1726
+ this.navigationDisclosureLinks.set(element, added);
1727
+ }
1728
+ if (this.actionApplied(grant, element, handle))
1729
+ break;
1730
+ await nextAnimationFrameOrTimeout(Math.min(50, deadline - Date.now()));
1731
+ }
1732
+ }
1679
1733
  const postActionStale = executionStale();
1680
- if (postActionStale)
1681
- return postActionStale;
1734
+ if (postActionStale) {
1735
+ this.deferredNavigation = null;
1736
+ return navigationReceipt ? { ...postActionStale, observed: {
1737
+ ...postActionStale.observed, targetState: 'unknown',
1738
+ navigation: { url: navigationReceipt.url, outcome: 'unconfirmed' },
1739
+ } } : postActionStale;
1740
+ }
1682
1741
  if (!this.actionApplied(grant, element, handle)) {
1683
1742
  throw new Error('The approved value was not applied.');
1684
1743
  }
@@ -1698,16 +1757,16 @@ export class PageContextCollector {
1698
1757
  const attached = element.isConnected;
1699
1758
  const after = attached ? this.safeState(element) : '';
1700
1759
  const expanded = attached
1701
- ? literalBooleanAttribute(element, 'aria-expanded')
1760
+ ? this.expandedState(element)
1702
1761
  : undefined;
1703
1762
  const pressed = attached
1704
1763
  ? literalBooleanAttribute(element, 'aria-pressed')
1705
1764
  : undefined;
1706
1765
  const checked = attached ? checkedStateFor(element) : undefined;
1707
- this.callbacks.onHighlight(attached && !handle.pageScroll ? element : null, 'success');
1766
+ this.callbacks.onHighlight(attached && !handle.pageScroll ? element : null, navigationReceipt && navigationReceipt.outcome !== 'opened' ? 'failure' : 'success');
1708
1767
  return {
1709
1768
  ...base,
1710
- status: 'executed',
1769
+ status: navigationReceipt && navigationReceipt.outcome !== 'opened' ? 'blocked' : 'executed',
1711
1770
  observed: {
1712
1771
  url: safePageUrl(),
1713
1772
  revision: snapshot.revision,
@@ -1716,19 +1775,24 @@ export class PageContextCollector {
1716
1775
  : grant.action === 'type' || grant.action === 'select' || before !== after || handle.pageScroll
1717
1776
  ? 'changed'
1718
1777
  : 'unchanged',
1719
- safeSummary: handle.pageScroll
1720
- ? `The page moved ${handle.pageScroll} by ${Math.round(Math.abs(window.scrollY - scrollStartY))} pixels.`
1721
- : grant.action === 'scroll'
1722
- ? 'The requested target is visible. This confirms target visibility, not a directional page scroll.'
1723
- : grant.action === 'type'
1724
- ? 'The requested text was applied to the approved field.'
1725
- : grant.action === 'select'
1726
- ? 'The requested option was applied to the approved field.'
1727
- : grant.desiredChecked !== undefined
1728
- ? 'The requested checkbox state was applied.'
1729
- : grant.desiredExpanded !== undefined || grant.desiredPressed !== undefined
1730
- ? 'The requested button state was applied.'
1731
- : `${grant.action} executed on the approved ${roleFor(element)} target.`,
1778
+ ...(navigationReceipt ? { navigation: navigationReceipt } : {}),
1779
+ safeSummary: navigationReceipt
1780
+ ? navigationReceipt.outcome === 'opened'
1781
+ ? 'Opened a separate tab for the exact approved link. Its contents and arrival are not observed.'
1782
+ : 'The new tab could not be confirmed. No fallback navigation was attempted.'
1783
+ : handle.pageScroll
1784
+ ? `The page moved ${handle.pageScroll} by ${Math.round(Math.abs(window.scrollY - scrollStartY))} pixels.`
1785
+ : grant.action === 'scroll'
1786
+ ? 'The requested target is visible. This confirms target visibility, not a directional page scroll.'
1787
+ : grant.action === 'type'
1788
+ ? 'The requested text was applied to the approved field.'
1789
+ : grant.action === 'select'
1790
+ ? 'The requested option was applied to the approved field.'
1791
+ : grant.desiredChecked !== undefined
1792
+ ? 'The requested checkbox state was applied.'
1793
+ : grant.desiredExpanded !== undefined || grant.desiredPressed !== undefined
1794
+ ? 'The requested button state was applied.'
1795
+ : `${grant.action} executed on the approved ${roleFor(element)} target.`,
1732
1796
  ...(checked !== undefined ? { checked } : {}),
1733
1797
  ...(this.statefulButtonActionProof && expanded !== undefined ? { expanded } : {}),
1734
1798
  ...(this.statefulButtonActionProof && pressed !== undefined ? { pressed } : {}),
@@ -1736,6 +1800,7 @@ export class PageContextCollector {
1736
1800
  };
1737
1801
  }
1738
1802
  catch {
1803
+ this.deferredNavigation = null;
1739
1804
  this.callbacks.onHighlight(element, 'failure');
1740
1805
  return {
1741
1806
  ...base,
@@ -1743,8 +1808,12 @@ export class PageContextCollector {
1743
1808
  observed: {
1744
1809
  url: safePageUrl(),
1745
1810
  revision: this.revision,
1746
- targetState: element.isConnected ? 'unchanged' : 'detached',
1747
- safeSummary: 'The approved page action failed safely.',
1811
+ targetState: navigationReceipt ? 'unknown' : element.isConnected ? 'unchanged' : 'detached',
1812
+ ...(navigationReceipt ? { navigation: { url: navigationReceipt.url,
1813
+ outcome: 'unconfirmed' } } : {}),
1814
+ safeSummary: navigationReceipt
1815
+ ? 'The link action could not be fully confirmed. Do not retry automatically.'
1816
+ : 'The approved page action failed safely.',
1748
1817
  },
1749
1818
  };
1750
1819
  }
@@ -1804,9 +1873,8 @@ export class PageContextCollector {
1804
1873
  return 'rejected';
1805
1874
  }
1806
1875
  if (window.location.href !== navigation.fromUrl) {
1807
- return sameNavigationDestination(window.location.href, navigation.toUrl)
1808
- ? 'settled'
1809
- : 'rejected';
1876
+ // Arrival before dispatch is not evidence that this action navigated.
1877
+ return 'rejected';
1810
1878
  }
1811
1879
  this.captureSnapshot(false);
1812
1880
  const handle = this.targets.get(navigation.targetId);
@@ -1822,7 +1890,7 @@ export class PageContextCollector {
1822
1890
  target.origin !== window.location.origin ||
1823
1891
  safeDestinationPath(navigation.source) !== target.pathname ||
1824
1892
  linkBrowsingTarget(navigation.source) !== navigation.browsingTarget ||
1825
- !supportsCurrentTabNavigation(navigation.source) ||
1893
+ !supportsCurrentTabNavigation(navigation.source) || navigation.browsingTarget === '_blank' ||
1826
1894
  !isRendered(navigation.source) ||
1827
1895
  !isVisible(navigation.source) ||
1828
1896
  !enabled(navigation.source) ||
@@ -1836,9 +1904,6 @@ export class PageContextCollector {
1836
1904
  if (current?.actionId !== navigation.actionId)
1837
1905
  return;
1838
1906
  this.releasedNavigationSettlement = null;
1839
- if (this.executingActionId === navigation.actionId) {
1840
- this.executingActionId = null;
1841
- }
1842
1907
  resolve('timed_out');
1843
1908
  }, RELEASED_NAVIGATION_SETTLEMENT_TIMEOUT_MS);
1844
1909
  this.releasedNavigationSettlement = {
@@ -1849,16 +1914,8 @@ export class PageContextCollector {
1849
1914
  resolve,
1850
1915
  };
1851
1916
  });
1852
- const useCurrentTab = navigation.browsingTarget === '_blank';
1853
1917
  try {
1854
- // New-window links must stay in the observable tab. Navigate the exact
1855
- // approved URL only after durable release; the normal document-navigation
1856
- // recovery handles the new page. Dispatching their handlers can open a
1857
- // popup even if the DOM target is temporarily changed (e.g. React props).
1858
- if (useCurrentTab) {
1859
- window.location.assign(navigation.toUrl);
1860
- }
1861
- else if (navigation.source.closest(FRAMER_GENERATED_PAGE_SELECTOR) &&
1918
+ if (navigation.source.closest(FRAMER_GENERATED_PAGE_SELECTOR) &&
1862
1919
  typeof window.PointerEvent === 'function') {
1863
1920
  const preventNativeNavigation = (event) => {
1864
1921
  if (event.target === navigation.source ||
@@ -1896,7 +1953,7 @@ export class PageContextCollector {
1896
1953
  // failure must not turn the acknowledged action into a failed click.
1897
1954
  }
1898
1955
  }
1899
- else if (navigationSettled === 'rejected' &&
1956
+ else if (navigationSettled !== 'settled' &&
1900
1957
  this.executingActionId === navigation.actionId) {
1901
1958
  // A router may consume the exact click but send the browser somewhere
1902
1959
  // other than the approved destination. Complete the context-side action
@@ -1934,7 +1991,7 @@ export class PageContextCollector {
1934
1991
  }
1935
1992
  }
1936
1993
  if (desiredExpanded !== undefined) {
1937
- const expanded = literalBooleanAttribute(element, 'aria-expanded');
1994
+ const expanded = this.expandedState(element);
1938
1995
  if (roleFor(element) !== 'button' || expanded === undefined) {
1939
1996
  throw new Error('Desired expanded state requires an expandable button target.');
1940
1997
  }
@@ -1949,6 +2006,10 @@ export class PageContextCollector {
1949
2006
  }
1950
2007
  if (desiredStates.length > 0 && desiredStates.every(Boolean))
1951
2008
  return;
2009
+ if (desiredExpanded === true && framerNavigationRoot(element)) {
2010
+ dispatchFramerPrimaryPointerSequence(element, this.trustedGuideRoot);
2011
+ return;
2012
+ }
1952
2013
  if (desiredChecked !== undefined && framerToggleDescriptor(element)) {
1953
2014
  dispatchFramerTogglePrimaryPointer(element, this.trustedGuideRoot);
1954
2015
  return;
@@ -1959,11 +2020,25 @@ export class PageContextCollector {
1959
2020
  safeDestinationPath(element) !== expectedDestinationPath) {
1960
2021
  throw new Error('The navigation destination changed before execution.');
1961
2022
  }
1962
- if (target.origin !== window.location.origin) {
1963
- throw new Error('External navigation is blocked.');
2023
+ const navigation = publicNavigation(element);
2024
+ if (!navigation || canonicalJson(navigation) !== canonicalJson(handle.target.navigation)) {
2025
+ throw new Error('The public link destination or browsing target changed.');
1964
2026
  }
1965
- if (!supportsCurrentTabNavigation(element)) {
1966
- throw new Error('Named-window and download navigation is blocked.');
2027
+ if (navigation.target === '_blank' || target.origin !== window.location.origin) {
2028
+ // Keep external pages outside this session's origin/credential boundary.
2029
+ // Only the browser's native noreferrer feature can prevent the source
2030
+ // URL from becoming the Referer on this navigation. With noopener the
2031
+ // return value may be null even when the tab opens, so never call a
2032
+ // null result "blocked" or try the link a second time.
2033
+ try {
2034
+ const popup = window.open(navigation.url, '_blank', 'noopener,noreferrer');
2035
+ return { url: navigation.url, outcome: popup && !popup.closed ? 'opened' : 'unconfirmed' };
2036
+ }
2037
+ catch {
2038
+ // Some browsers throw when popups are disallowed. The exact URL was
2039
+ // attempted at most once, and there is no fallback navigation.
2040
+ return { url: navigation.url, outcome: 'unconfirmed' };
2041
+ }
1967
2042
  }
1968
2043
  const fromUrl = window.location.href;
1969
2044
  if (window.location.href === fromUrl && target.href !== fromUrl) {
@@ -2061,11 +2136,26 @@ export class PageContextCollector {
2061
2136
  }
2062
2137
  form.requestSubmit(submitter);
2063
2138
  }
2139
+ exposedNavigationLinks(nav) {
2140
+ const links = nav.querySelectorAll('a[href]');
2141
+ if (links.length > MAX_TARGETS)
2142
+ return new Set();
2143
+ return new Set(Array.from(links).filter(link => isVisible(link) && publicNavigation(link) &&
2144
+ !isCovered(link, this.trustedGuideRoot)));
2145
+ }
2146
+ expandedState(element) {
2147
+ const explicit = literalBooleanAttribute(element, 'aria-expanded');
2148
+ if (explicit !== undefined)
2149
+ return explicit;
2150
+ if (!framerNavigationRoot(element))
2151
+ return undefined;
2152
+ return [...(this.navigationDisclosureLinks.get(element) ?? [])].some(link => link.isConnected && isRendered(link));
2153
+ }
2064
2154
  safeState(element) {
2065
2155
  return JSON.stringify({
2066
2156
  connected: element.isConnected,
2067
2157
  disabled: !enabled(element),
2068
- expanded: element.getAttribute('aria-expanded'),
2158
+ expanded: this.expandedState(element) ?? element.getAttribute('aria-expanded'),
2069
2159
  pressed: element.getAttribute('aria-pressed'),
2070
2160
  checked: checkedStateFor(element),
2071
2161
  selectedIndex: element instanceof HTMLSelectElement ? element.selectedIndex : undefined,
@@ -2088,6 +2178,7 @@ export class PageContextCollector {
2088
2178
  let documentLeft = expectedHandle.element.getBoundingClientRect().left + window.scrollX;
2089
2179
  let layoutCorrections = 0;
2090
2180
  let priorScrollY = null;
2181
+ let intersectedViewport = false;
2091
2182
  let interrupted = false;
2092
2183
  const interrupt = () => {
2093
2184
  interrupted = true;
@@ -2161,10 +2252,25 @@ export class PageContextCollector {
2161
2252
  if (!strictlyIntersectsViewport(rect)) {
2162
2253
  priorRect = null;
2163
2254
  stableSince = null;
2164
- // Lazy content can move the same approved link after it briefly enters
2165
- // view. Follow only layout shifts, never an unchanged offscreen target
2166
- // or visitor input, within the original deadline and a fixed budget.
2167
- if (layoutShifted &&
2255
+ // A public-link reveal performs exactly one native scroll. If layout
2256
+ // shifts move that link away again, stop instead of chasing it and
2257
+ // accidentally scrolling two or three more times. Other controls
2258
+ // retain the existing bounded lazy-layout correction.
2259
+ if (expectedHandle.target.consequence === 'navigation' && intersectedViewport) {
2260
+ this.captureSnapshot(false);
2261
+ const current = this.targets.get(grant.targetId);
2262
+ if (!current ||
2263
+ current.element !== expectedHandle.element ||
2264
+ actionTargetFingerprintBinding(current.target) !==
2265
+ actionTargetFingerprintBinding(expectedHandle.target) ||
2266
+ grant.pageId !== this.pageId ||
2267
+ grant.pageUrl !== safePageUrl() ||
2268
+ grant.policyVersion !== this.policyVersion) {
2269
+ return { status: 'target_changed' };
2270
+ }
2271
+ return { status: 'unavailable' };
2272
+ }
2273
+ if (layoutShifted && expectedHandle.target.consequence !== 'navigation' &&
2168
2274
  layoutCorrections < MAX_SCROLL_LAYOUT_CORRECTIONS) {
2169
2275
  this.captureSnapshot(false);
2170
2276
  const current = this.targets.get(grant.targetId);
@@ -2189,6 +2295,7 @@ export class PageContextCollector {
2189
2295
  }
2190
2296
  continue;
2191
2297
  }
2298
+ intersectedViewport = true;
2192
2299
  if (!priorRect || !sameSettledRect(priorRect, rect))
2193
2300
  stableSince = Date.now();
2194
2301
  priorRect = rect;
@@ -2211,7 +2318,7 @@ export class PageContextCollector {
2211
2318
  return checkedStateFor(element) === grant.desiredChecked;
2212
2319
  }
2213
2320
  if (grant.desiredExpanded !== undefined &&
2214
- literalBooleanAttribute(element, 'aria-expanded') !== grant.desiredExpanded) {
2321
+ this.expandedState(element) !== grant.desiredExpanded) {
2215
2322
  return false;
2216
2323
  }
2217
2324
  if (grant.desiredPressed !== undefined &&
@@ -2394,6 +2501,7 @@ export class PageContextCollector {
2394
2501
  resetBoundTargetIds() {
2395
2502
  this.targetIds = new WeakMap();
2396
2503
  this.navigationTargetBindings = new WeakMap();
2504
+ this.navigationDisclosureLinks = new WeakMap();
2397
2505
  this.pageScrollIds = new WeakMap();
2398
2506
  this.fieldTargetBindings = new WeakMap();
2399
2507
  this.fieldAppliedRevisions = new WeakMap();
@@ -1,5 +1,5 @@
1
1
  import { type LiveDuplexMediaOptions, type LiveDuplexMediaDependencies } from './liveDuplexMedia.js';
2
- export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.8";
2
+ export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.9";
3
3
  export declare const LIVE_CONTROL_CLIENT_CAPABILITIES: readonly ["live_duplex_v1", "live_local_wake_v1", "live_fragment_captions_v1"];
4
4
  declare const phases: readonly ["voice_off", "local_listening", "wake_buffering", "starting", "active", "tentative_idle", "closing", "uncertain", "muted", "suspended", "budget_blocked", "ended"];
5
5
  export type LiveControlState = {
package/lib/types.d.ts CHANGED
@@ -332,6 +332,10 @@ export type SanitizedTarget = {
332
332
  autocomplete?: string;
333
333
  semanticRegion?: string;
334
334
  navigationMenu?: true;
335
+ navigation?: {
336
+ url: string;
337
+ target: '_self' | '_blank';
338
+ };
335
339
  destinationPath?: string;
336
340
  sensitivity: Sensitivity;
337
341
  protectedRegion?: boolean;
@@ -441,6 +445,10 @@ export type ActionExecutionResult = {
441
445
  revision: number;
442
446
  targetState: "unchanged" | "changed" | "detached" | "unknown";
443
447
  safeSummary: string;
448
+ navigation?: {
449
+ url: string;
450
+ outcome: 'opened' | 'blocked' | 'unconfirmed';
451
+ };
444
452
  checked?: boolean;
445
453
  expanded?: boolean;
446
454
  pressed?: boolean;
package/lib/ui.js CHANGED
@@ -4882,16 +4882,15 @@ export class WidgetView {
4882
4882
  this.syncSurfaceEffectOwner();
4883
4883
  }
4884
4884
  setRemoteStream(stream) {
4885
- const streamChanged = this.audio.srcObject !== stream;
4886
- if (streamChanged)
4887
- this.invalidateRemotePlayback();
4885
+ if (this.destroyed || this.audio.srcObject === stream)
4886
+ return;
4887
+ // Rebinding the same stream restarts media loading, including its playout buffer.
4888
+ this.invalidateRemotePlayback();
4888
4889
  this.audio.srcObject = stream;
4889
- if (streamChanged) {
4890
- this.boundRemoteAudioStreams = new WeakSet();
4891
- this.boundRemoteAudioTracks = new WeakSet();
4892
- this.bindPlaybackState();
4893
- this.bindRemoteAudioTrackState(stream);
4894
- }
4890
+ this.boundRemoteAudioStreams = new WeakSet();
4891
+ this.boundRemoteAudioTracks = new WeakSet();
4892
+ this.bindPlaybackState();
4893
+ this.bindRemoteAudioTrackState(stream);
4895
4894
  void this.playRemoteAudio();
4896
4895
  }
4897
4896
  bindRemoteAudioTrackState(stream) {