aipeek 0.2.7 → 0.2.8

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.
@@ -3,18 +3,48 @@
3
3
  // The patch code (console/fetch/XHR/errors) is in client-patch.ts,
4
4
  // injected as a synchronous inline script before any modules execute.
5
5
 
6
- import type { ActionArgs, ActionType } from '../core/action'
7
- import type { ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
8
- import { INTERACTIVE, performAction, runEval, withDialogGuard } from '../core/action'
6
+ import type { ActionArgs, ActionResult, ActionType } from '../core/action'
7
+ import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest, PerformanceData, PerfBucketData, ScreenSnap } from '../core/types'
8
+ import { elementLabel, getDirectText, INTERACTIVE, isSensitive, performAction, reachable, runEval, safeValue, withDialogGuard } from '../core/action'
9
+ import { diffScreen, diffState, stringifyDomain } from '../core/diff'
10
+ import { formatActions } from '../core/util'
9
11
 
10
12
  declare global {
11
- interface Window { __AIPEEK_STORES__?: Record<string, unknown> }
13
+ interface Window {
14
+ __AIPEEK_STORES__?: Record<string, unknown>
15
+ // The app's own domain projection — collapses the UI to a few domain variables
16
+ // that don't live in the DOM (是否流式 / 选了哪个模型 / 未读数). Optional; when
17
+ // absent /screen falls back to the generic view/modal/focus projection unchanged.
18
+ __AIPEEK_SCREEN__?: () => Record<string, unknown>
19
+ // Idempotency for shortest-path delivery: the server re-broadcasts an addressed action
20
+ // while a tab is mid self-heal, so the same action id can arrive more than once. Map id →
21
+ // its final result; on a repeat we replay the cached result instead of re-executing.
22
+ __AIPEEK_DONE_ACTIONS__?: Map<number, ActionResult>
23
+ }
24
+ }
25
+
26
+ // Internal perf buffer shape (from client-patch.ts) — differs from PerformanceData
27
+ interface PerfStatBuf { total: number, n: number, max: number, samples: number[] }
28
+ interface PerfBucketBuf {
29
+ components: Record<string, PerfStatBuf>
30
+ frames: { total: number, long: number, max: number, samples: number[] }
31
+ lines: Record<string, PerfStatBuf>
32
+ }
33
+ interface PerfBuffer {
34
+ startedAt: number
35
+ hiddenFrames: number
36
+ mobxPatched: boolean
37
+ buckets: Record<string, PerfBucketBuf>
38
+ active: string
39
+ longtasks: { count: number, max: number }
12
40
  }
13
41
 
14
42
  interface AipeekBuffers {
15
43
  consoleLogs: LogEntry[]
16
44
  networkRequests: NetworkRequest[]
17
45
  errorEntries: ErrorEntry[]
46
+ actionEntries: ActionEntry[]
47
+ perf: PerfBuffer
18
48
  }
19
49
 
20
50
  // Read buffers from the synchronous patch script
@@ -22,6 +52,71 @@ const buffers = (window as any).__AIPEEK_BUFFERS__ as AipeekBuffers | undefined
22
52
  const consoleLogs: LogEntry[] = buffers?.consoleLogs ?? []
23
53
  const networkRequests: NetworkRequest[] = buffers?.networkRequests ?? []
24
54
  const errorEntries: ErrorEntry[] = buffers?.errorEntries ?? []
55
+ const actionEntries: ActionEntry[] = buffers?.actionEntries ?? []
56
+ const perfBuffer: PerfBuffer | undefined = buffers?.perf
57
+
58
+ // Project the runtime perf buffer into PerformanceData for collection.
59
+ // Top-20 components by total, capped samples already in buffer.
60
+ function collectPerformance(): PerformanceData | undefined {
61
+ if (!perfBuffer) return undefined
62
+ const buckets: PerfBucketData[] = []
63
+ // __all__ first, then named buckets
64
+ const names = Object.keys(perfBuffer.buckets).sort((a, b) => (a === '__all__' ? -1 : b === '__all__' ? 1 : a.localeCompare(b)))
65
+ for (const name of names) {
66
+ const b = perfBuffer.buckets[name]
67
+ const comps: Record<string, { total: number, n: number, max: number, samples: number[] }> = {}
68
+ const sorted = Object.entries(b.components).sort((a, b) => b[1].total - a[1].total).slice(0, 20)
69
+ for (const [cname, stat] of sorted) {
70
+ comps[cname] = { total: stat.total, n: stat.n, max: stat.max, samples: [...stat.samples] }
71
+ }
72
+ const lines: Record<string, { total: number, n: number, max: number, samples: number[] }> = {}
73
+ const sortedLines = Object.entries(b.lines ?? {}).sort((a, b) => b[1].total - a[1].total).slice(0, 30)
74
+ for (const [label, stat] of sortedLines) {
75
+ lines[label] = { total: stat.total, n: stat.n, max: stat.max, samples: [...stat.samples] }
76
+ }
77
+ buckets.push({
78
+ name,
79
+ components: comps,
80
+ frames: { total: b.frames.total, long: b.frames.long, max: b.frames.max, samples: [...b.frames.samples] },
81
+ lines,
82
+ })
83
+ }
84
+ return {
85
+ windowMs: Date.now() - perfBuffer.startedAt,
86
+ hiddenFrames: perfBuffer.hiddenFrames,
87
+ mobxPatched: perfBuffer.mobxPatched,
88
+ buckets,
89
+ longtasks: { ...perfBuffer.longtasks },
90
+ }
91
+ }
92
+
93
+ // Reset the perf buffer (clear window, start fresh)
94
+ function resetPerf() {
95
+ if (!perfBuffer) return
96
+ perfBuffer.startedAt = Date.now()
97
+ perfBuffer.hiddenFrames = 0
98
+ perfBuffer.buckets = {}
99
+ perfBuffer.longtasks = { count: 0, max: 0 }
100
+ }
101
+
102
+ // Stable per-tab id so commands can address one tab among many (?tab=). Lives in
103
+ // sessionStorage: one browser tab = one id, survives HMR/refresh, cleared on close.
104
+ // Falls back to an in-memory id if sessionStorage is unavailable (degrades to old
105
+ // broadcast behavior, never throws).
106
+ const TAB_ID = (() => {
107
+ try {
108
+ const k = '__aipeek_tab_id__'
109
+ let id = sessionStorage.getItem(k)
110
+ if (!id) {
111
+ id = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
112
+ sessionStorage.setItem(k, id)
113
+ }
114
+ return id
115
+ }
116
+ catch {
117
+ return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
118
+ }
119
+ })()
25
120
 
26
121
  // --- On-demand collectors ---
27
122
 
@@ -228,18 +323,6 @@ function collectDomFallback(): string {
228
323
  return lines.join('\n')
229
324
  }
230
325
 
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
326
  // --- Semantic DOM collector ---
244
327
  // UI-as-text: strip Tailwind atomic classes, keep only what carries meaning —
245
328
  // tag, role, semantic classes, data-*/aria, text, interactive state, a usable selector.
@@ -396,14 +479,28 @@ function baseState(el: Element): string[] {
396
479
  return parts
397
480
  }
398
481
 
482
+ // Heuristic: does opening this control need a *trusted* event (realclick), not a synthetic
483
+ // one? Synthetic dispatchEvent can't open a Radix portal popup reliably (and never a native
484
+ // right-click menu). aria-haspopup is the W3C marker Radix puts on menu/dropdown/popover/
485
+ // select triggers — the honest, low-false-positive signal. Tag it so the AI picks realclick
486
+ // up front instead of discovering it by a failed synthetic click. The `?` keeps it honest:
487
+ // a hint, not a guarantee (right-click-only handlers carry no DOM attribute, so they can't be
488
+ // detected here — those still surface only on a miss).
489
+ function needsTrusted(el: Element): boolean {
490
+ const hp = el.getAttribute('aria-haspopup')
491
+ return hp === 'menu' || hp === 'dialog' || hp === 'listbox' || hp === 'true'
492
+ }
493
+
399
494
  function domState(el: Element): string {
400
495
  const parts = baseState(el)
401
496
  if (el.getAttribute('aria-selected') === 'true' || el.getAttribute('aria-checked') === 'true')
402
497
  parts.push('selected')
403
498
  if ((el as HTMLInputElement).value)
404
- parts.push(`value="${(el as HTMLInputElement).value.slice(0, 40)}"`)
499
+ parts.push(`value="${safeValue(el).slice(0, 40)}"`)
405
500
  if (el.hasAttribute('contenteditable'))
406
501
  parts.push('editable')
502
+ if (needsTrusted(el))
503
+ parts.push('needs-trusted?')
407
504
  return parts.length ? ` {${parts.join(' ')}}` : ''
408
505
  }
409
506
 
@@ -447,9 +544,12 @@ export function collectDom(scope?: string, sel?: string): string {
447
544
  // data-* minus framework noise. data-insp-path (code-inspector) is the source
448
545
  // location — keep it, but compacted to its file:line tail, shown only when the
449
546
  // node is itself meaningful (carries role/text/state), not on bare wrappers.
547
+ // On a secret field, redact data-* values too — an app could mirror the value into
548
+ // a data attr, and we never want that echoed (same stance as the value redaction).
549
+ const secret = isSensitive(el)
450
550
  const dataAttrs = Array.from(el.attributes)
451
551
  .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)
552
+ .map(a => a.value ? `${a.name}="${secret ? `‹redacted ${a.value.length}›` : a.value.slice(0, 30)}"` : a.name)
453
553
  .slice(0, 3)
454
554
  const aria = el.getAttribute('aria-label') || ''
455
555
  const text = aria || getDirectText(el)
@@ -508,7 +608,9 @@ function collectState(): Record<string, unknown> {
508
608
  // — the few variables a human reads off a washing-machine panel to act in O(1).
509
609
  // knobs are clipped to what's reachable *now*: when a modal is open, only its
510
610
  // subtree counts (the layer beneath is unclickable).
511
- export function collectScreen(): string {
611
+ // The state-machine projection — just the three vars that move when the UI transitions.
612
+ // Shared by collectScreen (the /screen header) and the action handler's before/after diff.
613
+ export function screenSnap(): ScreenSnap {
512
614
  const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
513
615
  const view = stores?.appUIStore?.mode ?? '(unknown)'
514
616
 
@@ -518,22 +620,37 @@ export function collectScreen(): string {
518
620
  : ''
519
621
  const modal = modalEl ? `${modalTitle || 'untitled'} @${inspPath(modalEl) || 'dialog'}` : 'none'
520
622
 
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
623
  const active = document.activeElement
526
624
  const focus = active && active !== document.body
527
- ? `${labelOf(active).slice(0, 30) || active.tagName.toLowerCase()} [${active.tagName.toLowerCase()}]${domState(active)}`
625
+ ? `${elementLabel(active).slice(0, 30) || active.tagName.toLowerCase()} [${active.tagName.toLowerCase()}]${domState(active)}`
528
626
  : 'none'
529
627
 
628
+ let domain: Record<string, unknown> = {}
629
+ try {
630
+ domain = window.__AIPEEK_SCREEN__?.() ?? {}
631
+ }
632
+ catch {
633
+ domain = { '[error]': '__AIPEEK_SCREEN__ threw' }
634
+ }
635
+
636
+ return { view, modal, focus, domain }
637
+ }
638
+
639
+ export function collectScreen(): string {
640
+ const { view, modal, focus, domain } = screenSnap()
641
+ const domainLines = Object.entries(domain).map(([k, v]) => ` ${k}: ${stringifyDomain(v)}`)
642
+
643
+ const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
644
+
530
645
  // Group by source location. A source rendered many times is a *list* (content),
531
646
  // not a control — collapse it to one "source ×N" line. The washing-machine
532
647
  // panel shows "the laundry", not every garment.
533
648
  const root = modalEl ?? document.body
534
649
  const bySource = new Map<string, Element[]>()
535
650
  for (const el of root.querySelectorAll(INTERACTIVE)) {
536
- if (!visible(el))
651
+ // reachable, not just isVisible: only list knobs a click can actually land on now —
652
+ // same predicate /click uses, so read and write agree (no "listed but unclickable").
653
+ if (!reachable(el))
537
654
  continue
538
655
  const loc = inspPath(el) || '?'
539
656
  if (!bySource.has(loc))
@@ -547,7 +664,7 @@ export function collectScreen(): string {
547
664
  continue
548
665
  }
549
666
  for (const el of els) {
550
- const label = labelOf(el).slice(0, 40)
667
+ const label = elementLabel(el).slice(0, 40)
551
668
  knobs.push(` ${label || `<${el.tagName.toLowerCase()}>`} [${el.tagName.toLowerCase()}]${loc !== '?' ? ` ${loc}` : ''}${domState(el)}`)
552
669
  }
553
670
  if (knobs.length >= 40)
@@ -556,6 +673,7 @@ export function collectScreen(): string {
556
673
 
557
674
  return [
558
675
  `view: ${view}`,
676
+ ...(domainLines.length ? [`domain:`, ...domainLines] : []),
559
677
  `modal: ${modal}`,
560
678
  `focus: ${focus}`,
561
679
  `knobs (${knobs.length}):`,
@@ -563,6 +681,22 @@ export function collectScreen(): string {
563
681
  ].join('\n')
564
682
  }
565
683
 
684
+ // Called from the action handler after the DOM has settled. The synthetic event aipeek
685
+ // just fired was captured into actionEntries by client-patch; find that entry (latest
686
+ // synthetic) and back-fill its snapshot synchronously off the now-settled DOM — not via
687
+ // client-patch's async 180ms stamp, which may not have run yet (Risk 1). Then render the
688
+ // timeline anchored on it, so the model sees its own action between before/after context.
689
+ function attachTimeline(): string {
690
+ const cur = [...actionEntries].reverse().find(e => !e.trusted)
691
+ if (cur) {
692
+ const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
693
+ cur.view = stores?.appUIStore?.mode ?? '(unknown)'
694
+ const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
695
+ cur.modal = modalEl ? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || 'untitled').trim().slice(0, 40) : 'none'
696
+ }
697
+ return formatActions([...actionEntries], cur?.ts)
698
+ }
699
+
566
700
  function boundedSnapshot(obj: unknown, depth = 0): unknown {
567
701
  if (depth > 3)
568
702
  return '[…]'
@@ -621,28 +755,63 @@ function waitForStable(quiet = 150, timeout = 2000): Promise<string> {
621
755
  // would answer. So the server sends requireVisible=true first; if it times out with
622
756
  // no answer it retries requireVisible=false, and every tab answers (the original
623
757
  // race). `skip` collapses that into one guard: hidden tab skips only round one.
624
- function skip(msg?: { requireVisible?: boolean }) {
758
+ function skip(msg?: { requireVisible?: boolean, tab?: string }) {
759
+ if (msg?.tab) // addressed by id: only that tab answers, regardless of visibility (incl. background)
760
+ return msg.tab !== TAB_ID
625
761
  return msg?.requireVisible !== false && document.visibilityState !== 'visible'
626
762
  }
627
763
 
628
764
  if (import.meta.hot) {
629
- import.meta.hot.on('aipeek:collect', (msg: { requireVisible?: boolean }) => {
765
+ // 主动报到:补上 roster 缺失的「注册边」。在此之前 roster 只在收到轮询回复时(aipeek:state/
766
+ // result/dom/screen)才登记一个 tab——于是会话里第一个命令若是 /tabs(纯 roster 读,自身不轮询),
767
+ // 即使前台开着健康 tab 也必然读到 (no live tabs)。连上即 hello、visibilitychange 重发,roster
768
+ // 实时为真。TAB_ID 存 sessionStorage 跨自愈 reload 存活,reload 后 hello 自然重放。
769
+ const hello = () => import.meta.hot!.send('aipeek:hello', {
770
+ tab: TAB_ID,
771
+ url: location.href,
772
+ title: document.title,
773
+ visible: document.visibilityState === 'visible',
774
+ })
775
+ hello()
776
+ addEventListener('visibilitychange', hello)
777
+
778
+ import.meta.hot.on('aipeek:collect', (msg: { requireVisible?: boolean, tab?: string }) => {
630
779
  if (skip(msg))
631
780
  return
632
781
  import.meta.hot!.send('aipeek:state', {
782
+ tab: TAB_ID,
633
783
  url: location.href,
784
+ title: document.title,
785
+ visible: document.visibilityState === 'visible',
634
786
  ui: collectUI(),
635
787
  console: [...consoleLogs],
636
788
  network: [...networkRequests],
637
789
  errors: [...errorEntries],
790
+ actions: [...actionEntries],
638
791
  state: collectState(),
792
+ performance: collectPerformance(),
639
793
  timestamp: Date.now(),
640
794
  })
641
795
  })
642
796
 
643
- import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean }) => {
797
+ import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean, tab?: string }) => {
644
798
  if (skip(msg))
645
799
  return
800
+ // Idempotency: the server re-broadcasts an addressed action while the tab is mid self-heal,
801
+ // so the same id can land twice. Execute once per id; on a repeat replay the cached result
802
+ // (re-settles the server's pendingActions slot if the original reply was lost in teardown).
803
+ const done = (window.__AIPEEK_DONE_ACTIONS__ ??= new Map<number, ActionResult>())
804
+ const cached = done.get(msg.id)
805
+ if (cached) {
806
+ import.meta.hot!.send('aipeek:result', { tab: TAB_ID, id: msg.id, ...cached })
807
+ return
808
+ }
809
+ // Snapshot the state machine + error/network buffers *before* the action, so the
810
+ // post-settle diff is exactly what this action caused (see result.screen below).
811
+ const before = screenSnap()
812
+ const beforeConsole = [...consoleLogs]
813
+ const beforeNetwork = [...networkRequests]
814
+ const beforeErrors = [...errorEntries]
646
815
  // Guard against native alert/confirm/prompt freezing the probe (see withDialogGuard).
647
816
  const { result, dialogs } = await withDialogGuard(() => performAction(msg.type, msg.args))
648
817
  if (dialogs.length)
@@ -652,39 +821,72 @@ if (import.meta.hot) {
652
821
  // can reach the main process via electronAPI.invoke('aipeek:input') → sendInputEvent;
653
822
  // in a plain Chrome tab it can't (no chrome.debugger from page JS), so leave ui
654
823
  // undefined and let the server drive its extension queue.
824
+ // realclick resolved (x,y) but synthetic events can't open a Radix ContextMenu — fire
825
+ // a trusted click via Electron's main process if reachable, else leave it for the
826
+ // server's extension queue. Either path then settles like any other mutating action.
655
827
  const electronAPI = (window as { electronAPI?: { invoke: (channel: string, ...args: unknown[]) => Promise<unknown> } }).electronAPI
656
828
  if (msg.type === 'realclick' && result.ok && electronAPI) {
657
829
  await electronAPI.invoke('aipeek:input', { type: 'click', button: msg.args.button ?? 'left', x: result.x, y: result.y })
658
- result.ui = await waitForStable()
659
- result.screen = collectScreen()
830
+ result.fired = true // server: trusted click already fired in-process, skip the extension queue
660
831
  }
661
- // For mutating actions, settle the DOM then ship both the full UI tree and
662
- // the compact screen projection the caller skips a round-trip to /ui, and
663
- // /chain uses the per-step screen so an interaction's every transition shows.
664
- else if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
665
- result.ui = await waitForStable()
666
- result.screen = collectScreen()
832
+
833
+ // For mutating actions, settle the DOM then ship *only the change this action caused*
834
+ // (state-machine transition + new errors/failed requests), not a fresh snapshot. AI
835
+ // reads the delta and drills into /ui or /dom for detail if needed. The timeline still
836
+ // surfaces concurrent user actions.
837
+ const mutated = msg.type === 'click' || msg.type === 'fill' || msg.type === 'press'
838
+ || (msg.type === 'realclick' && !!electronAPI)
839
+ if (result.ok && mutated) {
840
+ await waitForStable()
841
+ const after = screenSnap()
842
+ const d = diffState(
843
+ { ui: '', console: beforeConsole, network: beforeNetwork, errors: beforeErrors, state: {}, url: '', timestamp: 0 },
844
+ { ui: '', console: [...consoleLogs], network: [...networkRequests], errors: [...errorEntries], state: {}, url: '', timestamp: 0 },
845
+ )
846
+ const changed = diffScreen(before, after, d.newErrors, d.newExceptions, d.newFailedRequests)
847
+ result.screen = changed.length ? changed.join('\n') : '(no state change)'
848
+ result.actions = attachTimeline()
667
849
  }
668
- import.meta.hot!.send('aipeek:result', { id: msg.id, ...result })
850
+ done.set(msg.id, result)
851
+ if (done.size > 64)
852
+ done.delete(done.keys().next().value!)
853
+ import.meta.hot!.send('aipeek:result', { tab: TAB_ID, id: msg.id, ...result })
669
854
  })
670
855
 
671
856
  // eval: run server-supplied code in the page with auto-return (see runEval).
672
- import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean }) => {
857
+ import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean, tab?: string }) => {
673
858
  if (skip(msg))
674
859
  return
675
860
  const { ok, value, error } = await runEval(msg.code)
676
- import.meta.hot!.send('aipeek:eval-result', { id: msg.id, ok, value, error })
861
+ import.meta.hot!.send('aipeek:eval-result', { tab: TAB_ID, id: msg.id, ok, value, error })
862
+ })
863
+
864
+ import.meta.hot.on('aipeek:collect-dom', (msg: { scope?: string, sel?: string, requireVisible?: boolean, tab?: string }) => {
865
+ if (skip(msg))
866
+ return
867
+ import.meta.hot!.send('aipeek:dom', { tab: TAB_ID, dom: collectDom(msg?.scope, msg?.sel) })
677
868
  })
678
869
 
679
- import.meta.hot.on('aipeek:collect-dom', (msg: { scope?: string, sel?: string, requireVisible?: boolean }) => {
870
+ import.meta.hot.on('aipeek:collect-screen', (msg: { requireVisible?: boolean, tab?: string }) => {
680
871
  if (skip(msg))
681
872
  return
682
- import.meta.hot!.send('aipeek:dom', { dom: collectDom(msg?.scope, msg?.sel) })
873
+ // Ship the rendered text *and* the structured snap + buffers, so the server can serve
874
+ // /screen?since=<token> by diffing this snap against a stashed prior one (diffScreen).
875
+ import.meta.hot!.send('aipeek:screen', {
876
+ tab: TAB_ID,
877
+ screen: collectScreen(),
878
+ snap: screenSnap(),
879
+ console: [...consoleLogs],
880
+ network: [...networkRequests],
881
+ errors: [...errorEntries],
882
+ })
683
883
  })
684
884
 
685
- import.meta.hot.on('aipeek:collect-screen', (msg: { requireVisible?: boolean }) => {
885
+ // Perf profiler handlers
886
+ import.meta.hot.on('aipeek:perf-reset', (msg: { requireVisible?: boolean, tab?: string }) => {
686
887
  if (skip(msg))
687
888
  return
688
- import.meta.hot!.send('aipeek:screen', { screen: collectScreen() })
889
+ resetPerf()
890
+ import.meta.hot!.send('aipeek:perf-reset-ack', { tab: TAB_ID })
689
891
  })
690
892
  }