diff --git a/app/chrome-extension/common/message-types.ts b/app/chrome-extension/common/message-types.ts
index cf93fce..fb6cd99 100644
--- a/app/chrome-extension/common/message-types.ts
+++ b/app/chrome-extension/common/message-types.ts
@@ -107,6 +107,8 @@ export const TOOL_MESSAGE_TYPES = {
// Record & Replay content script bridge
RR_RECORDER_CONTROL: 'rr_recorder_control',
RR_RECORDER_EVENT: 'rr_recorder_event',
+ // Record & Replay timeline feed (background -> content overlay)
+ RR_TIMELINE_UPDATE: 'rr_timeline_update',
// DOM observer trigger bridge
SET_DOM_TRIGGERS: 'set_dom_triggers',
DOM_TRIGGER_FIRED: 'dom_trigger_fired',
diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts
index 60a26c9..d478ac1 100644
--- a/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts
+++ b/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts
@@ -1,4 +1,4 @@
-import { appendSteps, addNavigationStep } from './flow-builder';
+import { addNavigationStep } from './flow-builder';
import { STEP_TYPES } from '@/common/step-types';
import { ensureRecorderInjected, broadcastControlToTab, REC_CMD } from './content-injection';
import type { RecordingSessionManager } from './session-manager';
@@ -23,7 +23,7 @@ export function initBrowserEventListeners(session: RecordingSessionManager): voi
type: STEP_TYPES.SWITCH_TAB,
...(url ? { urlContains: url } : {}),
};
- appendSteps(flow, [step]);
+ session.appendSteps([step]);
} catch (e) {
console.warn('onActivated handler failed', e);
}
diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts
index 370dc93..80f4315 100644
--- a/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts
+++ b/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts
@@ -1,5 +1,6 @@
import type { Flow, Step } from '../types';
import { STEP_TYPES } from '@/common/step-types';
+import { recordingSession } from './session-manager';
const WORKFLOW_VERSION = 1;
@@ -47,6 +48,14 @@ export function appendSteps(flow: Flow, steps: Step[]): void {
}
export function addNavigationStep(flow: Flow, url: string): void {
- const step = { id: generateStepId(), type: STEP_TYPES.NAVIGATE, url };
+ const step: Step = { id: generateStepId(), type: STEP_TYPES.NAVIGATE, url } as Step;
+ try {
+ // Prefer centralized session append (single broadcast path) when active and matching flow
+ const sessFlow = recordingSession.getFlow?.();
+ if (recordingSession.getStatus?.() === 'recording' && sessFlow === flow) {
+ recordingSession.appendSteps([step]);
+ return;
+ }
+ } catch {}
appendSteps(flow, [step]);
}
diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts
index d7b2018..0593f47 100644
--- a/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts
+++ b/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts
@@ -54,12 +54,14 @@ class RecorderManagerImpl {
async stop(): Promise<{ success: boolean; error?: string; flow?: Flow }> {
if (session.getStatus() === 'idle' || !session.getFlow())
return { success: false, error: 'No active recording' };
- // Best-effort STOP for all tabs that participated in this session
+ // Capture tabs first, then synchronously mark session as stopped
+ const tabs = session.getActiveTabs();
+ const flow = await session.stopSession();
+ // After state becomes idle, no further START should be triggered by listeners
+ // Best-effort STOP broadcast to remove overlays in all participating tabs
try {
- const tabs = session.getActiveTabs();
await Promise.all(tabs.map(async (id) => broadcastControlToTab(id, REC_CMD.STOP)));
} catch {}
- const flow = await session.stopSession();
if (flow) await saveFlow(flow);
return flow ? { success: true, flow } : { success: true };
}
diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts
index 6fa59ac..2fec580 100644
--- a/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts
+++ b/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts
@@ -1,4 +1,6 @@
import type { Flow, Step } from '../types';
+import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
+import { appendSteps as appendFlowSteps } from './flow-builder';
export type RecordingStatus = 'idle' | 'recording' | 'paused';
@@ -81,12 +83,23 @@ export class RecordingSessionManager {
appendSteps(steps: Step[]): void {
const f = this.state.flow;
if (!f || !Array.isArray(steps) || steps.length === 0) return;
- for (const st of steps) {
- // id should be ensured by builder; push directly
- f.steps.push(st);
- }
+ // Reuse builder implementation (ids + meta update) for consistency
+ appendFlowSteps(f, steps);
+ this.broadcastTimelineUpdate(steps);
+ }
+
+ // Broadcast timeline update to origin tab top-frame
+ broadcastTimelineUpdate(steps: Step[]): void {
try {
- (f.meta as any).updatedAt = new Date().toISOString();
+ if (!steps || steps.length === 0) return;
+ const origin = this.state.originTabId;
+ if (origin != null) {
+ chrome.tabs.sendMessage(
+ origin,
+ { action: TOOL_MESSAGE_TYPES.RR_TIMELINE_UPDATE, steps },
+ { frameId: 0 },
+ );
+ }
} catch {}
}
}
diff --git a/app/chrome-extension/inject-scripts/recorder.js b/app/chrome-extension/inject-scripts/recorder.js
index 3210ac9..49be188 100644
--- a/app/chrome-extension/inject-scripts/recorder.js
+++ b/app/chrome-extension/inject-scripts/recorder.js
@@ -13,6 +13,7 @@
BATCH_SEND_MS: 80,
SCROLL_DEBOUNCE_MS: 350,
SENSITIVE_INPUT_TYPES: new Set(['password']),
+ UI_MAX_STEPS: 30,
};
// Memoization caches for selector computations during recording
@@ -151,6 +152,11 @@
constructor(recorder) {
this.recorder = recorder;
this._box = null;
+ // Timeline elements state
+ this._timeline = null;
+ this._count = 0;
+ this._timelineBox = null;
+ this._collapsed = false;
}
ensure() {
const rec = this.recorder;
@@ -175,14 +181,52 @@
+
`;
document.documentElement.appendChild(root);
+ // Build timeline container just below the panel
+ try {
+ const timeline = document.createElement('div');
+ timeline.id = '__rr_rec_timeline';
+ Object.assign(timeline.style, {
+ marginTop: '8px',
+ width: '360px',
+ maxHeight: '220px',
+ overflow: 'auto',
+ background: 'rgba(17,24,39,0.85)',
+ color: '#F9FAFB',
+ border: '1px solid rgba(255,255,255,0.2)',
+ borderRadius: '8px',
+ boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
+ padding: '8px 10px',
+ fontSize: '12px',
+ lineHeight: '1.4',
+ });
+ const header = document.createElement('div');
+ header.textContent = '已录制步骤';
+ header.style.opacity = '0.8';
+ header.style.marginBottom = '4px';
+ const list = document.createElement('ol');
+ list.id = '__rr_rec_timeline_list';
+ list.style.listStyle = 'none';
+ list.style.margin = '0';
+ list.style.padding = '0';
+ list.style.display = 'flex';
+ list.style.flexDirection = 'column';
+ list.style.gap = '4px';
+ timeline.appendChild(header);
+ timeline.appendChild(list);
+ root.appendChild(timeline);
+ this._timeline = list;
+ this._timelineBox = timeline;
+ } catch {}
const btnPause = root.querySelector('#__rr_pause');
const btnStop = root.querySelector('#__rr_stop');
const hideChk = root.querySelector('#__rr_hide_values');
const highlightChk = root.querySelector('#__rr_enable_highlight');
+ const btnToggle = root.querySelector('#__rr_toggle_timeline');
hideChk.checked = !!rec.hideInputValues;
hideChk.addEventListener('change', () => (rec.hideInputValues = hideChk.checked));
highlightChk.checked = !!rec.highlightEnabled;
@@ -190,11 +234,22 @@
rec.highlightEnabled = !!highlightChk.checked;
rec._updateHoverListener();
});
+ if (btnToggle) {
+ btnToggle.addEventListener('click', () => {
+ this._collapsed = !this._collapsed;
+ if (this._timelineBox)
+ this._timelineBox.style.display = this._collapsed ? 'none' : 'block';
+ btnToggle.textContent = this._collapsed ? '展开' : '折叠';
+ });
+ }
btnPause.addEventListener('click', () => {
if (!rec.isPaused) rec.pause();
else rec.resume();
});
btnStop.addEventListener('click', () => {
+ try {
+ chrome.runtime.sendMessage({ type: 'rr_stop_recording' });
+ } catch {}
rec.stop();
});
this._box = document.createElement('div');
@@ -216,6 +271,8 @@
const root = document.getElementById('__rr_rec_overlay');
if (root) root.remove();
if (this._box) this._box.remove();
+ this._timeline = null;
+ this._timelineBox = null;
}
} catch {}
}
@@ -225,6 +282,77 @@
if (badge) badge.textContent = this.recorder.isPaused ? '已暂停' : '录制中';
if (pauseBtn) pauseBtn.textContent = this.recorder.isPaused ? '继续' : '暂停';
}
+
+ // Reset the timeline list content
+ resetTimeline() {
+ this._count = 0;
+ const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
+ if (list) list.innerHTML = '';
+ }
+
+ // Append a new recorded step into the timeline UI
+ appendStep(step) {
+ const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
+ if (!list) return;
+ this._count += 1;
+ const item = document.createElement('li');
+ const text = this._formatStepText(step, this._count);
+ item.setAttribute('data-step-id', step.id || '');
+ item.style.display = 'flex';
+ item.style.alignItems = 'flex-start';
+ item.style.gap = '6px';
+ item.innerHTML = `
+ ${this._count}.
+ ${text}
+ `;
+ list.appendChild(item);
+ while (list.children.length > CONFIG.UI_MAX_STEPS) {
+ list.removeChild(list.firstChild);
+ }
+ const container = list.parentElement;
+ if (container) container.scrollTop = container.scrollHeight;
+ }
+
+ // Update the last timeline entry when a step is coalesced/modified
+ updateLastStep(step) {
+ const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
+ if (!list) return;
+ const last = list.lastElementChild;
+ if (!last) return;
+ const text = this._formatStepText(step, this._count);
+ const spans = last.querySelectorAll('span');
+ if (spans && spans[1]) spans[1].textContent = text;
+ }
+
+ // Create a short, human-readable text for a recorded step
+ _formatStepText(step, _idx) {
+ try {
+ if (!step || typeof step !== 'object') return '未知步骤';
+ const t = step.type;
+ const sel = step.target && step.target.selector ? step.target.selector : '';
+ if (t === 'click' || t === 'dblclick') {
+ return `${t === 'dblclick' ? '双击' : '点击'}: ${sel || '(document)'}`;
+ }
+ if (t === 'fill') {
+ const val = step.value;
+ const shown = typeof val === 'string' && val.length > 0 ? val : String(val);
+ return `输入: ${sel} = ${shown}`;
+ }
+ if (t === 'scroll') {
+ const mode = step.mode === 'container' ? '容器' : '页面';
+ const off = step.offset || {};
+ return `滚动(${mode}): y=${off.y ?? 0}, x=${off.x ?? 0}`;
+ }
+ if (t === 'openTab') return `打开标签页: ${step.url || ''}`;
+ if (t === 'switchTab') return `切换标签页: 包含 ${step.urlContains || ''}`;
+ if (t === 'switchFrame')
+ return `切换Frame: 包含 ${step.frame && step.frame.urlContains ? step.frame.urlContains : ''}`;
+ if (t === 'waitFor') return `等待: ${sel || step.until || ''}`;
+ return `${t}`;
+ } catch (_) {
+ return '步骤';
+ }
+ }
}
// ================================================================
@@ -266,6 +394,7 @@
this.isPaused = false;
this._attach();
this.ui.ensure();
+ this.ui.resetTimeline();
this._send({ kind: 'start', flow: this.pendingFlow });
}
@@ -363,7 +492,8 @@
try {
if (window !== window.top && !this.frameSwitchPushed) {
const href = String(location && location.href ? location.href : '');
- this.pendingFlow.steps.push({ type: 'switchFrame', frame: { urlContains: href } });
+ const frameStep = { type: 'switchFrame', frame: { urlContains: href } };
+ this.pendingFlow.steps.push(frameStep);
this.frameSwitchPushed = true;
}
} catch {}
@@ -661,6 +791,17 @@
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
try {
if (!request || !request.action) return false;
+ if (request.action === 'rr_timeline_update') {
+ const rec = getRecorder();
+ rec.ui.ensure();
+ if (rec.isRecording && rec.pendingFlow && (!rec.ui._timeline || rec.ui._count === 0)) {
+ // no-op; rely purely on updates per design
+ }
+ const steps = Array.isArray(request.steps) ? request.steps : [];
+ for (const st of steps) rec.ui.appendStep(st);
+ sendResponse({ ok: true });
+ return true;
+ }
if (request.action === 'rr_recorder_control') {
const rec = getRecorder();
const cmd = request.cmd;