browsertrack 0.2.0 → 0.2.1

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.
Files changed (39) hide show
  1. package/AGENTS.md +2 -0
  2. package/dist/{chunk-UP5JKCFY.js → chunk-464D4U2U.js} +3 -2
  3. package/dist/chunk-464D4U2U.js.map +1 -0
  4. package/dist/{chunk-WB7ZKWK7.js → chunk-INXDWPJW.js} +354 -9
  5. package/dist/chunk-INXDWPJW.js.map +1 -0
  6. package/dist/cli/index.js +2 -1
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/client/index.cjs +357 -10
  9. package/dist/client/index.d.ts +36 -4
  10. package/dist/client/index.js +7 -3
  11. package/dist/client.iife.js +42 -19
  12. package/dist/{notes-BMnonq46.d.ts → commands-fjuqKzkm.d.ts} +125 -114
  13. package/dist/core/index.d.ts +2 -2
  14. package/dist/daemon/index.d.ts +3 -3
  15. package/dist/{engine-B43IohQY.d.ts → engine-CmchnMDq.d.ts} +2 -2
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +2 -2
  18. package/dist/mcp/index.d.ts +4 -4
  19. package/dist/mcp/index.js +1 -1
  20. package/dist/{projects-D5J-egVN.d.ts → projects-DB7S312i.d.ts} +1 -1
  21. package/dist/{server-BztYp1Zc.d.ts → server-DiVmTrIR.d.ts} +1 -1
  22. package/docs/component-resolver.md +108 -0
  23. package/docs/index.md +1 -0
  24. package/docs/mcp-reference.md +2 -2
  25. package/docs/visual-notes.md +36 -0
  26. package/package.json +1 -1
  27. package/packages/client/src/client.ts +18 -1
  28. package/packages/client/src/config.ts +84 -1
  29. package/packages/client/src/index.ts +4 -1
  30. package/packages/client/src/interceptors/interaction.ts +3 -0
  31. package/packages/client/src/notes/inspector.ts +107 -5
  32. package/packages/client/src/source/resolver.ts +272 -0
  33. package/packages/core/src/types/events.ts +3 -0
  34. package/packages/core/src/types/notes.ts +11 -0
  35. package/packages/mcp/src/handlers.ts +1 -0
  36. package/test/client/component-resolver.test.ts +141 -0
  37. package/test/client/interceptors.test.ts +56 -0
  38. package/dist/chunk-UP5JKCFY.js.map +0 -1
  39. package/dist/chunk-WB7ZKWK7.js.map +0 -1
@@ -12,12 +12,25 @@ export interface BrowserDiagClientOptions {
12
12
  enabled?: boolean;
13
13
  shortcut?: string;
14
14
  showBadges?: boolean;
15
+ showToolbar?: boolean;
15
16
  maskSelectors?: string[];
16
17
  };
18
+ /**
19
+ * Hide visible BrowserTrack UI (toolbar dock, pins, hover overlay) by default.
20
+ */
21
+ hidden?: boolean;
22
+ /**
23
+ * Custom query parameter(s) used to hide the UI (e.g. 'e2e', 'no_ui', 'clean').
24
+ * Built-in query params like ?bt=0, ?bt=false, ?bt=hidden, ?browsertrack=false, ?no_bt are supported automatically.
25
+ */
26
+ hideQueryParam?: string | string[];
17
27
  debug?: boolean;
18
28
  }
19
29
 
20
- export const DEFAULT_OPTIONS: Required<Omit<BrowserDiagClientOptions, 'notes'>> & { notes: Required<NonNullable<BrowserDiagClientOptions['notes']>> } = {
30
+ export const DEFAULT_OPTIONS: Required<Omit<BrowserDiagClientOptions, 'notes' | 'hideQueryParam'>> & {
31
+ hideQueryParam?: string | string[];
32
+ notes: Required<NonNullable<BrowserDiagClientOptions['notes']>>;
33
+ } = {
21
34
  daemonUrl: 'ws://127.0.0.1:7331',
22
35
  projectId: '',
23
36
  maxBreadcrumbs: 50,
@@ -31,7 +44,77 @@ export const DEFAULT_OPTIONS: Required<Omit<BrowserDiagClientOptions, 'notes'>>
31
44
  enabled: true,
32
45
  shortcut: 'Alt+Click',
33
46
  showBadges: true,
47
+ showToolbar: true,
34
48
  maskSelectors: ['input[type="password"]', '[data-sensitive]'],
35
49
  },
50
+ hidden: false,
51
+ hideQueryParam: undefined,
36
52
  debug: false,
37
53
  };
54
+
55
+ const HIDE_VALUES = new Set(['0', 'false', 'hidden', 'hide', 'off', 'none', 'disabled', 'ui_off', 'silent']);
56
+
57
+ /**
58
+ * Checks if the tool's visible UI should be hidden based on URL search query parameters.
59
+ */
60
+ export function shouldHideUIFromUrl(customParam?: string | string[], searchString?: string): boolean {
61
+ let search = searchString;
62
+ if (search === undefined) {
63
+ if (typeof window === 'undefined' || !window.location) return false;
64
+ search = window.location.search;
65
+ }
66
+
67
+ if (!search) return false;
68
+
69
+ try {
70
+ const params = new URLSearchParams(search);
71
+
72
+ // 1. Check custom parameters if configured
73
+ if (customParam) {
74
+ const customKeys = Array.isArray(customParam) ? customParam : [customParam];
75
+ for (const key of customKeys) {
76
+ if (params.has(key)) {
77
+ const val = (params.get(key) || '').toLowerCase().trim();
78
+ if (val === '' || val === '1' || val === 'true' || HIDE_VALUES.has(val)) {
79
+ return true;
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ // 2. Boolean flags without values or with 1/true: ?no_bt, ?no_browsertrack, ?hide_bt, ?hide_browsertrack
86
+ for (const flag of ['no_bt', 'no_browsertrack', 'hide_bt', 'hide_browsertrack']) {
87
+ if (params.has(flag)) {
88
+ const val = (params.get(flag) || '').toLowerCase().trim();
89
+ if (val === '' || val === '1' || val === 'true' || val === 'yes') {
90
+ return true;
91
+ }
92
+ }
93
+ }
94
+
95
+ // 3. Value-based parameters: ?bt=false, ?bt=0, ?bt=hidden, ?bt=off
96
+ if (params.has('bt')) {
97
+ const val = (params.get('bt') || '').toLowerCase().trim();
98
+ if (HIDE_VALUES.has(val)) return true;
99
+ }
100
+
101
+ if (params.has('browsertrack')) {
102
+ const val = (params.get('browsertrack') || '').toLowerCase().trim();
103
+ if (HIDE_VALUES.has(val)) return true;
104
+ }
105
+
106
+ if (params.has('bt_ui')) {
107
+ const val = (params.get('bt_ui') || '').toLowerCase().trim();
108
+ if (HIDE_VALUES.has(val) || val === '0' || val === 'false') return true;
109
+ }
110
+
111
+ if (params.has('bt_hide')) {
112
+ const val = (params.get('bt_hide') || '').toLowerCase().trim();
113
+ if (val === '' || val === '1' || val === 'true') return true;
114
+ }
115
+ } catch {
116
+ // Defensive in non-standard environments
117
+ }
118
+
119
+ return false;
120
+ }
@@ -7,6 +7,7 @@ export * from './breadcrumbs.js';
7
7
  export * from './screenshot/driver.js';
8
8
  export * from './screenshot/browser-script-driver.js';
9
9
  export * from './notes/inspector.js';
10
+ export * from './source/resolver.js';
10
11
 
11
12
  let defaultClient: BrowserTrackClient | null = null;
12
13
 
@@ -40,9 +41,11 @@ if (typeof window !== 'undefined') {
40
41
  const autoInit = currentScript?.getAttribute('data-auto-init') !== 'false';
41
42
  const daemonUrl = currentScript?.getAttribute('data-daemon-url') || undefined;
42
43
  const projectId = currentScript?.getAttribute('data-project-id') || undefined;
44
+ const hidden = currentScript?.getAttribute('data-hidden') === 'true';
45
+ const hideQueryParam = currentScript?.getAttribute('data-hide-query-param') || undefined;
43
46
 
44
47
  if (autoInit) {
45
- init({ daemonUrl, projectId });
48
+ init({ daemonUrl, projectId, hidden, hideQueryParam });
46
49
  }
47
50
  } catch {
48
51
  // If auto-start fails, don't crash
@@ -1,5 +1,6 @@
1
1
  import type { Breadcrumb, ElementSummary } from '../../../core/src/index.js';
2
2
  import { getSemanticSelector, truncate } from '../../../core/src/index.js';
3
+ import { resolveComponentSource } from '../source/resolver.js';
3
4
 
4
5
  export type InteractionCallback = (breadcrumb: Breadcrumb, elementSummary?: ElementSummary) => void;
5
6
 
@@ -12,6 +13,7 @@ export function extractElementSummary(el: HTMLElement | Element): ElementSummary
12
13
  const tag = el.tagName.toLowerCase();
13
14
  const id = el.id || undefined;
14
15
  const classes = Array.from(el.classList || []);
16
+ const componentSource = resolveComponentSource(el as HTMLElement);
15
17
 
16
18
  let boundingRect: ElementSummary['boundingRect'] = undefined;
17
19
  let visible = true;
@@ -53,6 +55,7 @@ export function extractElementSummary(el: HTMLElement | Element): ElementSummary
53
55
  visible,
54
56
  innerText,
55
57
  outerHTML,
58
+ componentSource,
56
59
  };
57
60
  }
58
61
 
@@ -2,6 +2,8 @@ import type { ElementContext, NoteTarget, RegionContext, VisualNote } from '../.
2
2
  import { getSemanticSelector, truncate } from '../../../core/src/index.js';
3
3
  import type { ScreenshotDriver } from '../screenshot/driver.js';
4
4
  import type { WebSocketTransport } from '../transport/websocket.js';
5
+ import { shouldHideUIFromUrl } from '../config.js';
6
+ import { resolveComponentSource } from '../source/resolver.js';
5
7
 
6
8
  export type NoteInspectMode = 'element' | 'region' | 'page' | 'idle';
7
9
 
@@ -9,6 +11,9 @@ export interface InspectorOptions {
9
11
  shortcut?: string; // e.g. "Alt+Click"
10
12
  maskSelectors?: string[];
11
13
  showToolbar?: boolean;
14
+ showBadges?: boolean;
15
+ hidden?: boolean;
16
+ hideQueryParam?: string | string[];
12
17
  onNoteCreated?: (note: Partial<VisualNote>) => void;
13
18
  }
14
19
 
@@ -28,6 +33,7 @@ export interface ActiveScenarioState {
28
33
  * 5. Real-time Saved Note & Step Markers (pins with step sequencing numbers)
29
34
  * 6. Interactive Note & Step Detail Card (with previous/next step walk-through navigation)
30
35
  * 7. Floating quick dock / toolbar in bottom-right corner with Notes count and Flow controls
36
+ * 8. Automatic invisibility via query parameter (?bt=0, ?bt=false, ?bt=hidden, ?no_bt, custom query params)
31
37
  */
32
38
  export class NoteInspector {
33
39
  private transport: WebSocketTransport;
@@ -58,15 +64,20 @@ export class NoteInspector {
58
64
 
59
65
  private savedNotes: VisualNote[] = [];
60
66
  private showMarkers = true;
67
+ private isHidden = false;
61
68
  private cleanups: (() => void)[] = [];
62
69
 
63
70
  constructor(transport: WebSocketTransport, screenshotDriver: ScreenshotDriver, options: InspectorOptions = {}) {
64
71
  this.transport = transport;
65
72
  this.screenshotDriver = screenshotDriver;
73
+ const hiddenByQuery = shouldHideUIFromUrl(options.hideQueryParam);
74
+ this.isHidden = options.hidden === true || hiddenByQuery;
75
+ this.showMarkers = options.showBadges !== false && !this.isHidden;
66
76
  this.options = {
67
77
  shortcut: 'Alt+Click',
68
78
  maskSelectors: ['input[type="password"]', '[data-sensitive]'],
69
79
  showToolbar: true,
80
+ showBadges: true,
70
81
  ...options,
71
82
  };
72
83
  }
@@ -158,6 +169,44 @@ export class NoteInspector {
158
169
  }
159
170
  }
160
171
 
172
+ public isVisible(): boolean {
173
+ return !this.isHidden;
174
+ }
175
+
176
+ public setVisible(visible: boolean): void {
177
+ this.isHidden = !visible;
178
+ this.showMarkers = this.options.showBadges !== false && !this.isHidden;
179
+
180
+ if (this.container) {
181
+ this.container.style.display = this.isHidden ? 'none' : 'block';
182
+ }
183
+
184
+ if (this.toolbarElement) {
185
+ this.toolbarElement.style.display = this.isHidden ? 'none' : 'flex';
186
+ } else if (!this.isHidden && this.options.showToolbar !== false) {
187
+ this.createToolbar();
188
+ }
189
+
190
+ if (this.isHidden) {
191
+ this.hideHighlight();
192
+ this.hideRegionOverlay();
193
+ if (this.modalOverlay && this.shadowRoot) {
194
+ this.shadowRoot.removeChild(this.modalOverlay);
195
+ this.modalOverlay = null;
196
+ }
197
+ if (this.cardOverlay && this.shadowRoot) {
198
+ this.shadowRoot.removeChild(this.cardOverlay);
199
+ this.cardOverlay = null;
200
+ }
201
+ if (this.markersContainer) {
202
+ this.markersContainer.innerHTML = '';
203
+ }
204
+ } else {
205
+ this.renderMarkers();
206
+ this.updateToolbarCount();
207
+ }
208
+ }
209
+
161
210
  private ensureContainer(): ShadowRoot | null {
162
211
  if (typeof document === 'undefined') return null;
163
212
 
@@ -166,6 +215,9 @@ export class NoteInspector {
166
215
  this.container.id = 'browsertrack-inspector-host';
167
216
  this.container.style.cssText =
168
217
  'all: initial; position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none;';
218
+ if (this.isHidden) {
219
+ this.container.style.display = 'none';
220
+ }
169
221
 
170
222
  this.shadowRoot = this.container.attachShadow({ mode: 'open' });
171
223
 
@@ -611,6 +663,22 @@ export class NoteInspector {
611
663
  color: #cbd5e1;
612
664
  }
613
665
 
666
+ .bt-component-pill {
667
+ display: flex;
668
+ align-items: center;
669
+ gap: 6px;
670
+ background: rgba(15, 23, 42, 0.95);
671
+ border: 1px solid rgba(99, 102, 241, 0.35);
672
+ border-radius: 6px;
673
+ padding: 6px 10px;
674
+ font-size: 11.5px;
675
+ color: #c7d2fe;
676
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
677
+ overflow: hidden;
678
+ text-overflow: ellipsis;
679
+ white-space: nowrap;
680
+ }
681
+
614
682
  .bt-scenario-stepper {
615
683
  display: flex;
616
684
  align-items: center;
@@ -859,7 +927,7 @@ export class NoteInspector {
859
927
  this.shadowRoot.appendChild(this.toastContainer);
860
928
 
861
929
  // Floating Toolbar
862
- if (this.options.showToolbar) {
930
+ if (this.options.showToolbar && !this.isHidden) {
863
931
  this.createToolbar();
864
932
  }
865
933
  }
@@ -978,7 +1046,7 @@ export class NoteInspector {
978
1046
  if (!root || !this.markersContainer) return;
979
1047
 
980
1048
  this.markersContainer.innerHTML = '';
981
- if (!this.showMarkers) return;
1049
+ if (!this.showMarkers || this.isHidden) return;
982
1050
 
983
1051
  const currentPath = window.location.pathname;
984
1052
  // Filter open notes on this route or global
@@ -1143,6 +1211,32 @@ export class NoteInspector {
1143
1211
 
1144
1212
  ${scenarioStepsHtml}
1145
1213
 
1214
+ ${
1215
+ note.elementContext?.componentSource?.componentName
1216
+ ? `<div class="bt-component-pill" title="${
1217
+ note.elementContext.componentSource.sourceFile
1218
+ ? note.elementContext.componentSource.sourceFile +
1219
+ (note.elementContext.componentSource.sourceLine ? ':' + note.elementContext.componentSource.sourceLine : '')
1220
+ : note.elementContext.componentSource.componentName
1221
+ }">
1222
+ <span>🧬</span>
1223
+ <span style="font-weight: 600; color: #a5b4fc;">&lt;${note.elementContext.componentSource.componentName}&gt;</span>
1224
+ ${
1225
+ note.elementContext.componentSource.sourceFile
1226
+ ? `<span style="color: #64748b; font-size: 11px; margin-left: 4px;">${note.elementContext.componentSource.sourceFile}${
1227
+ note.elementContext.componentSource.sourceLine ? ':' + note.elementContext.componentSource.sourceLine : ''
1228
+ }</span>`
1229
+ : ''
1230
+ }
1231
+ ${
1232
+ note.elementContext.componentSource.framework
1233
+ ? `<span class="bt-mode-badge" style="background: rgba(99, 102, 241, 0.15); color: #818cf8; margin-left: auto; font-size: 10px;">${note.elementContext.componentSource.framework.toUpperCase()}</span>`
1234
+ : ''
1235
+ }
1236
+ </div>`
1237
+ : ''
1238
+ }
1239
+
1146
1240
  <div class="bt-target-pill" title="${contextText}">
1147
1241
  <span>🏷️</span>
1148
1242
  <span class="bt-pill-content">${contextText}</span>
@@ -1249,7 +1343,7 @@ export class NoteInspector {
1249
1343
  private setupListeners(): void {
1250
1344
  // 1. Mouse move: hover highlight in element mode or with Alt key
1251
1345
  const onMouseMove = (e: MouseEvent) => {
1252
- if (this.modalOverlay || this.cardOverlay || this.isDraggingRegion || this.activeMode === 'region') return;
1346
+ if (this.isHidden || this.modalOverlay || this.cardOverlay || this.isDraggingRegion || this.activeMode === 'region') return;
1253
1347
 
1254
1348
  // Don't highlight elements if mouse is over our own inspector UI
1255
1349
  const path = e.composedPath ? e.composedPath() : [];
@@ -1274,7 +1368,7 @@ export class NoteInspector {
1274
1368
 
1275
1369
  // 2. Click: Alt+Click or Element mode click selects element and opens editor
1276
1370
  const onClick = (e: MouseEvent) => {
1277
- if (this.modalOverlay || this.cardOverlay) return;
1371
+ if (this.isHidden || this.modalOverlay || this.cardOverlay) return;
1278
1372
 
1279
1373
  // If clicking inside inspector toolbar or container, do NOT intercept
1280
1374
  const path = e.composedPath ? e.composedPath() : [];
@@ -1505,6 +1599,11 @@ export class NoteInspector {
1505
1599
  this.updateHighlight(targetEl);
1506
1600
  }
1507
1601
 
1602
+ const comp = noteType === 'element' ? resolveComponentSource(targetEl) : undefined;
1603
+ const compPrefix = comp?.componentName
1604
+ ? `🧬 <${comp.componentName}>${comp.sourceFile ? ' (' + comp.sourceFile + (comp.sourceLine ? ':' + comp.sourceLine : '') + ')' : ''} · `
1605
+ : '';
1606
+
1508
1607
  this.renderNoteModal({
1509
1608
  title: this.activeScenario
1510
1609
  ? `Step ${this.activeScenario.stepNumber}: ${this.activeScenario.title}`
@@ -1513,7 +1612,7 @@ export class NoteInspector {
1513
1612
  pillText:
1514
1613
  noteType === 'page'
1515
1614
  ? `Page Viewport: ${window.innerWidth} × ${window.innerHeight} px`
1516
- : `${getSemanticSelector(targetEl)} (${Math.round(targetEl.getBoundingClientRect().width)}×${Math.round(targetEl.getBoundingClientRect().height)}) · Viewport: ${window.innerWidth}×${window.innerHeight}`,
1615
+ : `${compPrefix}${getSemanticSelector(targetEl)} (${Math.round(targetEl.getBoundingClientRect().width)}×${Math.round(targetEl.getBoundingClientRect().height)}) · Viewport: ${window.innerWidth}×${window.innerHeight}`,
1517
1616
  onSave: async (message, action) => {
1518
1617
  let scenarioParam: { scenarioId?: string; stepNumber?: number; scenarioTitle?: string } | undefined;
1519
1618
 
@@ -1863,12 +1962,15 @@ export class NoteInspector {
1863
1962
  innerText = truncate(innerText, 200);
1864
1963
  }
1865
1964
 
1965
+ const componentSource = resolveComponentSource(el);
1966
+
1866
1967
  return {
1867
1968
  selector,
1868
1969
  tag: el.tagName.toLowerCase(),
1869
1970
  attributes,
1870
1971
  outerHTML,
1871
1972
  innerText,
1973
+ componentSource,
1872
1974
  parent: el.parentElement
1873
1975
  ? {
1874
1976
  selector: getSemanticSelector(el.parentElement),
@@ -0,0 +1,272 @@
1
+ import type { ComponentSourceInfo } from '../../../core/src/index.js';
2
+
3
+ /**
4
+ * Resolves framework component metadata, source file paths, line numbers,
5
+ * and component hierarchy directly from a live DOM element.
6
+ * Supports React (Fiber), Vue 2 & 3, Svelte, Custom Elements, and standard data attributes.
7
+ */
8
+ export function resolveComponentSource(el: HTMLElement): ComponentSourceInfo | undefined {
9
+ if (!el || typeof el !== 'object') return undefined;
10
+
11
+ // 1. Try React Fiber Resolution
12
+ const reactInfo = resolveReactComponent(el);
13
+ if (reactInfo) return reactInfo;
14
+
15
+ // 2. Try Vue (Vue 3 / Vue 2) Resolution
16
+ const vueInfo = resolveVueComponent(el);
17
+ if (vueInfo) return vueInfo;
18
+
19
+ // 3. Try Svelte Resolution
20
+ const svelteInfo = resolveSvelteComponent(el);
21
+ if (svelteInfo) return svelteInfo;
22
+
23
+ // 4. Try Web Component / Custom Element
24
+ if (el.tagName && el.tagName.includes('-')) {
25
+ return {
26
+ framework: 'web-component',
27
+ componentName: el.tagName.toLowerCase(),
28
+ hierarchy: [el.tagName.toLowerCase()],
29
+ };
30
+ }
31
+
32
+ // 5. Try Data Attribute Annotations (e.g. data-component, data-source-file)
33
+ const attrInfo = resolveDataAttributeComponent(el);
34
+ if (attrInfo) return attrInfo;
35
+
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * Extracts React Fiber debug source, owner, and hierarchy
41
+ */
42
+ function resolveReactComponent(el: HTMLElement): ComponentSourceInfo | undefined {
43
+ try {
44
+ const fiberKey = Object.keys(el).find(
45
+ (key) => key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')
46
+ );
47
+ if (!fiberKey) return undefined;
48
+
49
+ const hostFiber = (el as any)[fiberKey];
50
+ if (!hostFiber) return undefined;
51
+
52
+ let sourceFile: string | undefined;
53
+ let sourceLine: number | undefined;
54
+ let sourceColumn: number | undefined;
55
+ let componentName: string | undefined;
56
+ const hierarchy: string[] = [];
57
+ let props: Record<string, any> | undefined;
58
+
59
+ // Check host fiber debugSource first (often populated by JSX transform)
60
+ if (hostFiber._debugSource) {
61
+ sourceFile = hostFiber._debugSource.fileName;
62
+ sourceLine = hostFiber._debugSource.lineNumber;
63
+ sourceColumn = hostFiber._debugSource.columnNumber;
64
+ }
65
+
66
+ // Check _debugOwner if available
67
+ if (hostFiber._debugOwner) {
68
+ const ownerType = hostFiber._debugOwner.type;
69
+ componentName = getReactComponentName(ownerType);
70
+ if (!sourceFile && hostFiber._debugOwner._debugSource) {
71
+ sourceFile = hostFiber._debugOwner._debugSource.fileName;
72
+ sourceLine = hostFiber._debugOwner._debugSource.lineNumber;
73
+ sourceColumn = hostFiber._debugOwner._debugSource.columnNumber;
74
+ }
75
+ }
76
+
77
+ // Walk up fiber return tree to discover composite components and hierarchy
78
+ let curr = hostFiber;
79
+ while (curr) {
80
+ const type = curr.type;
81
+ const name = getReactComponentName(type);
82
+
83
+ if (name && !name.startsWith('html:') && name !== 'Fragment') {
84
+ if (!componentName) {
85
+ componentName = name;
86
+ }
87
+ if (!hierarchy.includes(name)) {
88
+ hierarchy.unshift(name);
89
+ }
90
+
91
+ // Check if composite component has debug source
92
+ if (!sourceFile && curr._debugSource) {
93
+ sourceFile = curr._debugSource.fileName;
94
+ sourceLine = curr._debugSource.lineNumber;
95
+ sourceColumn = curr._debugSource.columnNumber;
96
+ }
97
+
98
+ // Capture safe props from the nearest custom component
99
+ if (!props && curr.memoizedProps && typeof curr.memoizedProps === 'object') {
100
+ props = sanitizeProps(curr.memoizedProps);
101
+ }
102
+ }
103
+
104
+ curr = curr.return;
105
+ }
106
+
107
+ if (!componentName && !sourceFile) {
108
+ return undefined;
109
+ }
110
+
111
+ return {
112
+ framework: 'react',
113
+ componentName,
114
+ sourceFile: normalizeFilePath(sourceFile),
115
+ sourceLine,
116
+ sourceColumn,
117
+ hierarchy: hierarchy.length > 0 ? hierarchy : componentName ? [componentName] : undefined,
118
+ props,
119
+ };
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ function getReactComponentName(type: any): string | undefined {
126
+ if (!type) return undefined;
127
+ if (typeof type === 'string') return undefined; // Host DOM tag (div, span, button)
128
+ if (type.displayName) return type.displayName;
129
+ if (type.name) return type.name;
130
+ if (type.render?.displayName) return type.render.displayName;
131
+ if (type.render?.name) return type.render.name;
132
+ return undefined;
133
+ }
134
+
135
+ /**
136
+ * Extracts Vue 3 and Vue 2 component instances and file paths
137
+ */
138
+ function resolveVueComponent(el: HTMLElement): ComponentSourceInfo | undefined {
139
+ try {
140
+ // Vue 3
141
+ const vueParent = (el as any).__vueParentComponent || (el as any).__vnode?.ctx;
142
+ if (vueParent) {
143
+ const type = vueParent.type || {};
144
+ const componentName = type.name || type.__name || type.displayName || 'AnonymousComponent';
145
+ const sourceFile = type.__file;
146
+ const hierarchy: string[] = [];
147
+
148
+ let curr = vueParent;
149
+ while (curr) {
150
+ const cType = curr.type || {};
151
+ const cName = cType.name || cType.__name || cType.displayName;
152
+ if (cName && !hierarchy.includes(cName)) {
153
+ hierarchy.unshift(cName);
154
+ }
155
+ curr = curr.parent;
156
+ }
157
+
158
+ return {
159
+ framework: 'vue',
160
+ componentName,
161
+ sourceFile: normalizeFilePath(sourceFile),
162
+ hierarchy: hierarchy.length > 0 ? hierarchy : [componentName],
163
+ props: vueParent.props ? sanitizeProps(vueParent.props) : undefined,
164
+ };
165
+ }
166
+
167
+ // Vue 2
168
+ const vue2Instance = (el as any).__vue__;
169
+ if (vue2Instance) {
170
+ const options = vue2Instance.$options || {};
171
+ const componentName = options.name || options._componentTag || 'VueComponent';
172
+ const sourceFile = options.__file;
173
+
174
+ return {
175
+ framework: 'vue',
176
+ componentName,
177
+ sourceFile: normalizeFilePath(sourceFile),
178
+ hierarchy: [componentName],
179
+ props: vue2Instance.$props ? sanitizeProps(vue2Instance.$props) : undefined,
180
+ };
181
+ }
182
+
183
+ return undefined;
184
+ } catch {
185
+ return undefined;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Extracts Svelte __svelte_meta debug locations
191
+ */
192
+ function resolveSvelteComponent(el: HTMLElement): ComponentSourceInfo | undefined {
193
+ try {
194
+ let curr: HTMLElement | null = el;
195
+ while (curr) {
196
+ const meta = (curr as any).__svelte_meta;
197
+ if (meta && meta.loc) {
198
+ return {
199
+ framework: 'svelte',
200
+ sourceFile: normalizeFilePath(meta.loc.file),
201
+ sourceLine: meta.loc.line,
202
+ sourceColumn: meta.loc.column,
203
+ componentName: meta.loc.file ? getBaseNameWithoutExt(meta.loc.file) : undefined,
204
+ hierarchy: meta.loc.file ? [getBaseNameWithoutExt(meta.loc.file)] : undefined,
205
+ };
206
+ }
207
+ curr = curr.parentElement;
208
+ }
209
+ return undefined;
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Resolves components from standard data attributes (e.g. data-component="UserCard")
217
+ */
218
+ function resolveDataAttributeComponent(el: HTMLElement): ComponentSourceInfo | undefined {
219
+ try {
220
+ const compEl = el.closest('[data-component], [data-component-name], [data-source-file]');
221
+ if (!compEl) return undefined;
222
+
223
+ const componentName =
224
+ compEl.getAttribute('data-component') || compEl.getAttribute('data-component-name') || undefined;
225
+ const sourceFile = compEl.getAttribute('data-source-file') || undefined;
226
+ const lineAttr = compEl.getAttribute('data-source-line');
227
+ const sourceLine = lineAttr ? parseInt(lineAttr, 10) : undefined;
228
+
229
+ if (!componentName && !sourceFile) return undefined;
230
+
231
+ return {
232
+ framework: 'vanilla',
233
+ componentName,
234
+ sourceFile: normalizeFilePath(sourceFile),
235
+ sourceLine: isNaN(sourceLine as number) ? undefined : sourceLine,
236
+ hierarchy: componentName ? [componentName] : undefined,
237
+ };
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ }
242
+
243
+ function normalizeFilePath(filePath?: string): string | undefined {
244
+ if (!filePath) return undefined;
245
+ // Strip query strings or vite bundle hashes (e.g. /App.tsx?t=123)
246
+ const cleaned = filePath.split('?')[0];
247
+ return cleaned;
248
+ }
249
+
250
+ function getBaseNameWithoutExt(filePath: string): string {
251
+ const parts = filePath.split('/');
252
+ const last = parts[parts.length - 1] || filePath;
253
+ return last.split('.')[0] || last;
254
+ }
255
+
256
+ function sanitizeProps(props: Record<string, any>): Record<string, any> {
257
+ const result: Record<string, any> = {};
258
+ for (const [key, value] of Object.entries(props)) {
259
+ if (key.startsWith('__') || typeof value === 'function') continue;
260
+
261
+ if (value === null || value === undefined) {
262
+ result[key] = value;
263
+ } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
264
+ result[key] = value;
265
+ } else if (Array.isArray(value)) {
266
+ result[key] = `Array(${value.length})`;
267
+ } else if (typeof value === 'object') {
268
+ result[key] = '[Object]';
269
+ }
270
+ }
271
+ return result;
272
+ }
@@ -1,3 +1,5 @@
1
+ import type { ComponentSourceInfo } from './notes.js';
2
+
1
3
  export type EventType =
2
4
  | 'runtime_error'
3
5
  | 'unhandled_rejection'
@@ -17,6 +19,7 @@ export interface ElementSummary {
17
19
  attributes?: Record<string, string>;
18
20
  outerHTML?: string;
19
21
  innerText?: string;
22
+ componentSource?: ComponentSourceInfo;
20
23
  boundingRect?: {
21
24
  x: number;
22
25
  y: number;
@@ -39,12 +39,23 @@ export interface NoteTarget {
39
39
  confidence?: 'high' | 'medium' | 'low';
40
40
  }
41
41
 
42
+ export interface ComponentSourceInfo {
43
+ framework?: 'react' | 'vue' | 'svelte' | 'web-component' | 'vanilla';
44
+ componentName?: string;
45
+ sourceFile?: string;
46
+ sourceLine?: number;
47
+ sourceColumn?: number;
48
+ hierarchy?: string[];
49
+ props?: Record<string, any>;
50
+ }
51
+
42
52
  export interface ElementContext {
43
53
  selector: string;
44
54
  tag: string;
45
55
  attributes?: Record<string, string>;
46
56
  outerHTML?: string;
47
57
  innerText?: string;
58
+ componentSource?: ComponentSourceInfo;
48
59
  parent?: {
49
60
  selector: string;
50
61
  tag: string;
@@ -104,6 +104,7 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
104
104
  visible: incident.lastElement.visible,
105
105
  innerText: incident.lastElement.innerText,
106
106
  outerHTML: incident.lastElement.outerHTML,
107
+ componentSource: incident.lastElement.componentSource,
107
108
  }
108
109
  : undefined,
109
110
  recentBreadcrumbs: breadcrumbsTimeline,