aipeek 0.2.2 → 0.2.3
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/dist/{chunk-GIMXNZD5.cjs → chunk-3NVB3GGE.cjs} +3 -2
- package/dist/{chunk-JOY7QP24.js → chunk-72ZKZ42D.js} +3 -2
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -2
- package/dist/plugin.js +1 -1
- package/package.json +5 -2
- package/src/client/client-patch.ts +312 -0
- package/src/client/client.ts +689 -0
- package/src/core/action.ts +272 -0
- package/src/core/types.ts +75 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
// aipeek client — collectors + HMR channel (module)
|
|
3
|
+
// The patch code (console/fetch/XHR/errors) is in client-patch.ts,
|
|
4
|
+
// injected as a synchronous inline script before any modules execute.
|
|
5
|
+
|
|
6
|
+
import type { ActionArgs, ActionType } from '../core/action'
|
|
7
|
+
import type { ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
|
|
8
|
+
import { INTERACTIVE, performAction } from '../core/action'
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface Window { __AIPEEK_STORES__?: Record<string, unknown> }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AipeekBuffers {
|
|
15
|
+
consoleLogs: LogEntry[]
|
|
16
|
+
networkRequests: NetworkRequest[]
|
|
17
|
+
errorEntries: ErrorEntry[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Read buffers from the synchronous patch script
|
|
21
|
+
const buffers = (window as any).__AIPEEK_BUFFERS__ as AipeekBuffers | undefined
|
|
22
|
+
const consoleLogs: LogEntry[] = buffers?.consoleLogs ?? []
|
|
23
|
+
const networkRequests: NetworkRequest[] = buffers?.networkRequests ?? []
|
|
24
|
+
const errorEntries: ErrorEntry[] = buffers?.errorEntries ?? []
|
|
25
|
+
|
|
26
|
+
// --- On-demand collectors ---
|
|
27
|
+
|
|
28
|
+
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'link', 'meta', 'head'])
|
|
29
|
+
|
|
30
|
+
const IMPLICIT_ROLES: Record<string, string> = {
|
|
31
|
+
a: 'link',
|
|
32
|
+
button: 'button',
|
|
33
|
+
input: 'textbox',
|
|
34
|
+
textarea: 'textbox',
|
|
35
|
+
select: 'combobox',
|
|
36
|
+
img: 'img',
|
|
37
|
+
nav: 'navigation',
|
|
38
|
+
main: 'main',
|
|
39
|
+
header: 'banner',
|
|
40
|
+
footer: 'contentinfo',
|
|
41
|
+
aside: 'complementary',
|
|
42
|
+
form: 'form',
|
|
43
|
+
dialog: 'dialog',
|
|
44
|
+
ul: 'list',
|
|
45
|
+
ol: 'list',
|
|
46
|
+
li: 'listitem',
|
|
47
|
+
table: 'table',
|
|
48
|
+
h1: 'heading',
|
|
49
|
+
h2: 'heading',
|
|
50
|
+
h3: 'heading',
|
|
51
|
+
h4: 'heading',
|
|
52
|
+
h5: 'heading',
|
|
53
|
+
h6: 'heading',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collectUI(): string {
|
|
57
|
+
const root = document.getElementById('root')
|
|
58
|
+
if (!root)
|
|
59
|
+
return collectDomFallback()
|
|
60
|
+
|
|
61
|
+
const fiberKey = Object.keys(root).find(k =>
|
|
62
|
+
k.startsWith('__reactContainer$') || k.startsWith('__reactFiber$'),
|
|
63
|
+
)
|
|
64
|
+
if (!fiberKey)
|
|
65
|
+
return collectDomFallback()
|
|
66
|
+
|
|
67
|
+
const rootFiber = (root as any)[fiberKey]
|
|
68
|
+
if (!rootFiber)
|
|
69
|
+
return collectDomFallback()
|
|
70
|
+
|
|
71
|
+
const COMPONENT_TAGS = new Set([0, 1, 14, 15])
|
|
72
|
+
const SKIP_EXACT = new Set([
|
|
73
|
+
'Fragment',
|
|
74
|
+
'Suspense',
|
|
75
|
+
'StrictMode',
|
|
76
|
+
'Context',
|
|
77
|
+
'Outlet',
|
|
78
|
+
'Routes',
|
|
79
|
+
'RenderedRoute',
|
|
80
|
+
'DataRoutes',
|
|
81
|
+
'RemoveScrollBar',
|
|
82
|
+
'Sheet',
|
|
83
|
+
'Router',
|
|
84
|
+
'RenderErrorBoundary',
|
|
85
|
+
'Tooltip',
|
|
86
|
+
'Popover',
|
|
87
|
+
'Dialog',
|
|
88
|
+
'DropdownMenu',
|
|
89
|
+
'ContextMenu',
|
|
90
|
+
'HoverCard',
|
|
91
|
+
'Select',
|
|
92
|
+
'Command',
|
|
93
|
+
'Tabs',
|
|
94
|
+
'RadioGroup',
|
|
95
|
+
'Markdown',
|
|
96
|
+
'Sonner',
|
|
97
|
+
])
|
|
98
|
+
// wrapper components carry no UI semantics — skip by name shape
|
|
99
|
+
const SKIP_CONTAINS = ['Provider', 'Consumer', 'Portal', 'Popper', 'Presence', 'SideCar']
|
|
100
|
+
const SKIP_ENDS = ['Warning', 'Header', 'Footer', 'Menu', 'Root']
|
|
101
|
+
const isSkipped = (name: string) =>
|
|
102
|
+
SKIP_CONTAINS.some(s => name.includes(s)) || SKIP_ENDS.some(s => name.endsWith(s))
|
|
103
|
+
const lines: string[] = []
|
|
104
|
+
|
|
105
|
+
// strip a trailing run of digits (React dedup suffix: Foo2 → Foo)
|
|
106
|
+
function cleanName(name: string): string {
|
|
107
|
+
let end = name.length
|
|
108
|
+
while (end > 0 && name[end - 1] >= '0' && name[end - 1] <= '9') end--
|
|
109
|
+
return name.slice(0, end)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getDomDesc(fiber: any): string {
|
|
113
|
+
const el = fiber.stateNode
|
|
114
|
+
if (!(el instanceof Element))
|
|
115
|
+
return ''
|
|
116
|
+
|
|
117
|
+
const parts: string[] = []
|
|
118
|
+
const tag = el.tagName.toLowerCase()
|
|
119
|
+
const role = el.getAttribute('role') || IMPLICIT_ROLES[tag] || ''
|
|
120
|
+
if (role)
|
|
121
|
+
parts.push(role)
|
|
122
|
+
|
|
123
|
+
const ariaLabel = el.getAttribute('aria-label') || ''
|
|
124
|
+
const text = ariaLabel || getDirectText(el)
|
|
125
|
+
if (text)
|
|
126
|
+
parts.push(`"${text.slice(0, 80)}"`)
|
|
127
|
+
|
|
128
|
+
for (const s of baseState(el)) parts.push(`[${s}]`)
|
|
129
|
+
|
|
130
|
+
return parts.join(' ')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getComponentDomDesc(fiber: any): string {
|
|
134
|
+
let node = fiber.child
|
|
135
|
+
while (node) {
|
|
136
|
+
if (node.tag === 5)
|
|
137
|
+
return getDomDesc(node)
|
|
138
|
+
if (node.tag === 6) {
|
|
139
|
+
const text = node.memoizedProps
|
|
140
|
+
if (typeof text === 'string' && text.trim())
|
|
141
|
+
return `"${text.trim().slice(0, 80)}"`
|
|
142
|
+
}
|
|
143
|
+
node = node.child
|
|
144
|
+
}
|
|
145
|
+
return ''
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function walkFiber(fiber: any, depth: number) {
|
|
149
|
+
if (!fiber || depth > 40)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
const isComponent = COMPONENT_TAGS.has(fiber.tag)
|
|
153
|
+
const rawName = fiber.type?.displayName || fiber.type?.name
|
|
154
|
+
|
|
155
|
+
if (isComponent && rawName && rawName.length > 1 && rawName[0] !== '_'
|
|
156
|
+
&& !SKIP_EXACT.has(rawName) && !isSkipped(rawName)) {
|
|
157
|
+
const name = cleanName(rawName)
|
|
158
|
+
const indent = ' '.repeat(depth)
|
|
159
|
+
let desc = name
|
|
160
|
+
|
|
161
|
+
const domDesc = getComponentDomDesc(fiber)
|
|
162
|
+
if (domDesc)
|
|
163
|
+
desc += ` — ${domDesc}`
|
|
164
|
+
|
|
165
|
+
const props = fiber.memoizedProps
|
|
166
|
+
if (props?.generating)
|
|
167
|
+
desc += ' [generating]'
|
|
168
|
+
if (props?.loading)
|
|
169
|
+
desc += ' [loading]'
|
|
170
|
+
|
|
171
|
+
lines.push(`${indent}${desc}`)
|
|
172
|
+
walkChildren(fiber, depth + 1)
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
walkChildren(fiber, depth)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function walkChildren(fiber: any, depth: number) {
|
|
180
|
+
let child = fiber.child
|
|
181
|
+
while (child) {
|
|
182
|
+
walkFiber(child, depth)
|
|
183
|
+
child = child.sibling
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
walkFiber(rootFiber, 0)
|
|
188
|
+
if (!lines.length)
|
|
189
|
+
return collectDomFallback()
|
|
190
|
+
|
|
191
|
+
const minIndent = Math.min(...lines.map(l => l.length - l.trimStart().length))
|
|
192
|
+
if (minIndent > 0)
|
|
193
|
+
return lines.map(l => l.slice(minIndent)).join('\n')
|
|
194
|
+
return lines.join('\n')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function collectDomFallback(): string {
|
|
198
|
+
const lines: string[] = []
|
|
199
|
+
|
|
200
|
+
function walk(el: Element, depth: number) {
|
|
201
|
+
if (depth > 12)
|
|
202
|
+
return
|
|
203
|
+
const tag = el.tagName.toLowerCase()
|
|
204
|
+
if (SKIP_TAGS.has(tag))
|
|
205
|
+
return
|
|
206
|
+
if (tag === 'svg') {
|
|
207
|
+
lines.push(`${' '.repeat(depth)}img: [svg]`)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const role = el.getAttribute('role') || IMPLICIT_ROLES[tag] || ''
|
|
212
|
+
const ariaLabel = el.getAttribute('aria-label') || ''
|
|
213
|
+
const directText = getDirectText(el)
|
|
214
|
+
|
|
215
|
+
let desc = role || tag
|
|
216
|
+
if (ariaLabel)
|
|
217
|
+
desc += `: ${ariaLabel}`
|
|
218
|
+
else if (directText)
|
|
219
|
+
desc += `: ${directText.slice(0, 80)}`
|
|
220
|
+
|
|
221
|
+
for (const s of baseState(el)) desc += ` [${s}]`
|
|
222
|
+
|
|
223
|
+
lines.push(' '.repeat(depth) + desc)
|
|
224
|
+
for (const child of el.children) walk(child, depth + 1)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
walk(document.body, 0)
|
|
228
|
+
return lines.join('\n')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getDirectText(el: Element): string {
|
|
232
|
+
let text = ''
|
|
233
|
+
for (const node of el.childNodes) {
|
|
234
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
235
|
+
const t = node.textContent?.trim()
|
|
236
|
+
if (t)
|
|
237
|
+
text += (text ? ' ' : '') + t
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return text
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Semantic DOM collector ---
|
|
244
|
+
// UI-as-text: strip Tailwind atomic classes, keep only what carries meaning —
|
|
245
|
+
// tag, role, semantic classes, data-*/aria, text, interactive state, a usable selector.
|
|
246
|
+
|
|
247
|
+
// Tailwind atomic-class prefixes. A class is "noise" if it starts with one of
|
|
248
|
+
// these (with - or end), or contains a variant separator (hover:, md:, dark:).
|
|
249
|
+
const TW_PREFIXES = new Set([
|
|
250
|
+
'flex',
|
|
251
|
+
'grid',
|
|
252
|
+
'block',
|
|
253
|
+
'inline',
|
|
254
|
+
'hidden',
|
|
255
|
+
'table',
|
|
256
|
+
'contents',
|
|
257
|
+
'p',
|
|
258
|
+
'px',
|
|
259
|
+
'py',
|
|
260
|
+
'pt',
|
|
261
|
+
'pb',
|
|
262
|
+
'pl',
|
|
263
|
+
'pr',
|
|
264
|
+
'm',
|
|
265
|
+
'mx',
|
|
266
|
+
'my',
|
|
267
|
+
'mt',
|
|
268
|
+
'mb',
|
|
269
|
+
'ml',
|
|
270
|
+
'mr',
|
|
271
|
+
'w',
|
|
272
|
+
'h',
|
|
273
|
+
'min',
|
|
274
|
+
'max',
|
|
275
|
+
'size',
|
|
276
|
+
'gap',
|
|
277
|
+
'space',
|
|
278
|
+
'text',
|
|
279
|
+
'font',
|
|
280
|
+
'leading',
|
|
281
|
+
'tracking',
|
|
282
|
+
'whitespace',
|
|
283
|
+
'truncate',
|
|
284
|
+
'break',
|
|
285
|
+
'bg',
|
|
286
|
+
'border',
|
|
287
|
+
'rounded',
|
|
288
|
+
'ring',
|
|
289
|
+
'shadow',
|
|
290
|
+
'outline',
|
|
291
|
+
'divide',
|
|
292
|
+
'opacity',
|
|
293
|
+
'items',
|
|
294
|
+
'justify',
|
|
295
|
+
'self',
|
|
296
|
+
'place',
|
|
297
|
+
'content',
|
|
298
|
+
'order',
|
|
299
|
+
'col',
|
|
300
|
+
'row',
|
|
301
|
+
'absolute',
|
|
302
|
+
'relative',
|
|
303
|
+
'fixed',
|
|
304
|
+
'sticky',
|
|
305
|
+
'static',
|
|
306
|
+
'top',
|
|
307
|
+
'bottom',
|
|
308
|
+
'left',
|
|
309
|
+
'right',
|
|
310
|
+
'z',
|
|
311
|
+
'inset',
|
|
312
|
+
'overflow',
|
|
313
|
+
'overscroll',
|
|
314
|
+
'cursor',
|
|
315
|
+
'select',
|
|
316
|
+
'pointer',
|
|
317
|
+
'resize',
|
|
318
|
+
'scroll',
|
|
319
|
+
'transition',
|
|
320
|
+
'duration',
|
|
321
|
+
'ease',
|
|
322
|
+
'delay',
|
|
323
|
+
'animate',
|
|
324
|
+
'transform',
|
|
325
|
+
'translate',
|
|
326
|
+
'rotate',
|
|
327
|
+
'scale',
|
|
328
|
+
'skew',
|
|
329
|
+
'origin',
|
|
330
|
+
'fill',
|
|
331
|
+
'stroke',
|
|
332
|
+
'object',
|
|
333
|
+
'aspect',
|
|
334
|
+
'basis',
|
|
335
|
+
'shrink',
|
|
336
|
+
'grow',
|
|
337
|
+
'flex',
|
|
338
|
+
'antialiased',
|
|
339
|
+
'uppercase',
|
|
340
|
+
'lowercase',
|
|
341
|
+
'capitalize',
|
|
342
|
+
'italic',
|
|
343
|
+
'underline',
|
|
344
|
+
'line',
|
|
345
|
+
'list',
|
|
346
|
+
'align',
|
|
347
|
+
'backdrop',
|
|
348
|
+
'blur',
|
|
349
|
+
'brightness',
|
|
350
|
+
'contrast',
|
|
351
|
+
'saturate',
|
|
352
|
+
'invert',
|
|
353
|
+
'sepia',
|
|
354
|
+
'grayscale',
|
|
355
|
+
])
|
|
356
|
+
|
|
357
|
+
function isNoiseClass(cls: string): boolean {
|
|
358
|
+
if (cls.includes(':')) // variant: hover:, md:, dark:, group-hover:
|
|
359
|
+
return true
|
|
360
|
+
if (cls[0] === '[') // arbitrary value: [mask-type:luminance]
|
|
361
|
+
return true
|
|
362
|
+
const head = cls.split('-')[0]
|
|
363
|
+
return TW_PREFIXES.has(head)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function semanticClasses(el: Element): string {
|
|
367
|
+
const cls = el.getAttribute('class')
|
|
368
|
+
if (!cls)
|
|
369
|
+
return ''
|
|
370
|
+
return cls.split(' ').map(c => c.trim()).filter(c => c && !isNoiseClass(c)).slice(0, 6).join('.')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// code-inspector data-insp-path looks like "src/features/Sidebar/index.tsx:78:17:App"
|
|
374
|
+
// → compact to "Sidebar/index.tsx:78" (last dir + file + line)
|
|
375
|
+
export function inspPath(el: Element): string {
|
|
376
|
+
const raw = el.getAttribute('data-insp-path')
|
|
377
|
+
if (!raw)
|
|
378
|
+
return ''
|
|
379
|
+
const segs = raw.split(':')
|
|
380
|
+
const file = segs[0]
|
|
381
|
+
const line = segs[1]
|
|
382
|
+
const fileParts = file.split('/')
|
|
383
|
+
const tail = fileParts.slice(-2).join('/')
|
|
384
|
+
return line ? `${tail}:${line}` : tail
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// focused/disabled/expanded — the three states shared by every DOM describer
|
|
388
|
+
function baseState(el: Element): string[] {
|
|
389
|
+
const parts: string[] = []
|
|
390
|
+
if (document.activeElement === el)
|
|
391
|
+
parts.push('focused')
|
|
392
|
+
if ((el as HTMLButtonElement).disabled)
|
|
393
|
+
parts.push('disabled')
|
|
394
|
+
if (el.getAttribute('aria-expanded') === 'true')
|
|
395
|
+
parts.push('expanded')
|
|
396
|
+
return parts
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function domState(el: Element): string {
|
|
400
|
+
const parts = baseState(el)
|
|
401
|
+
if (el.getAttribute('aria-selected') === 'true' || el.getAttribute('aria-checked') === 'true')
|
|
402
|
+
parts.push('selected')
|
|
403
|
+
if ((el as HTMLInputElement).value)
|
|
404
|
+
parts.push(`value="${(el as HTMLInputElement).value.slice(0, 40)}"`)
|
|
405
|
+
if (el.hasAttribute('contenteditable'))
|
|
406
|
+
parts.push('editable')
|
|
407
|
+
return parts.length ? ` {${parts.join(' ')}}` : ''
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Find the scope root: a CSS selector, or a component name matched against the
|
|
411
|
+
// code-inspector data-insp-path. Returns body when no scope given, null on miss.
|
|
412
|
+
function scopeRoot(scope?: string, sel?: string): Element | null {
|
|
413
|
+
if (sel)
|
|
414
|
+
return document.querySelector(sel)
|
|
415
|
+
if (scope) {
|
|
416
|
+
const all = document.querySelectorAll('[data-insp-path]')
|
|
417
|
+
const lower = scope.toLowerCase()
|
|
418
|
+
for (const el of all) {
|
|
419
|
+
if ((el.getAttribute('data-insp-path') || '').toLowerCase().includes(lower))
|
|
420
|
+
return el
|
|
421
|
+
}
|
|
422
|
+
return null
|
|
423
|
+
}
|
|
424
|
+
return document.body
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function collectDom(scope?: string, sel?: string): string {
|
|
428
|
+
const root = scopeRoot(scope, sel)
|
|
429
|
+
if (!root)
|
|
430
|
+
return `(no element for scope="${scope || sel}")`
|
|
431
|
+
const lines: string[] = []
|
|
432
|
+
|
|
433
|
+
function walk(el: Element, depth: number) {
|
|
434
|
+
if (depth > 20)
|
|
435
|
+
return
|
|
436
|
+
const tag = el.tagName.toLowerCase()
|
|
437
|
+
if (SKIP_TAGS.has(tag))
|
|
438
|
+
return
|
|
439
|
+
if (tag === 'svg') {
|
|
440
|
+
if (el.getBoundingClientRect().width > 0)
|
|
441
|
+
lines.push(`${' '.repeat(depth)}svg [icon]`)
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const role = el.getAttribute('role') || IMPLICIT_ROLES[tag] || ''
|
|
446
|
+
const sem = semanticClasses(el)
|
|
447
|
+
// data-* minus framework noise. data-insp-path (code-inspector) is the source
|
|
448
|
+
// location — keep it, but compacted to its file:line tail, shown only when the
|
|
449
|
+
// node is itself meaningful (carries role/text/state), not on bare wrappers.
|
|
450
|
+
const dataAttrs = Array.from(el.attributes)
|
|
451
|
+
.filter(a => a.name.startsWith('data-') && a.name !== 'data-testid' && a.name !== 'data-insp-path')
|
|
452
|
+
.map(a => a.value ? `${a.name}="${a.value.slice(0, 30)}"` : a.name)
|
|
453
|
+
.slice(0, 3)
|
|
454
|
+
const aria = el.getAttribute('aria-label') || ''
|
|
455
|
+
const text = aria || getDirectText(el)
|
|
456
|
+
const state = domState(el)
|
|
457
|
+
|
|
458
|
+
// collapse an element whose only child is an icon → one line
|
|
459
|
+
const onlyIcon = el.children.length === 1 && el.children[0].tagName.toLowerCase() === 'svg'
|
|
460
|
+
|
|
461
|
+
// a node is meaningful if it carries semantics a layout div doesn't
|
|
462
|
+
const meaningful = !!(role || sem || dataAttrs.length || text || el.id || state || el.hasAttribute('contenteditable'))
|
|
463
|
+
|
|
464
|
+
if (meaningful) {
|
|
465
|
+
const parts = [role ? `${tag}[${role}]` : tag]
|
|
466
|
+
if (sem)
|
|
467
|
+
parts.push(`.${sem}`)
|
|
468
|
+
if (el.id)
|
|
469
|
+
parts.push(`#${el.id}`)
|
|
470
|
+
for (const d of dataAttrs)
|
|
471
|
+
parts.push(`[${d}]`)
|
|
472
|
+
if (text)
|
|
473
|
+
parts.push(`"${text.slice(0, 60)}"`)
|
|
474
|
+
if (onlyIcon)
|
|
475
|
+
parts.push('[icon]')
|
|
476
|
+
const src = inspPath(el)
|
|
477
|
+
lines.push(' '.repeat(depth) + parts.join(' ') + state + (src ? ` @${src}` : ''))
|
|
478
|
+
depth += 1
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!onlyIcon) {
|
|
482
|
+
for (const child of el.children) walk(child, depth)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
walk(root, 0)
|
|
487
|
+
return lines.join('\n')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function collectState(): Record<string, unknown> {
|
|
491
|
+
const stores = window.__AIPEEK_STORES__
|
|
492
|
+
if (!stores)
|
|
493
|
+
return {}
|
|
494
|
+
|
|
495
|
+
const result: Record<string, unknown> = {}
|
|
496
|
+
for (const [name, store] of Object.entries(stores)) {
|
|
497
|
+
try {
|
|
498
|
+
result[name] = boundedSnapshot(store)
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
result[name] = '[error]'
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return result
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// State-machine projection: collapse the whole UI to {view, modal, focus, knobs}
|
|
508
|
+
// — the few variables a human reads off a washing-machine panel to act in O(1).
|
|
509
|
+
// knobs are clipped to what's reachable *now*: when a modal is open, only its
|
|
510
|
+
// subtree counts (the layer beneath is unclickable).
|
|
511
|
+
export function collectScreen(): string {
|
|
512
|
+
const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
|
|
513
|
+
const view = stores?.appUIStore?.mode ?? '(unknown)'
|
|
514
|
+
|
|
515
|
+
const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
|
|
516
|
+
const modalTitle = modalEl
|
|
517
|
+
? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || '').trim().slice(0, 40)
|
|
518
|
+
: ''
|
|
519
|
+
const modal = modalEl ? `${modalTitle || 'untitled'} @${inspPath(modalEl) || 'dialog'}` : 'none'
|
|
520
|
+
|
|
521
|
+
// Icon buttons carry no direct text — fall back to title, then any nested text.
|
|
522
|
+
const labelOf = (el: Element) => (el.getAttribute('aria-label') || getDirectText(el) || el.getAttribute('title') || (el.textContent || '').trim()).trim()
|
|
523
|
+
const visible = (el: Element) => el.getBoundingClientRect().width > 0
|
|
524
|
+
|
|
525
|
+
const active = document.activeElement
|
|
526
|
+
const focus = active && active !== document.body
|
|
527
|
+
? `${labelOf(active).slice(0, 30) || active.tagName.toLowerCase()} [${active.tagName.toLowerCase()}]${domState(active)}`
|
|
528
|
+
: 'none'
|
|
529
|
+
|
|
530
|
+
// Group by source location. A source rendered many times is a *list* (content),
|
|
531
|
+
// not a control — collapse it to one "source ×N" line. The washing-machine
|
|
532
|
+
// panel shows "the laundry", not every garment.
|
|
533
|
+
const root = modalEl ?? document.body
|
|
534
|
+
const bySource = new Map<string, Element[]>()
|
|
535
|
+
for (const el of root.querySelectorAll(INTERACTIVE)) {
|
|
536
|
+
if (!visible(el))
|
|
537
|
+
continue
|
|
538
|
+
const loc = inspPath(el) || '?'
|
|
539
|
+
if (!bySource.has(loc))
|
|
540
|
+
bySource.set(loc, [])
|
|
541
|
+
bySource.get(loc)!.push(el)
|
|
542
|
+
}
|
|
543
|
+
const knobs: string[] = []
|
|
544
|
+
for (const [loc, els] of bySource) {
|
|
545
|
+
if (els.length > 2) {
|
|
546
|
+
knobs.push(` ${loc} ×${els.length} (list)`)
|
|
547
|
+
continue
|
|
548
|
+
}
|
|
549
|
+
for (const el of els) {
|
|
550
|
+
const label = labelOf(el).slice(0, 40)
|
|
551
|
+
knobs.push(` ${label || `<${el.tagName.toLowerCase()}>`} [${el.tagName.toLowerCase()}]${loc !== '?' ? ` ${loc}` : ''}${domState(el)}`)
|
|
552
|
+
}
|
|
553
|
+
if (knobs.length >= 40)
|
|
554
|
+
break
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return [
|
|
558
|
+
`view: ${view}`,
|
|
559
|
+
`modal: ${modal}`,
|
|
560
|
+
`focus: ${focus}`,
|
|
561
|
+
`knobs (${knobs.length}):`,
|
|
562
|
+
...knobs,
|
|
563
|
+
].join('\n')
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function boundedSnapshot(obj: unknown, depth = 0): unknown {
|
|
567
|
+
if (depth > 3)
|
|
568
|
+
return '[…]'
|
|
569
|
+
if (obj === null || obj === undefined)
|
|
570
|
+
return obj
|
|
571
|
+
if (typeof obj === 'function')
|
|
572
|
+
return undefined
|
|
573
|
+
if (typeof obj === 'string')
|
|
574
|
+
return obj.length > 100 ? `${obj.slice(0, 100)}…` : obj
|
|
575
|
+
if (typeof obj !== 'object')
|
|
576
|
+
return obj
|
|
577
|
+
if (Array.isArray(obj))
|
|
578
|
+
return `Array(${obj.length})`
|
|
579
|
+
|
|
580
|
+
const result: Record<string, unknown> = {}
|
|
581
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>).slice(0, 20)) {
|
|
582
|
+
if (typeof v === 'function')
|
|
583
|
+
continue
|
|
584
|
+
result[k] = boundedSnapshot(v, depth + 1)
|
|
585
|
+
}
|
|
586
|
+
return result
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// --- HMR channel ---
|
|
590
|
+
|
|
591
|
+
// Resolve once the DOM goes quiet (no mutations for `quiet`ms) or `timeout` hits,
|
|
592
|
+
// then return the fresh UI tree. Lets a click/fill report its own outcome.
|
|
593
|
+
function waitForStable(quiet = 150, timeout = 2000): Promise<string> {
|
|
594
|
+
return new Promise((resolve) => {
|
|
595
|
+
const start = performance.now()
|
|
596
|
+
let timer = 0
|
|
597
|
+
let observer: MutationObserver
|
|
598
|
+
const done = () => {
|
|
599
|
+
observer.disconnect()
|
|
600
|
+
clearTimeout(timer)
|
|
601
|
+
resolve(collectUI())
|
|
602
|
+
}
|
|
603
|
+
observer = new MutationObserver(() => {
|
|
604
|
+
clearTimeout(timer)
|
|
605
|
+
if (performance.now() - start > timeout)
|
|
606
|
+
done()
|
|
607
|
+
else
|
|
608
|
+
timer = window.setTimeout(done, quiet)
|
|
609
|
+
})
|
|
610
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true })
|
|
611
|
+
timer = window.setTimeout(done, quiet)
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Multi-tab: the HMR channel broadcasts to every open tab. Only the tab the user
|
|
616
|
+
// is actually looking at should answer — otherwise server races N responses and
|
|
617
|
+
// keeps a random one, while click/fill fire in *all* tabs. visibilityState is
|
|
618
|
+
// 'visible' for exactly the focused tab (or single tab), 'hidden' for the rest.
|
|
619
|
+
//
|
|
620
|
+
// But when the user is reading the terminal, EVERY dev tab is hidden — then no one
|
|
621
|
+
// would answer. So the server sends requireVisible=true first; if it times out with
|
|
622
|
+
// no answer it retries requireVisible=false, and every tab answers (the original
|
|
623
|
+
// race). `skip` collapses that into one guard: hidden tab skips only round one.
|
|
624
|
+
function skip(msg?: { requireVisible?: boolean }) {
|
|
625
|
+
return msg?.requireVisible !== false && document.visibilityState !== 'visible'
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (import.meta.hot) {
|
|
629
|
+
import.meta.hot.on('aipeek:collect', (msg: { requireVisible?: boolean }) => {
|
|
630
|
+
if (skip(msg))
|
|
631
|
+
return
|
|
632
|
+
import.meta.hot!.send('aipeek:state', {
|
|
633
|
+
url: location.href,
|
|
634
|
+
ui: collectUI(),
|
|
635
|
+
console: [...consoleLogs],
|
|
636
|
+
network: [...networkRequests],
|
|
637
|
+
errors: [...errorEntries],
|
|
638
|
+
state: collectState(),
|
|
639
|
+
timestamp: Date.now(),
|
|
640
|
+
})
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean }) => {
|
|
644
|
+
if (skip(msg))
|
|
645
|
+
return
|
|
646
|
+
const result = await performAction(msg.type, msg.args)
|
|
647
|
+
// For mutating actions, settle the DOM then ship both the full UI tree and
|
|
648
|
+
// the compact screen projection — the caller skips a round-trip to /ui, and
|
|
649
|
+
// /chain uses the per-step screen so an interaction's every transition shows.
|
|
650
|
+
if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
|
|
651
|
+
result.ui = await waitForStable()
|
|
652
|
+
result.screen = collectScreen()
|
|
653
|
+
}
|
|
654
|
+
import.meta.hot!.send('aipeek:result', { id: msg.id, ...result })
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
// eval: run server-supplied code in the page. Wrapped in an async IIFE so the
|
|
658
|
+
// code can `await` and use `return`; non-string results are JSON-stringified.
|
|
659
|
+
import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean }) => {
|
|
660
|
+
if (skip(msg))
|
|
661
|
+
return
|
|
662
|
+
let ok = true
|
|
663
|
+
let value: string | undefined
|
|
664
|
+
let error: string | undefined
|
|
665
|
+
try {
|
|
666
|
+
// eslint-disable-next-line no-new-func
|
|
667
|
+
const fn = new Function(`return (async () => { ${msg.code} })()`)
|
|
668
|
+
const result = await fn()
|
|
669
|
+
value = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
670
|
+
}
|
|
671
|
+
catch (e) {
|
|
672
|
+
ok = false
|
|
673
|
+
error = e instanceof Error ? `${e.message}\n${e.stack ?? ''}` : String(e)
|
|
674
|
+
}
|
|
675
|
+
import.meta.hot!.send('aipeek:eval-result', { id: msg.id, ok, value, error })
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
import.meta.hot.on('aipeek:collect-dom', (msg: { scope?: string, sel?: string, requireVisible?: boolean }) => {
|
|
679
|
+
if (skip(msg))
|
|
680
|
+
return
|
|
681
|
+
import.meta.hot!.send('aipeek:dom', { dom: collectDom(msg?.scope, msg?.sel) })
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
import.meta.hot.on('aipeek:collect-screen', (msg: { requireVisible?: boolean }) => {
|
|
685
|
+
if (skip(msg))
|
|
686
|
+
return
|
|
687
|
+
import.meta.hot!.send('aipeek:screen', { screen: collectScreen() })
|
|
688
|
+
})
|
|
689
|
+
}
|