compound.ts
1 /** 2 * Compound-component expansion for high-agent-failure form controls. 3 * 4 * Agents burn turns on three recurring input categories because the raw 5 * attribute dump from `browser state` under-specifies them: 6 * 7 * - date / time / datetime-local / month / week — agents type 8 * free-form strings and the browser silently ignores mismatched formats. 9 * - select — the snapshot caps visible options at ~6; agents don't know 10 * the full option set, can't match by label, and waste turns clicking 11 * to open the dropdown just to read options. 12 * - file — the snapshot shows current filenames but not `accept` or 13 * `multiple`; agents re-upload or pick unsupported MIME types. 14 * 15 * `compoundInfoOf(el)` returns a structured JSON summary agents can rely 16 * on. Included in `browser find --css` envelope so the agent gets the 17 * rich view without extra round-trips. 18 * 19 * Emitted as a JS source string (`COMPOUND_INFO_JS`) so it can be inlined 20 * into the generated evaluate scripts under find / snapshot / eval. 21 */ 22 23 export type DateLikeControl = 'date' | 'time' | 'datetime-local' | 'month' | 'week'; 24 25 export interface DateCompound { 26 control: DateLikeControl; 27 format: string; 28 current: string; 29 min?: string; 30 max?: string; 31 } 32 33 export interface SelectOption { 34 label: string; 35 value: string; 36 selected: boolean; 37 disabled?: boolean; 38 } 39 40 export interface SelectCompound { 41 control: 'select'; 42 multiple: boolean; 43 current: string | string[]; 44 options: SelectOption[]; 45 options_total: number; 46 } 47 48 export interface FileCompound { 49 control: 'file'; 50 multiple: boolean; 51 current: string[]; 52 accept?: string; 53 } 54 55 export type CompoundInfo = DateCompound | SelectCompound | FileCompound; 56 57 /** Max options included in a SelectCompound.options[]. Above this, `options_total` still reflects the true count. */ 58 export const COMPOUND_SELECT_OPTIONS_CAP = 50; 59 60 /** Max characters per option label / file name. */ 61 export const COMPOUND_LABEL_CAP = 80; 62 63 /** 64 * JavaScript source declaring `compoundInfoOf(el)`. Inlined into the JS 65 * emitted by `buildFindJs` (and any other evaluate script that needs the 66 * rich compound view). Returns a `CompoundInfo` object or `null`. 67 */ 68 export const COMPOUND_INFO_JS = ` 69 function compoundInfoOf(el) { 70 if (!el || !el.tagName) return null; 71 const tag = el.tagName; 72 const LABEL_CAP = ${COMPOUND_LABEL_CAP}; 73 const OPTS_CAP = ${COMPOUND_SELECT_OPTIONS_CAP}; 74 if (tag === 'INPUT') { 75 const type = (el.getAttribute('type') || 'text').toLowerCase(); 76 const FORMATS = { 77 'date': 'YYYY-MM-DD', 78 'time': 'HH:MM', 79 'datetime-local': 'YYYY-MM-DDTHH:MM', 80 'month': 'YYYY-MM', 81 'week': 'YYYY-W##', 82 }; 83 if (FORMATS[type]) { 84 const info = { 85 control: type, 86 format: FORMATS[type], 87 current: (el.value == null ? '' : String(el.value)), 88 }; 89 const min = el.getAttribute('min'); 90 if (min) info.min = min; 91 const max = el.getAttribute('max'); 92 if (max) info.max = max; 93 return info; 94 } 95 if (type === 'file') { 96 const info = { 97 control: 'file', 98 multiple: !!el.multiple, 99 current: [], 100 }; 101 const accept = el.getAttribute('accept'); 102 if (accept) info.accept = accept; 103 try { 104 if (el.files && el.files.length) { 105 for (let i = 0; i < el.files.length; i++) { 106 const name = (el.files[i].name || '').slice(0, LABEL_CAP); 107 info.current.push(name); 108 } 109 } 110 } catch (_) {} 111 return info; 112 } 113 return null; 114 } 115 if (tag === 'SELECT') { 116 const multiple = !!el.multiple; 117 const options = []; 118 const selectedLabels = []; 119 let total = 0; 120 try { 121 const opts = el.options || []; 122 total = opts.length; 123 // Walk ALL options so \`current\` reflects selections that sit beyond the 124 // serialization cap. Only the first OPTS_CAP entries get pushed into 125 // options[]; anything past the cap still contributes to selectedLabels 126 // so agents see the true current state of big dropdowns. 127 for (let i = 0; i < opts.length; i++) { 128 const o = opts[i]; 129 const labelRaw = (o.label != null && o.label !== '') ? o.label : (o.text || ''); 130 const label = String(labelRaw).trim().slice(0, LABEL_CAP); 131 if (i < OPTS_CAP) { 132 const entry = { label: label, value: o.value, selected: !!o.selected }; 133 if (o.disabled) entry.disabled = true; 134 options.push(entry); 135 } 136 if (o.selected) selectedLabels.push(label); 137 } 138 } catch (_) {} 139 return { 140 control: 'select', 141 multiple: multiple, 142 current: multiple ? selectedLabels : (selectedLabels[0] || ''), 143 options: options, 144 options_total: total, 145 }; 146 } 147 return null; 148 } 149 `;