Skip to content

Commit 7c935fa

Browse files
authored
fix: exclude keyboard/IME chrome from replay divergence screen.refs (#1233)
* fix: exclude keyboard/IME chrome from replay divergence screen.refs On a keyboard-open screen, target-binding divergence refs were dominated by keyboard KEY/window chrome, pushing the real actionable target past the 20-ref cap. Reuse settle's existing structural keyboard/IME chrome classifier (collectSettleChromeRefs, #1198/#1200) to filter divergence refs before the cap, instead of duplicating the classification. * refactor: move settle chrome classifier to core/ for cross-layer reuse daemon/ importing collectSettleChromeRefs from commands/ violated the layering DAG (R2 commands-floor). Extract the pure SnapshotNode[] keyboard/IME/system-chrome classifier into src/core/snapshot-chrome.ts (below both commands/ and daemon/); settle.ts and session-replay-divergence.ts both import it from there. No logic change. * fix: preserve app-owned keyboard accessory controls in chrome filter The iOS classifier treated an entire keyboard WINDOW as chrome, so a button-only inputAccessoryView/toolbar (e.g. a "Send" button) the app hosts in that window was wrongly stripped from divergence screen.refs — hiding a control the agent must heal against. Narrow it structurally: the keyboard's own chrome (keys, shift/Emoji/return, Next keyboard/Dictate) renders within the keyboard container's frame at the bottom of the screen, while an inputAccessoryView renders as a bar ABOVE the keys, so non-keyboard nodes above the keyboard's top edge survive classification. Structural spine nodes that contain the keyboard are never exempted, so genuine key-only keyboard windows classify exactly as before. Android scope unchanged (app dialog / unmarked SystemUI controls already kept). Adds core classifier unit tests (iOS accessory survives, genuine keyboard unchanged, Android app/SystemUI controls kept) plus a divergence-level regression that an app inputAccessoryView control stays in screen.refs.
1 parent 66910f1 commit 7c935fa

5 files changed

Lines changed: 836 additions & 284 deletions

File tree

src/commands/interaction/runtime/settle.ts

Lines changed: 1 addition & 281 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import type {
77
import { isSparseSnapshotQualityVerdict } from '../../../snapshot/snapshot-quality.ts';
88
import { buildSnapshotDiff } from '../../../snapshot/snapshot-diff.ts';
99
import { displayLabel, formatRole } from '../../../snapshot/snapshot-lines.ts';
10-
import { isAndroidInputMethodSnapshotNode } from '../../../snapshot/android-input-method-overlays.ts';
11-
import { normalizeType } from '../../../utils/text-surface.ts';
10+
import { collectSettleChromeRefs, withoutSettleChrome } from '../../../core/snapshot-chrome.ts';
1211
import { summarizeAxEvidence } from '../../../utils/ax-digest.ts';
1312
import type {
1413
InteractionEvidence,
@@ -279,285 +278,6 @@ export function buildSettleTailEntries(
279278
};
280279
}
281280

282-
// Editable text roles (normalized `node.type` vocabulary) used by the
283-
// keyboard-window guard below.
284-
const EDITABLE_TEXT_TYPES = new Set([
285-
'textfield',
286-
'securetextfield',
287-
'textview',
288-
'textarea',
289-
'searchfield',
290-
'edittext',
291-
]);
292-
293-
/**
294-
* Keyboard chrome classification, live-verified against the iPhone 17 Pro
295-
* simulator (iOS 26, July 2026): the software keyboard renders in its OWN
296-
* dedicated window (UIRemoteKeyboardWindow). That window contains the
297-
* `[Keyboard]` container (keys plus real XCUIElementTypeButton chrome like
298-
* shift/Emoji/return) AND a SIBLING subtree holding the "Next keyboard" and
299-
* "Dictate" buttons — siblings of the container, so a container-descendant
300-
* walk alone provably misses them. The rule is therefore: every node inside
301-
* a window that has a `[Keyboard]` descendant is keyboard chrome.
302-
*
303-
* Detection stays structural (`parentIndex` chains), never label-based:
304-
* keyboard chrome text is locale-dependent (the verified capture had Polish
305-
* key labels) and a label list would silently stop matching under a
306-
* different input language.
307-
*
308-
* Conservative guard: a window that also hosts an editable text node OUTSIDE
309-
* the keyboard container is never window-classified — iOS hosts
310-
* inputAccessoryView content (e.g. a messaging composer) in the keyboard
311-
* window, and hiding the field the user is typing into would be worse than
312-
* leaking chrome. Such windows fall back to the container-descendant walk.
313-
*/
314-
type KeyboardChrome = {
315-
/** Chrome node indexes EXCLUDING the containers: stripped from the diff. */
316-
strippedIndexes: ReadonlySet<number>;
317-
/** Refs of ALL chrome incl. containers/window: trigger + tail exclusion. */
318-
refs: ReadonlySet<string>;
319-
};
320-
321-
const EMPTY_KEYBOARD_CHROME: KeyboardChrome = { strippedIndexes: new Set(), refs: new Set() };
322-
323-
function collectKeyboardChrome(nodes: SnapshotNode[]): KeyboardChrome {
324-
const containerIndexes = new Set(
325-
nodes.filter((node) => normalizeType(node.type ?? '') === 'keyboard').map((node) => node.index),
326-
);
327-
if (containerIndexes.size === 0) return EMPTY_KEYBOARD_CHROME;
328-
const byIndex = new Map(nodes.map((node) => [node.index, node]));
329-
const chromeIndexes = collectSubtreeIndexes(nodes, byIndex, containerIndexes);
330-
const windowIndexes = resolveKeyboardWindowIndexes(nodes, byIndex, chromeIndexes);
331-
for (const index of collectSubtreeIndexes(nodes, byIndex, windowIndexes)) {
332-
chromeIndexes.add(index);
333-
}
334-
const refs = new Set(
335-
nodes.filter((node) => node.ref && chromeIndexes.has(node.index)).map((node) => node.ref),
336-
);
337-
// The [Keyboard] container line itself survives the diff so "keyboard
338-
// appeared/left" stays one visible signal line; everything else collapses.
339-
const strippedIndexes = new Set(
340-
[...chromeIndexes].filter((index) => !containerIndexes.has(index)),
341-
);
342-
return { strippedIndexes, refs };
343-
}
344-
345-
/** The root indexes plus every node whose parent chain passes through one. */
346-
function collectSubtreeIndexes(
347-
nodes: SnapshotNode[],
348-
byIndex: Map<number, SnapshotNode>,
349-
rootIndexes: ReadonlySet<number>,
350-
): Set<number> {
351-
const indexes = new Set(rootIndexes);
352-
if (rootIndexes.size === 0) return indexes;
353-
for (const node of nodes) {
354-
if (hasAncestorIn(node, byIndex, rootIndexes)) indexes.add(node.index);
355-
}
356-
return indexes;
357-
}
358-
359-
// SystemUI hosts BOTH persistent chrome and actionable overlays (volume
360-
// panel, media/output pickers), so chrome is never a package-level fact.
361-
// Within `com.android.systemui`, only window-runs carrying a status-bar or
362-
// navigation-bar marker resource-id drop; every other systemui surface is
363-
// kept. Marker set live-verified on the emulator: the status-bar window
364-
// carries `status_bar*` ids throughout while the VolumeDialog window carries
365-
// only `volume_dialog*` ids (`input_method_nav*` bars are IME-owned and
366-
// handled by the IME tier).
367-
const ANDROID_SYSTEM_CHROME_PACKAGE = 'com.android.systemui';
368-
const ANDROID_SYSTEM_CHROME_MARKER_PREFIXES = [
369-
'com.android.systemui:id/status_bar',
370-
'com.android.systemui:id/navigation_bar',
371-
];
372-
373-
function hasAndroidSystemChromeMarker(node: SnapshotNode): boolean {
374-
const identifier = node.identifier ?? '';
375-
return ANDROID_SYSTEM_CHROME_MARKER_PREFIXES.some((prefix) => identifier.startsWith(prefix));
376-
}
377-
378-
/**
379-
* Android settle chrome (#1198): IME-owned nodes collapse to one surviving
380-
* line per contiguous run; systemui status/nav-bar window-runs drop from both
381-
* diff sides; every other foreign node — system dialogs (package `android`),
382-
* permission prompts, AND actionable systemui overlays like the volume panel
383-
* — is kept in full. Constraint: package membership is strictly per-node by
384-
* the node's own `bundleId` — parentIndex chains can cross windows on Android
385-
* (enforced by the settle.test.ts cross-window regression test); run grouping
386-
* only ever walks parent chains BETWEEN same-package nodes, so it cannot
387-
* swallow another package's node. Inert on iOS/macOS: those nodes never set
388-
* `bundleId`.
389-
*/
390-
function collectAndroidSettleChrome(
391-
nodes: SnapshotNode[],
392-
appBundleId: string | undefined,
393-
): KeyboardChrome {
394-
const byIndex = new Map(nodes.map((node) => [node.index, node]));
395-
const imeIndexes = new Set(
396-
nodes.filter((node) => isAndroidInputMethodSnapshotNode(node)).map((node) => node.index),
397-
);
398-
const imeContainerIndexes = new Set(
399-
[...imeIndexes].filter((index) => {
400-
const parentIndex = byIndex.get(index)?.parentIndex;
401-
const parent = parentIndex !== undefined ? byIndex.get(parentIndex) : undefined;
402-
return !parent || !imeIndexes.has(parent.index);
403-
}),
404-
);
405-
// appBundleId is the session's pre-action value (not refreshed inside the
406-
// settle loop); it is only a never-drop-the-app-under-test guard here, so
407-
// staleness cannot hide a foreign dialog.
408-
const systemChromeIndexes =
409-
appBundleId === ANDROID_SYSTEM_CHROME_PACKAGE
410-
? new Set<number>()
411-
: collectAndroidSystemChromeRunIndexes(nodes, byIndex, imeIndexes);
412-
// The one surviving container line per IME run; the rest of the run and all
413-
// status/nav-bar chrome never spend diff/tail budget.
414-
const strippedIndexes = new Set(
415-
[...imeIndexes].filter((index) => !imeContainerIndexes.has(index)),
416-
);
417-
for (const index of systemChromeIndexes) strippedIndexes.add(index);
418-
const refs = new Set(
419-
nodes
420-
.filter(
421-
(node) => node.ref && (imeIndexes.has(node.index) || systemChromeIndexes.has(node.index)),
422-
)
423-
.map((node) => node.ref),
424-
);
425-
if (strippedIndexes.size === 0 && refs.size === 0) return EMPTY_KEYBOARD_CHROME;
426-
return { strippedIndexes, refs };
427-
}
428-
429-
/**
430-
* Systemui window-runs (contiguous same-package parent chains) that contain a
431-
* status/nav-bar marker anywhere in the run. The whole marked run drops —
432-
* unmarked wrappers above `status_bar_container` churn with the bar itself —
433-
* while unmarked runs (volume panel, media pickers) are kept whole.
434-
*/
435-
function collectAndroidSystemChromeRunIndexes(
436-
nodes: SnapshotNode[],
437-
byIndex: Map<number, SnapshotNode>,
438-
imeIndexes: ReadonlySet<number>,
439-
): Set<number> {
440-
const systemUiIndexes = new Set(
441-
nodes
442-
.filter(
443-
(node) => node.bundleId === ANDROID_SYSTEM_CHROME_PACKAGE && !imeIndexes.has(node.index),
444-
)
445-
.map((node) => node.index),
446-
);
447-
if (systemUiIndexes.size === 0) return new Set();
448-
// Union-find-lite: each systemui node resolves to its run root (the nearest
449-
// ancestor chain member whose parent is absent or not systemui).
450-
const runRootByIndex = new Map<number, number>();
451-
const resolveRunRoot = (index: number): number => {
452-
const cached = runRootByIndex.get(index);
453-
if (cached !== undefined) return cached;
454-
const parentIndex = byIndex.get(index)?.parentIndex;
455-
const root =
456-
parentIndex !== undefined && systemUiIndexes.has(parentIndex)
457-
? resolveRunRoot(parentIndex)
458-
: index;
459-
runRootByIndex.set(index, root);
460-
return root;
461-
};
462-
const markedRunRoots = new Set(
463-
[...systemUiIndexes]
464-
.filter((index) => {
465-
const node = byIndex.get(index);
466-
return node !== undefined && hasAndroidSystemChromeMarker(node);
467-
})
468-
.map((index) => resolveRunRoot(index)),
469-
);
470-
return new Set([...systemUiIndexes].filter((index) => markedRunRoots.has(resolveRunRoot(index))));
471-
}
472-
473-
/** iOS keyboard-window chrome unioned with Android IME/system chrome. */
474-
function collectSettleChrome(
475-
nodes: SnapshotNode[],
476-
appBundleId: string | undefined,
477-
): KeyboardChrome {
478-
const keyboard = collectKeyboardChrome(nodes);
479-
const android = collectAndroidSettleChrome(nodes, appBundleId);
480-
if (keyboard.strippedIndexes.size === 0 && keyboard.refs.size === 0) return android;
481-
if (android.strippedIndexes.size === 0 && android.refs.size === 0) return keyboard;
482-
return {
483-
strippedIndexes: new Set([...keyboard.strippedIndexes, ...android.strippedIndexes]),
484-
refs: new Set([...keyboard.refs, ...android.refs]),
485-
};
486-
}
487-
488-
function withoutSettleChrome(
489-
nodes: SnapshotNode[],
490-
appBundleId: string | undefined,
491-
): SnapshotNode[] {
492-
const { strippedIndexes } = collectSettleChrome(nodes, appBundleId);
493-
if (strippedIndexes.size === 0) return nodes;
494-
return nodes.filter((node) => !strippedIndexes.has(node.index));
495-
}
496-
497-
function collectSettleChromeRefs(
498-
nodes: SnapshotNode[],
499-
appBundleId: string | undefined,
500-
): ReadonlySet<string> {
501-
return collectSettleChrome(nodes, appBundleId).refs;
502-
}
503-
504-
/**
505-
* Windows eligible for whole-window chrome classification: nearest `[window]`
506-
* ancestor of each `[Keyboard]` container, minus windows hosting editable
507-
* text outside the container subtree (the inputAccessoryView guard above).
508-
*/
509-
function resolveKeyboardWindowIndexes(
510-
nodes: SnapshotNode[],
511-
byIndex: Map<number, SnapshotNode>,
512-
containerChromeIndexes: ReadonlySet<number>,
513-
): Set<number> {
514-
const windowIndexes = new Set<number>();
515-
for (const index of containerChromeIndexes) {
516-
const node = byIndex.get(index);
517-
if (!node || normalizeType(node.type ?? '') !== 'keyboard') continue;
518-
const windowAncestor = findNearestWindowAncestor(node, byIndex);
519-
if (windowAncestor !== undefined) windowIndexes.add(windowAncestor);
520-
}
521-
for (const windowIndex of windowIndexes) {
522-
const windowSet = new Set([windowIndex]);
523-
const hostsEditableText = nodes.some(
524-
(node) =>
525-
EDITABLE_TEXT_TYPES.has(normalizeType(node.type ?? '')) &&
526-
!containerChromeIndexes.has(node.index) &&
527-
hasAncestorIn(node, byIndex, windowSet),
528-
);
529-
if (hostsEditableText) windowIndexes.delete(windowIndex);
530-
}
531-
return windowIndexes;
532-
}
533-
534-
function findNearestWindowAncestor(
535-
node: SnapshotNode,
536-
byIndex: Map<number, SnapshotNode>,
537-
): number | undefined {
538-
let current = typeof node.parentIndex === 'number' ? byIndex.get(node.parentIndex) : undefined;
539-
while (current) {
540-
if (normalizeType(current.type ?? '') === 'window') return current.index;
541-
current =
542-
typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined;
543-
}
544-
return undefined;
545-
}
546-
547-
function hasAncestorIn(
548-
node: SnapshotNode,
549-
byIndex: Map<number, SnapshotNode>,
550-
ancestorIndexes: ReadonlySet<number>,
551-
): boolean {
552-
let current = typeof node.parentIndex === 'number' ? byIndex.get(node.parentIndex) : undefined;
553-
while (current) {
554-
if (ancestorIndexes.has(current.index)) return true;
555-
current =
556-
typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined;
557-
}
558-
return false;
559-
}
560-
561281
/**
562282
* Truncation policy: added lines win. They carry the settled tree's fresh
563283
* refs — the actionable half of the diff — while removals only describe what

0 commit comments

Comments
 (0)