browsertrack 0.1.2 → 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 (52) hide show
  1. package/AGENTS.md +6 -2
  2. package/dist/{chunk-ILRYKMME.js → chunk-3HOXPTM2.js} +97 -3
  3. package/dist/chunk-3HOXPTM2.js.map +1 -0
  4. package/dist/{chunk-G2Y3CXCY.js → chunk-464D4U2U.js} +86 -3
  5. package/dist/chunk-464D4U2U.js.map +1 -0
  6. package/dist/{chunk-SPCIROIU.js → chunk-7OCOQGDN.js} +24 -5
  7. package/dist/chunk-7OCOQGDN.js.map +1 -0
  8. package/dist/{chunk-SKCMT2DE.js → chunk-INXDWPJW.js} +806 -160
  9. package/dist/chunk-INXDWPJW.js.map +1 -0
  10. package/dist/cli/index.js +202 -6
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/client/index.cjs +809 -161
  13. package/dist/client/index.d.ts +62 -11
  14. package/dist/client/index.js +7 -3
  15. package/dist/client.iife.js +206 -27
  16. package/dist/{notes-CBvN91Wf.d.ts → commands-fjuqKzkm.d.ts} +125 -91
  17. package/dist/core/index.d.ts +2 -2
  18. package/dist/daemon/index.d.ts +3 -3
  19. package/dist/daemon/index.js +2 -2
  20. package/dist/{engine-CeT9URuN.d.ts → engine-CmchnMDq.d.ts} +13 -2
  21. package/dist/index.d.ts +4 -4
  22. package/dist/index.js +4 -4
  23. package/dist/mcp/index.d.ts +4 -4
  24. package/dist/mcp/index.js +2 -2
  25. package/dist/{projects-CY8ungMt.d.ts → projects-DB7S312i.d.ts} +1 -1
  26. package/dist/{server-Dd8NX2Mk.d.ts → server-DiVmTrIR.d.ts} +1 -1
  27. package/docs/component-resolver.md +108 -0
  28. package/docs/index.md +3 -1
  29. package/docs/mcp-reference.md +15 -2
  30. package/docs/scenarios-flows.md +86 -0
  31. package/docs/visual-notes.md +36 -0
  32. package/package.json +1 -1
  33. package/packages/client/src/client.ts +18 -1
  34. package/packages/client/src/config.ts +84 -1
  35. package/packages/client/src/index.ts +4 -1
  36. package/packages/client/src/interceptors/interaction.ts +3 -0
  37. package/packages/client/src/notes/inspector.ts +640 -166
  38. package/packages/client/src/source/resolver.ts +272 -0
  39. package/packages/core/src/types/events.ts +3 -0
  40. package/packages/core/src/types/notes.ts +36 -0
  41. package/packages/daemon/src/notes/engine.ts +6 -0
  42. package/packages/daemon/src/server/ws.ts +23 -1
  43. package/packages/daemon/src/storage/db.ts +106 -3
  44. package/packages/mcp/src/handlers.ts +59 -0
  45. package/packages/mcp/src/tools.ts +28 -0
  46. package/test/client/component-resolver.test.ts +141 -0
  47. package/test/client/interceptors.test.ts +120 -0
  48. package/test/daemon/scenario-storage.test.ts +158 -0
  49. package/dist/chunk-G2Y3CXCY.js.map +0 -1
  50. package/dist/chunk-ILRYKMME.js.map +0 -1
  51. package/dist/chunk-SKCMT2DE.js.map +0 -1
  52. package/dist/chunk-SPCIROIU.js.map +0 -1
@@ -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;
@@ -73,6 +84,9 @@ export interface VisualNote {
73
84
  region?: RegionContext;
74
85
  status: NoteStatus;
75
86
  incidentId?: string;
87
+ scenarioId?: string;
88
+ stepNumber?: number;
89
+ scenarioTitle?: string;
76
90
  screenshots?: {
77
91
  original?: string;
78
92
  after?: string;
@@ -82,6 +96,28 @@ export interface VisualNote {
82
96
  resolvedAt?: string;
83
97
  }
84
98
 
99
+ export interface ScenarioOverview {
100
+ id: string;
101
+ projectId: string;
102
+ title: string;
103
+ stepsCount: number;
104
+ status: NoteStatus;
105
+ route: string;
106
+ firstStepAt: string;
107
+ lastStepAt: string;
108
+ }
109
+
110
+ export interface ScenarioDetail {
111
+ id: string;
112
+ projectId: string;
113
+ title: string;
114
+ stepsCount: number;
115
+ status: NoteStatus;
116
+ steps: VisualNote[];
117
+ createdAt: string;
118
+ updatedAt: string;
119
+ }
120
+
85
121
  export interface NoteVerificationResult {
86
122
  noteId: string;
87
123
  status: NoteStatus;
@@ -27,6 +27,9 @@ export class NotesEngine {
27
27
  region?: any;
28
28
  screenshot?: string;
29
29
  incidentId?: string;
30
+ scenarioId?: string;
31
+ stepNumber?: number;
32
+ scenarioTitle?: string;
30
33
  }): VisualNote {
31
34
  const session = this.db.getSession(payload.sessionId);
32
35
  const projectId = session?.projectId || 'default';
@@ -56,6 +59,9 @@ export class NotesEngine {
56
59
  region: payload.region,
57
60
  status: 'OPEN',
58
61
  incidentId: payload.incidentId,
62
+ scenarioId: payload.scenarioId,
63
+ stepNumber: payload.stepNumber,
64
+ scenarioTitle: payload.scenarioTitle,
59
65
  screenshots: screenshotPath ? { original: screenshotPath } : undefined,
60
66
  createdAt: now,
61
67
  updatedAt: now,
@@ -134,10 +134,17 @@ export function setupWebSocketServer(
134
134
  region: data.region,
135
135
  screenshot: data.screenshot,
136
136
  incidentId: data.incidentId,
137
+ scenarioId: data.scenarioId,
138
+ stepNumber: data.stepNumber,
139
+ scenarioTitle: data.scenarioTitle,
137
140
  });
138
141
 
139
142
  if (verbose) {
140
- console.log(`[BrowserTrack] Visual note created: ${note.id} on ${note.route} ("${note.message}")`);
143
+ console.log(
144
+ `[BrowserTrack] Visual note created: ${note.id} on ${note.route} ("${note.message}")${
145
+ note.scenarioId ? ` [Scenario: ${note.scenarioTitle || note.scenarioId} Step ${note.stepNumber}]` : ''
146
+ }`
147
+ );
141
148
  }
142
149
 
143
150
  ws.send(
@@ -145,6 +152,8 @@ export function setupWebSocketServer(
145
152
  type: 'note_created_ack',
146
153
  noteId: note.id,
147
154
  status: note.status,
155
+ scenarioId: note.scenarioId,
156
+ stepNumber: note.stepNumber,
148
157
  })
149
158
  );
150
159
 
@@ -197,6 +206,19 @@ export function setupWebSocketServer(
197
206
  return;
198
207
  }
199
208
 
209
+ if (data.type === 'delete_scenario' && data.scenarioId) {
210
+ const scenario = db.getScenario(data.scenarioId);
211
+ if (scenario) {
212
+ db.deleteScenario(data.scenarioId);
213
+ const allNotes = db.listNotes({ projectId: scenario.projectId, limit: 100 });
214
+ sessionManager.broadcastToProject(scenario.projectId, {
215
+ type: 'notes_sync',
216
+ notes: allNotes,
217
+ });
218
+ }
219
+ return;
220
+ }
221
+
200
222
  if (data.type === 'get_notes') {
201
223
  const session = currentSessionId ? db.getSession(currentSessionId) : null;
202
224
  const projectId = data.projectId || session?.projectId;
@@ -15,6 +15,8 @@ import type {
15
15
  VisualNote,
16
16
  NoteStatus,
17
17
  NoteVerificationResult,
18
+ ScenarioOverview,
19
+ ScenarioDetail,
18
20
  } from '../../../core/src/index.js';
19
21
 
20
22
  export class StorageDB {
@@ -134,6 +136,9 @@ export class StorageDB {
134
136
  region_json TEXT,
135
137
  screenshot_path TEXT,
136
138
  incident_id TEXT,
139
+ scenario_id TEXT,
140
+ step_number INTEGER,
141
+ scenario_title TEXT,
137
142
  status TEXT DEFAULT 'OPEN',
138
143
  created_at TEXT,
139
144
  updated_at TEXT,
@@ -156,7 +161,19 @@ export class StorageDB {
156
161
  CREATE INDEX IF NOT EXISTS idx_incidents_project ON incidents(project_id, status);
157
162
  CREATE INDEX IF NOT EXISTS idx_incidents_fp ON incidents(fingerprint);
158
163
  CREATE INDEX IF NOT EXISTS idx_notes_project ON notes(project_id, status);
164
+ CREATE INDEX IF NOT EXISTS idx_notes_scenario ON notes(scenario_id, step_number);
159
165
  `);
166
+
167
+ // Schema migrations for scenario fields
168
+ try {
169
+ this.db.exec('ALTER TABLE notes ADD COLUMN scenario_id TEXT;');
170
+ } catch {}
171
+ try {
172
+ this.db.exec('ALTER TABLE notes ADD COLUMN step_number INTEGER;');
173
+ } catch {}
174
+ try {
175
+ this.db.exec('ALTER TABLE notes ADD COLUMN scenario_title TEXT;');
176
+ } catch {}
160
177
  }
161
178
 
162
179
  // --- PROJECTS ---
@@ -539,8 +556,8 @@ export class StorageDB {
539
556
  `INSERT INTO notes (
540
557
  id, project_id, session_id, type, message, route, url,
541
558
  viewport_json, scroll_json, target_json, element_context_json, region_json,
542
- screenshot_path, incident_id, status, created_at, updated_at, resolved_at
543
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
559
+ screenshot_path, incident_id, scenario_id, step_number, scenario_title, status, created_at, updated_at, resolved_at
560
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
544
561
  )
545
562
  .run(
546
563
  note.id,
@@ -557,6 +574,9 @@ export class StorageDB {
557
574
  note.region ? JSON.stringify(note.region) : null,
558
575
  note.screenshots?.original || null,
559
576
  note.incidentId || null,
577
+ note.scenarioId || null,
578
+ note.stepNumber ?? null,
579
+ note.scenarioTitle || null,
560
580
  note.status,
561
581
  note.createdAt,
562
582
  note.updatedAt,
@@ -570,7 +590,7 @@ export class StorageDB {
570
590
  return this.mapNoteRow(row);
571
591
  }
572
592
 
573
- public listNotes(options: { projectId?: string; status?: NoteStatus; route?: string; limit?: number } = {}): VisualNote[] {
593
+ public listNotes(options: { projectId?: string; status?: NoteStatus; route?: string; scenarioId?: string; limit?: number } = {}): VisualNote[] {
574
594
  let sql = 'SELECT * FROM notes WHERE 1=1';
575
595
  const params: any[] = [];
576
596
 
@@ -586,6 +606,10 @@ export class StorageDB {
586
606
  sql += ' AND route = ?';
587
607
  params.push(options.route);
588
608
  }
609
+ if (options.scenarioId) {
610
+ sql += ' AND scenario_id = ?';
611
+ params.push(options.scenarioId);
612
+ }
589
613
 
590
614
  sql += ' ORDER BY created_at DESC LIMIT ?';
591
615
  params.push(options.limit || 50);
@@ -599,6 +623,82 @@ export class StorageDB {
599
623
  this.db.prepare('DELETE FROM notes WHERE id = ?').run(id);
600
624
  }
601
625
 
626
+ // --- SCENARIOS (MULTI-STEP FLOWS) ---
627
+ public listScenarios(options: { projectId?: string; status?: NoteStatus; limit?: number } = {}): ScenarioOverview[] {
628
+ let sql = `
629
+ SELECT
630
+ scenario_id,
631
+ project_id,
632
+ COALESCE(MAX(scenario_title), 'Scenario') as title,
633
+ COUNT(id) as steps_count,
634
+ CASE WHEN SUM(CASE WHEN status != 'RESOLVED' THEN 1 ELSE 0 END) = 0 THEN 'RESOLVED' ELSE 'OPEN' END as status,
635
+ MIN(route) as route,
636
+ MIN(created_at) as first_step_at,
637
+ MAX(created_at) as last_step_at
638
+ FROM notes
639
+ WHERE scenario_id IS NOT NULL
640
+ `;
641
+ const params: any[] = [];
642
+ if (options.projectId) {
643
+ sql += ' AND (project_id = ? OR project_id IS NULL)';
644
+ params.push(options.projectId);
645
+ }
646
+ sql += ' GROUP BY scenario_id';
647
+
648
+ if (options.status) {
649
+ if (options.status === 'RESOLVED') {
650
+ sql += " HAVING SUM(CASE WHEN status != 'RESOLVED' THEN 1 ELSE 0 END) = 0";
651
+ } else {
652
+ sql += " HAVING SUM(CASE WHEN status != 'RESOLVED' THEN 1 ELSE 0 END) > 0";
653
+ }
654
+ }
655
+
656
+ sql += ' ORDER BY last_step_at DESC LIMIT ?';
657
+ params.push(options.limit || 50);
658
+
659
+ const rows = this.db.prepare(sql).all(...params) as any[];
660
+ return rows.map((r) => ({
661
+ id: r.scenario_id,
662
+ projectId: r.project_id,
663
+ title: r.title,
664
+ stepsCount: Number(r.steps_count),
665
+ status: r.status as NoteStatus,
666
+ route: r.route || '/',
667
+ firstStepAt: r.first_step_at,
668
+ lastStepAt: r.last_step_at,
669
+ }));
670
+ }
671
+
672
+ public getScenario(scenarioId: string): ScenarioDetail | null {
673
+ const rows = this.db
674
+ .prepare('SELECT * FROM notes WHERE scenario_id = ? ORDER BY COALESCE(step_number, 999999) ASC, created_at ASC')
675
+ .all(scenarioId) as any[];
676
+
677
+ if (!rows || rows.length === 0) return null;
678
+
679
+ const steps = rows.map((r) => this.mapNoteRow(r));
680
+ const title = steps.find((s) => s.scenarioTitle)?.scenarioTitle || `Scenario ${scenarioId}`;
681
+ const allResolved = steps.every((s) => s.status === 'RESOLVED');
682
+
683
+ return {
684
+ id: scenarioId,
685
+ projectId: steps[0].projectId,
686
+ title,
687
+ stepsCount: steps.length,
688
+ status: allResolved ? 'RESOLVED' : 'OPEN',
689
+ steps,
690
+ createdAt: steps[0].createdAt,
691
+ updatedAt: steps[steps.length - 1].updatedAt,
692
+ };
693
+ }
694
+
695
+ public deleteScenario(scenarioId: string): void {
696
+ const notes = this.db.prepare('SELECT id FROM notes WHERE scenario_id = ?').all(scenarioId) as any[];
697
+ for (const n of notes) {
698
+ this.deleteNote(n.id);
699
+ }
700
+ }
701
+
602
702
  public updateNoteStatus(id: string, status: NoteStatus): void {
603
703
  const now = new Date().toISOString();
604
704
  const resolvedAt = status === 'RESOLVED' ? now : null;
@@ -725,6 +825,9 @@ export class StorageDB {
725
825
  region: row.region_json ? JSON.parse(row.region_json) : undefined,
726
826
  status: row.status as NoteStatus,
727
827
  incidentId: row.incident_id || undefined,
828
+ scenarioId: row.scenario_id || undefined,
829
+ stepNumber: row.step_number != null ? Number(row.step_number) : undefined,
830
+ scenarioTitle: row.scenario_title || undefined,
728
831
  screenshots: row.screenshot_path
729
832
  ? {
730
833
  original: row.screenshot_path,
@@ -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,
@@ -252,6 +253,7 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
252
253
  case 'list_notes': {
253
254
  const notes = db.listNotes({
254
255
  projectId: args.projectId,
256
+ scenarioId: args.scenarioId,
255
257
  status: args.status,
256
258
  limit: args.limit || 20,
257
259
  });
@@ -263,6 +265,9 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
263
265
  type: n.type,
264
266
  status: n.status,
265
267
  route: n.route,
268
+ scenarioId: n.scenarioId,
269
+ stepNumber: n.stepNumber,
270
+ scenarioTitle: n.scenarioTitle,
266
271
  viewport: `${n.viewport.width} × ${n.viewport.height}`,
267
272
  target: n.target?.selector || n.type,
268
273
  message: n.message,
@@ -272,6 +277,57 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
272
277
  };
273
278
  }
274
279
 
280
+ case 'list_scenarios': {
281
+ const scenarios = db.listScenarios({
282
+ projectId: args.projectId,
283
+ status: args.status,
284
+ limit: args.limit || 20,
285
+ });
286
+
287
+ return {
288
+ total: scenarios.length,
289
+ scenarios: scenarios.map((s) => ({
290
+ id: s.id,
291
+ title: s.title,
292
+ stepsCount: s.stepsCount,
293
+ status: s.status,
294
+ route: s.route,
295
+ firstStepAt: s.firstStepAt,
296
+ lastStepAt: s.lastStepAt,
297
+ })),
298
+ };
299
+ }
300
+
301
+ case 'get_scenario': {
302
+ const scenario = db.getScenario(args.scenarioId);
303
+ if (!scenario) {
304
+ throw new Error(`Scenario '${args.scenarioId}' not found.`);
305
+ }
306
+
307
+ return {
308
+ id: scenario.id,
309
+ title: scenario.title,
310
+ stepsCount: scenario.stepsCount,
311
+ status: scenario.status,
312
+ steps: scenario.steps.map((step) => ({
313
+ id: step.id,
314
+ stepNumber: step.stepNumber,
315
+ type: step.type,
316
+ message: step.message,
317
+ route: step.route,
318
+ url: step.url,
319
+ targetSelector: step.target?.selector,
320
+ boundingRect: step.target?.boundingRect || step.region,
321
+ elementContext: step.elementContext,
322
+ screenshot: step.screenshots?.original,
323
+ status: step.status,
324
+ createdAt: step.createdAt,
325
+ })),
326
+ createdAt: scenario.createdAt,
327
+ updatedAt: scenario.updatedAt,
328
+ };
329
+ }
330
+
275
331
  case 'get_note': {
276
332
  const note = db.getNote(args.noteId);
277
333
  if (!note) {
@@ -285,6 +341,9 @@ export async function handleToolCall(name: string, args: any, ctx: McpContext):
285
341
  type: note.type,
286
342
  status: note.status,
287
343
  message: note.message,
344
+ scenarioId: note.scenarioId,
345
+ stepNumber: note.stepNumber,
346
+ scenarioTitle: note.scenarioTitle,
288
347
  route: note.route,
289
348
  url: note.url,
290
349
  viewport: note.viewport,