feat(bd-1dq): Export Session Replay

Add export/import functionality for session replay:

**Export Features:**
- Export as shareable link (base64 encoded events in URL parameter)
- Export as .fabric-replay file (JSON format)
- Share button in replay controls with dropdown menu

**Import Features:**
- Import from .fabric-replay file via file picker
- Import from URL parameter (?replay=base64EncodedEvents)
- Automatic detection and loading of replay data from URL

**Implementation:**
- src/utils/replayExport.ts: Core export/import utilities for Node.js
- src/web/frontend/src/utils/replayExport.ts: Browser-specific utilities
- Updated web SessionReplay.tsx with export dropdown and status messages
- Updated TUI SessionReplay.ts with exportToFile/importFromFile methods
- Added session replay panel to App.tsx with URL parameter support
- Added CSS styles for export dropdown, status messages, and replay panel

**Export Format:**
```json
{
  "version": "1.0",
  "exportedAt": 1709337600,
  "eventCount": 150,
  "events": [...],
  "metadata": {
    "sessionStart": 1709337000,
    "sessionEnd": 1709337600,
    "workerCount": 3
  }
}
```

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-07 05:11:13 +00:00
parent 4d6aa15c13
commit 7ddbd7820b
6 changed files with 1245 additions and 0 deletions

View file

@ -565,6 +565,176 @@ export class SessionReplay extends EventEmitter {
this.parent.render();
}
/**
* Get all events (for export)
*/
getEvents(): LogEvent[] {
return [...this.events];
}
/**
* Get filtered events (for export)
*/
getFilteredEvents(): LogEvent[] {
return [...this.filteredEvents];
}
/**
* Export session to file
*/
exportToFile(filePath?: string): string {
const eventsToExport = this.filteredEvents.length > 0 ? this.filteredEvents : this.events;
if (eventsToExport.length === 0) {
throw new Error('No events to export');
}
// Calculate metadata
const timestamps = eventsToExport.map(e => e.ts);
const sessionStart = Math.min(...timestamps);
const sessionEnd = Math.max(...timestamps);
const workers = new Set(eventsToExport.map(e => e.worker));
const exportData = {
version: '1.0',
exportedAt: Date.now(),
eventCount: eventsToExport.length,
events: eventsToExport,
metadata: {
sessionStart,
sessionEnd,
workerCount: workers.size,
sourcePath: this.sourcePath || undefined,
},
};
// Generate filename if not provided
const exportPath = filePath || this.generateExportFilename(exportData.metadata);
fs.writeFileSync(exportPath, JSON.stringify(exportData, null, 2), 'utf-8');
return exportPath;
}
/**
* Import session from file
*/
importFromFile(filePath: string): { eventCount: number; metadata: object } {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const content = fs.readFileSync(filePath, 'utf-8');
const importData = JSON.parse(content);
// Validate structure
if (!importData.version || !Array.isArray(importData.events)) {
throw new Error('Invalid replay export format');
}
// Validate events have required fields
for (const event of importData.events) {
if (typeof event.ts !== 'number' || !event.worker || !event.msg) {
throw new Error('Invalid event format in export');
}
}
// Load events
this.sourcePath = filePath;
this.events = importData.events;
this.applyFilter();
this.currentIndex = 0;
this.state = 'idle';
this.logBox.setContent('');
this.updateDisplay();
this.emit('loaded', this.events.length);
this.emit('imported', { eventCount: importData.eventCount, metadata: importData.metadata });
return {
eventCount: importData.eventCount,
metadata: importData.metadata,
};
}
/**
* Export to base64 string (for sharing)
*/
exportToBase64(): string {
const eventsToExport = this.filteredEvents.length > 0 ? this.filteredEvents : this.events;
if (eventsToExport.length === 0) {
throw new Error('No events to export');
}
// Calculate metadata
const timestamps = eventsToExport.map(e => e.ts);
const sessionStart = Math.min(...timestamps);
const sessionEnd = Math.max(...timestamps);
const workers = new Set(eventsToExport.map(e => e.worker));
const exportData = {
version: '1.0',
exportedAt: Date.now(),
eventCount: eventsToExport.length,
events: eventsToExport,
metadata: {
sessionStart,
sessionEnd,
workerCount: workers.size,
sourcePath: this.sourcePath || undefined,
},
};
const jsonString = JSON.stringify(exportData);
return Buffer.from(jsonString, 'utf-8').toString('base64');
}
/**
* Import from base64 string
*/
importFromBase64(base64String: string): { eventCount: number; metadata: object } {
const jsonString = Buffer.from(base64String, 'base64').toString('utf-8');
const importData = JSON.parse(jsonString);
// Validate structure
if (!importData.version || !Array.isArray(importData.events)) {
throw new Error('Invalid replay export format');
}
// Validate events have required fields
for (const event of importData.events) {
if (typeof event.ts !== 'number' || !event.worker || !event.msg) {
throw new Error('Invalid event format in export');
}
}
// Load events
this.sourcePath = '[imported]';
this.events = importData.events;
this.applyFilter();
this.currentIndex = 0;
this.state = 'idle';
this.logBox.setContent('');
this.updateDisplay();
this.emit('loaded', this.events.length);
this.emit('imported', { eventCount: importData.eventCount, metadata: importData.metadata });
return {
eventCount: importData.eventCount,
metadata: importData.metadata,
};
}
/**
* Generate export filename
*/
private generateExportFilename(metadata: { sessionStart: number }): string {
const date = new Date(metadata.sessionStart);
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD
const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS
return `session-${dateStr}-${timeStr}.fabric-replay`;
}
/**
* Clean up resources
*/

388
src/utils/replayExport.ts Normal file
View file

@ -0,0 +1,388 @@
/**
* Session Replay Export/Import Utilities
*
* Provides functionality for exporting and importing session replay data
* as shareable links or .fabric-replay files.
*/
import { LogEvent } from '../types.js';
/**
* Version of the export format
*/
export const REPLAY_EXPORT_VERSION = '1.0';
/**
* Metadata for the exported session
*/
export interface ReplayExportMetadata {
/** Unix timestamp of session start */
sessionStart: number;
/** Unix timestamp of session end */
sessionEnd: number;
/** Number of unique workers */
workerCount: number;
/** Optional source file path */
sourcePath?: string;
/** Optional description */
description?: string;
}
/**
* Export format for session replay
*/
export interface ReplayExport {
/** Format version */
version: string;
/** Unix timestamp when exported */
exportedAt: number;
/** Number of events in the export */
eventCount: number;
/** The events to replay */
events: LogEvent[];
/** Metadata about the session */
metadata: ReplayExportMetadata;
}
/**
* Convert web LogEvent format to core LogEvent format
*/
export interface WebLogEvent {
timestamp: string;
level: 'debug' | 'info' | 'warn' | 'error';
worker: string;
tool?: string;
message: string;
raw: string;
bead?: string;
}
/**
* Create a replay export from events
*/
export function createReplayExport(
events: LogEvent[],
options: {
sourcePath?: string;
description?: string;
} = {}
): ReplayExport {
if (events.length === 0) {
return {
version: REPLAY_EXPORT_VERSION,
exportedAt: Date.now(),
eventCount: 0,
events: [],
metadata: {
sessionStart: Date.now(),
sessionEnd: Date.now(),
workerCount: 0,
sourcePath: options.sourcePath,
description: options.description,
},
};
}
// Sort events by timestamp
const sortedEvents = [...events].sort((a, b) => a.ts - b.ts);
// Calculate metadata
const timestamps = sortedEvents.map(e => e.ts);
const sessionStart = Math.min(...timestamps);
const sessionEnd = Math.max(...timestamps);
const workers = new Set(sortedEvents.map(e => e.worker));
return {
version: REPLAY_EXPORT_VERSION,
exportedAt: Date.now(),
eventCount: sortedEvents.length,
events: sortedEvents,
metadata: {
sessionStart,
sessionEnd,
workerCount: workers.size,
sourcePath: options.sourcePath,
description: options.description,
},
};
}
/**
* Export events to JSON string
*/
export function exportToJson(events: LogEvent[], options?: {
sourcePath?: string;
description?: string;
}): string {
const exportData = createReplayExport(events, options);
return JSON.stringify(exportData, null, 2);
}
/**
* Export events to base64 encoded string (for URL sharing)
*/
export function exportToBase64(events: LogEvent[], options?: {
sourcePath?: string;
description?: string;
}): string {
const exportData = createReplayExport(events, options);
const jsonString = JSON.stringify(exportData);
// Use Node.js Buffer for base64 encoding
return Buffer.from(jsonString, 'utf-8').toString('base64');
}
/**
* Import events from JSON string
*/
export function importFromJson(jsonString: string): ReplayExport {
try {
const data = JSON.parse(jsonString) as ReplayExport;
// Validate structure
if (!data.version || !Array.isArray(data.events)) {
throw new Error('Invalid replay export format');
}
// Validate events have required fields
for (const event of data.events) {
if (typeof event.ts !== 'number' || !event.worker || !event.msg) {
throw new Error('Invalid event format in export');
}
}
return data;
} catch (error) {
throw new Error(`Failed to parse replay export: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Import events from base64 encoded string (from URL)
*/
export function importFromBase64(base64String: string): ReplayExport {
try {
const jsonString = Buffer.from(base64String, 'base64').toString('utf-8');
return importFromJson(jsonString);
} catch (error) {
throw new Error(`Failed to decode replay data: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Generate a shareable URL with replay data
*/
export function generateShareableUrl(
events: LogEvent[],
baseUrl: string,
options?: {
sourcePath?: string;
description?: string;
}
): string {
const base64Data = exportToBase64(events, options);
const url = new URL(baseUrl);
url.searchParams.set('replay', base64Data);
return url.toString();
}
/**
* Extract replay data from URL parameters
*/
export function extractReplayFromUrl(url: string): ReplayExport | null {
try {
const parsedUrl = new URL(url);
const replayParam = parsedUrl.searchParams.get('replay');
if (!replayParam) {
return null;
}
return importFromBase64(replayParam);
} catch {
return null;
}
}
/**
* Generate a filename for the export
*/
export function generateExportFilename(metadata: ReplayExportMetadata): string {
const date = new Date(metadata.sessionStart);
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD
const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS
return `session-${dateStr}-${timeStr}.fabric-replay`;
}
/**
* Validate a replay export
*/
export function validateReplayExport(data: unknown): data is ReplayExport {
if (typeof data !== 'object' || data === null) {
return false;
}
const obj = data as Record<string, unknown>;
// Check required fields
if (typeof obj.version !== 'string') return false;
if (typeof obj.exportedAt !== 'number') return false;
if (typeof obj.eventCount !== 'number') return false;
if (!Array.isArray(obj.events)) return false;
if (typeof obj.metadata !== 'object' || obj.metadata === null) return false;
// Validate metadata
const metadata = obj.metadata as Record<string, unknown>;
if (typeof metadata.sessionStart !== 'number') return false;
if (typeof metadata.sessionEnd !== 'number') return false;
if (typeof metadata.workerCount !== 'number') return false;
return true;
}
// ============================================
// Web-specific utilities (for browser environment)
// ============================================
/**
* Export events to base64 (browser version)
*/
export function exportToBase64Browser(events: WebLogEvent[]): string {
const exportData = createReplayExportWeb(events);
const jsonString = JSON.stringify(exportData);
return btoa(encodeURIComponent(jsonString));
}
/**
* Import events from base64 (browser version)
*/
export function importFromBase64Browser(base64String: string): ReplayExportWeb {
try {
const jsonString = decodeURIComponent(atob(base64String));
return JSON.parse(jsonString);
} catch (error) {
throw new Error(`Failed to decode replay data: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Web version of ReplayExport
*/
export interface ReplayExportWeb {
version: string;
exportedAt: number;
eventCount: number;
events: WebLogEvent[];
metadata: {
sessionStart: number;
sessionEnd: number;
workerCount: number;
sourcePath?: string;
description?: string;
};
}
/**
* Create a replay export from web events
*/
export function createReplayExportWeb(
events: WebLogEvent[],
options: {
sourcePath?: string;
description?: string;
} = {}
): ReplayExportWeb {
if (events.length === 0) {
return {
version: REPLAY_EXPORT_VERSION,
exportedAt: Date.now(),
eventCount: 0,
events: [],
metadata: {
sessionStart: Date.now(),
sessionEnd: Date.now(),
workerCount: 0,
sourcePath: options.sourcePath,
description: options.description,
},
};
}
// Sort events by timestamp
const sortedEvents = [...events].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Calculate metadata
const timestamps = sortedEvents.map(e => new Date(e.timestamp).getTime());
const sessionStart = Math.min(...timestamps);
const sessionEnd = Math.max(...timestamps);
const workers = new Set(sortedEvents.map(e => e.worker));
return {
version: REPLAY_EXPORT_VERSION,
exportedAt: Date.now(),
eventCount: sortedEvents.length,
events: sortedEvents,
metadata: {
sessionStart,
sessionEnd,
workerCount: workers.size,
sourcePath: options.sourcePath,
description: options.description,
},
};
}
/**
* Export web events to JSON string
*/
export function exportToJsonWeb(events: WebLogEvent[], options?: {
sourcePath?: string;
description?: string;
}): string {
const exportData = createReplayExportWeb(events, options);
return JSON.stringify(exportData, null, 2);
}
/**
* Import web events from JSON string
*/
export function importFromJsonWeb(jsonString: string): ReplayExportWeb {
try {
const data = JSON.parse(jsonString);
// Validate structure
if (!data.version || !Array.isArray(data.events)) {
throw new Error('Invalid replay export format');
}
// Validate events have required fields
for (const event of data.events) {
if (!event.timestamp || !event.worker || !event.message) {
throw new Error('Invalid event format in export');
}
}
return data;
} catch (error) {
throw new Error(`Failed to parse replay export: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export default {
REPLAY_EXPORT_VERSION,
createReplayExport,
exportToJson,
exportToBase64,
importFromJson,
importFromBase64,
generateShareableUrl,
extractReplayFromUrl,
generateExportFilename,
validateReplayExport,
exportToBase64Browser,
importFromBase64Browser,
createReplayExportWeb,
exportToJsonWeb,
importFromJsonWeb,
};

View file

@ -10,6 +10,8 @@ import DependencyDag from './components/DependencyDag';
import RecoveryPanel from './components/RecoveryPanel';
import FileContextPanel from './components/FileContextPanel';
import TimelineView from './components/TimelineView';
import SessionReplay from './components/SessionReplay';
import { extractReplayFromUrl, ReplayExport } from './utils/replayExport';
const FOCUS_MODE_STORAGE_KEY = 'fabric-focus-mode';
@ -228,6 +230,26 @@ const App: React.FC = () => {
const [selectedTimelineTime, setSelectedTimelineTime] = useState<number | null>(null);
const [recoverySuggestions, setRecoverySuggestions] = useState<RecoverySuggestion[]>([]);
// Session Replay state
const [showSessionReplay, setShowSessionReplay] = useState(false);
const [replayEvents, setReplayEvents] = useState<LogEvent[]>([]);
const [replayMetadata, setReplayMetadata] = useState<ReplayExport['metadata'] | null>(null);
const [replayImportError, setReplayImportError] = useState<string | null>(null);
// Check URL for replay parameter on mount
useEffect(() => {
const replayData = extractReplayFromUrl();
if (replayData) {
setReplayEvents(replayData.events);
setReplayMetadata(replayData.metadata);
setShowSessionReplay(true);
// Clear the URL parameter after loading
const url = new URL(window.location.href);
url.searchParams.delete('replay');
window.history.replaceState({}, '', url.toString());
}
}, []);
// Focus Mode state
const [focusModeEnabled, setFocusModeEnabled] = useState(false);
const [pinnedWorkers, setPinnedWorkers] = useState<Set<string>>(new Set());
@ -436,6 +458,14 @@ const App: React.FC = () => {
<span className="timeline-toggle-icon">📊</span>
<span className="timeline-toggle-label">Timeline</span>
</button>
<button
className={`session-replay-toggle ${showSessionReplay ? 'active' : ''}`}
onClick={() => setShowSessionReplay(!showSessionReplay)}
title={showSessionReplay ? 'Hide session replay' : 'Show session replay'}
>
<span className="session-replay-toggle-icon">📼</span>
<span className="session-replay-toggle-label">Replay</span>
</button>
{unacknowledgedAlertCount > 0 && (
<button
className="collision-alert-toggle"
@ -555,6 +585,42 @@ const App: React.FC = () => {
}}
/>
)}
{showSessionReplay && (
<div className="session-replay-panel">
<div className="session-replay-header">
<h3>
Session Replay
{replayMetadata && (
<span className="replay-info">
{replayMetadata.eventCount || replayEvents.length} events | {replayMetadata.workerCount} workers
</span>
)}
</h3>
<button
className="close-button"
onClick={() => {
setShowSessionReplay(false);
setReplayEvents([]);
setReplayMetadata(null);
}}
>
×
</button>
</div>
{replayImportError && (
<div className="replay-import-error">{replayImportError}</div>
)}
<SessionReplay
events={replayEvents.length > 0 ? replayEvents : filteredEvents}
onImport={(importedEvents, metadata) => {
setReplayEvents(importedEvents);
setReplayMetadata(metadata);
setReplayImportError(null);
}}
/>
</div>
)}
</main>
</div>
);

View file

@ -3,10 +3,18 @@
*
* Provides session replay functionality - ability to replay worker activity
* history chronologically with playback controls.
* Includes export/import functionality for sharing sessions.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { LogEvent, ReplaySpeed, ReplayState } from '../types';
import {
exportToBase64Browser,
importFromBase64Browser,
exportToJsonWeb,
importFromJsonWeb,
ReplayExportWeb,
} from '../utils/replayExport';
// Re-export types for external use
export type { ReplaySpeed, ReplayState };
@ -26,6 +34,9 @@ interface SessionReplayProps {
/** Optional CSS class */
className?: string;
/** Callback when events are imported */
onImport?: (events: LogEvent[], metadata: ReplayExportWeb['metadata']) => void;
}
/**
@ -62,6 +73,7 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
onEvent,
onStateChange,
className = '',
onImport,
}) => {
// Playback state
const [state, setState] = useState<ReplayState>('idle');
@ -69,9 +81,16 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
const [speed, setSpeed] = useState<ReplaySpeed>(1);
const [displayedEvents, setDisplayedEvents] = useState<LogEvent[]>([]);
// Export/Import state
const [showExportMenu, setShowExportMenu] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [exportSuccess, setExportSuccess] = useState<string | null>(null);
// Refs
const playbackTimerRef = useRef<NodeJS.Timeout | null>(null);
const eventListRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
// Filter events
const filteredEvents = React.useMemo(() => {
@ -165,6 +184,20 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
}
}, [displayedEvents, state]);
// Click outside to close export menu
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as Node)) {
setShowExportMenu(false);
}
};
if (showExportMenu) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showExportMenu]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -316,6 +349,116 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
seekToPercent(percent);
};
// ============================================
// Export/Import Functions
// ============================================
/**
* Export as shareable link (copies to clipboard)
*/
const handleExportLink = useCallback(async () => {
if (filteredEvents.length === 0) {
setImportError('No events to export');
return;
}
try {
const base64Data = exportToBase64Browser(filteredEvents);
const url = `${window.location.origin}${window.location.pathname}?replay=${base64Data}`;
await navigator.clipboard.writeText(url);
setExportSuccess('Link copied to clipboard!');
setTimeout(() => setExportSuccess(null), 3000);
setShowExportMenu(false);
} catch (err) {
setImportError(`Failed to create shareable link: ${err instanceof Error ? err.message : 'Unknown error'}`);
setTimeout(() => setImportError(null), 5000);
}
}, [filteredEvents]);
/**
* Export as .fabric-replay file
*/
const handleExportFile = useCallback(() => {
if (filteredEvents.length === 0) {
setImportError('No events to export');
return;
}
try {
const jsonData = exportToJsonWeb(filteredEvents);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// Generate filename with timestamp
const date = new Date();
const dateStr = date.toISOString().split('T')[0];
const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-');
const filename = `session-${dateStr}-${timeStr}.fabric-replay`;
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setExportSuccess(`Exported as ${filename}`);
setTimeout(() => setExportSuccess(null), 3000);
setShowExportMenu(false);
} catch (err) {
setImportError(`Failed to export file: ${err instanceof Error ? err.message : 'Unknown error'}`);
setTimeout(() => setImportError(null), 5000);
}
}, [filteredEvents]);
/**
* Import from file
*/
const handleImportFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const content = event.target?.result as string;
const importData = importFromJsonWeb(content);
if (importData.events.length === 0) {
setImportError('No events found in import file');
return;
}
onImport?.(importData.events, importData.metadata);
setExportSuccess(`Imported ${importData.eventCount} events`);
setTimeout(() => setExportSuccess(null), 3000);
setShowExportMenu(false);
} catch (err) {
setImportError(`Failed to import file: ${err instanceof Error ? err.message : 'Unknown error'}`);
setTimeout(() => setImportError(null), 5000);
}
};
reader.onerror = () => {
setImportError('Failed to read file');
setTimeout(() => setImportError(null), 5000);
};
reader.readAsText(file);
// Reset input for re-import of same file
e.target.value = '';
}, [onImport]);
/**
* Trigger file input click
*/
const handleImportClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
// Format event for display
const formatEvent = (event: LogEvent): React.ReactNode => {
const time = new Date(event.timestamp).toLocaleTimeString();
@ -372,6 +515,18 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
)}
</div>
{/* Export/Import Status Messages */}
{importError && (
<div className="replay-status replay-status-error">
{importError}
</div>
)}
{exportSuccess && (
<div className="replay-status replay-status-success">
{exportSuccess}
</div>
)}
{/* Controls bar */}
<div className="replay-controls">
<div className="replay-controls-left">
@ -439,6 +594,53 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
</div>
<div className="replay-controls-right">
{/* Export/Import Menu */}
<div className="replay-export-menu" ref={exportMenuRef}>
<button
className="replay-btn replay-btn-share"
onClick={() => setShowExportMenu(!showExportMenu)}
title="Export/Import replay"
>
📤
</button>
{showExportMenu && (
<div className="replay-export-dropdown">
<button
className="replay-dropdown-item"
onClick={handleExportLink}
disabled={filteredEvents.length === 0}
title="Copy shareable link to clipboard"
>
🔗 Copy Share Link
</button>
<button
className="replay-dropdown-item"
onClick={handleExportFile}
disabled={filteredEvents.length === 0}
title="Download as .fabric-replay file"
>
💾 Export File
</button>
<button
className="replay-dropdown-item"
onClick={handleImportClick}
title="Import from .fabric-replay file"
>
📂 Import File
</button>
</div>
)}
</div>
{/* Hidden file input for import */}
<input
ref={fileInputRef}
type="file"
accept=".fabric-replay,.json"
onChange={handleImportFile}
style={{ display: 'none' }}
/>
<span className="replay-help">
[Space] Play/Pause | [/] Step | [/] Speed | [r] Reset
</span>

View file

@ -981,6 +981,184 @@ body {
color: var(--text-secondary);
}
/* Export/Import Styles */
.replay-export-menu {
position: relative;
margin-right: 0.5rem;
}
.replay-btn-share {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
.replay-btn-share:hover {
background: var(--accent);
border-color: var(--accent);
}
.replay-export-dropdown {
position: absolute;
bottom: 100%;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px var(--shadow-color);
min-width: 160px;
z-index: 1000;
margin-bottom: 4px;
overflow: hidden;
}
.replay-dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.875rem;
background: none;
border: none;
color: var(--text-primary);
font-size: 0.8rem;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.replay-dropdown-item:hover {
background: var(--hover-bg);
}
.replay-dropdown-item:disabled {
color: var(--text-secondary);
cursor: not-allowed;
opacity: 0.6;
}
/* Status Messages */
.replay-status {
padding: 0.5rem 1rem;
font-size: 0.8rem;
text-align: center;
animation: fadeIn 0.2s ease-out;
}
.replay-status-success {
background: rgba(0, 200, 83, 0.15);
color: var(--success);
border-top: 1px solid rgba(0, 200, 83, 0.3);
}
.replay-status-error {
background: rgba(244, 67, 54, 0.15);
color: var(--error);
border-top: 1px solid rgba(244, 67, 54, 0.3);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Session Replay Panel (App-level) */
.session-replay-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
}
.session-replay-toggle:hover {
background: var(--hover-bg);
}
.session-replay-toggle.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.session-replay-toggle-icon {
font-size: 1rem;
}
.session-replay-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 300px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
z-index: 100;
display: flex;
flex-direction: column;
box-shadow: 0 -4px 12px var(--shadow-color);
}
.session-replay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.session-replay-header h3 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.session-replay-header .replay-info {
font-size: 0.75rem;
font-weight: 400;
color: var(--text-secondary);
}
.session-replay-header .close-button {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
line-height: 1;
border-radius: 4px;
transition: all 0.2s;
}
.session-replay-header .close-button:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.replay-import-error {
padding: 0.5rem 1rem;
background: rgba(244, 67, 54, 0.15);
color: var(--error);
font-size: 0.8rem;
}
.session-replay-panel .session-replay {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ============================================
Collision Alert Panel Styles
============================================ */

View file

@ -0,0 +1,241 @@
/**
* Session Replay Export/Import Utilities for Web Frontend
*
* Provides functionality for exporting and importing session replay data
* as shareable links or .fabric-replay files.
*/
import { LogEvent } from '../types';
/**
* Version of the export format
*/
export const REPLAY_EXPORT_VERSION = '1.0';
/**
* Metadata for the exported session
*/
export interface ReplayExportMetadata {
/** Unix timestamp of session start */
sessionStart: number;
/** Unix timestamp of session end */
sessionEnd: number;
/** Number of unique workers */
workerCount: number;
/** Optional source file path */
sourcePath?: string;
/** Optional description */
description?: string;
}
/**
* Export format for session replay
*/
export interface ReplayExport {
version: string;
exportedAt: number;
eventCount: number;
events: LogEvent[];
metadata: ReplayExportMetadata;
}
/**
* Create a replay export from events
*/
export function createReplayExport(
events: LogEvent[],
options: {
sourcePath?: string;
description?: string;
} = {}
): ReplayExport {
if (events.length === 0) {
return {
version: REPLAY_EXPORT_VERSION,
exportedAt: Date.now(),
eventCount: 0,
events: [],
metadata: {
sessionStart: Date.now(),
sessionEnd: Date.now(),
workerCount: 0,
sourcePath: options.sourcePath,
description: options.description,
},
};
}
// Sort events by timestamp
const sortedEvents = [...events].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Calculate metadata
const timestamps = sortedEvents.map(e => new Date(e.timestamp).getTime());
const sessionStart = Math.min(...timestamps);
const sessionEnd = Math.max(...timestamps);
const workers = new Set(sortedEvents.map(e => e.worker));
return {
version: REPLAY_EXPORT_VERSION,
exportedAt: Date.now(),
eventCount: sortedEvents.length,
events: sortedEvents,
metadata: {
sessionStart,
sessionEnd,
workerCount: workers.size,
sourcePath: options.sourcePath,
description: options.description,
},
};
}
/**
* Export events to JSON string
*/
export function exportToJson(events: LogEvent[], options?: {
sourcePath?: string;
description?: string;
}): string {
const exportData = createReplayExport(events, options);
return JSON.stringify(exportData, null, 2);
}
/**
* Export events to base64 encoded string (for URL sharing)
* Uses browser's btoa with UTF-8 encoding support
*/
export function exportToBase64Browser(events: LogEvent[]): string {
const exportData = createReplayExport(events);
const jsonString = JSON.stringify(exportData);
// Encode UTF-8 to handle special characters
return btoa(encodeURIComponent(jsonString).replace(/%([0-9A-F]{2})/g, (_, p1) => {
return String.fromCharCode(parseInt(p1, 16));
}));
}
/**
* Import events from JSON string
*/
export function importFromJson(jsonString: string): ReplayExport {
try {
const data = JSON.parse(jsonString) as ReplayExport;
// Validate structure
if (!data.version || !Array.isArray(data.events)) {
throw new Error('Invalid replay export format');
}
// Validate events have required fields
for (const event of data.events) {
if (!event.timestamp || !event.worker || !event.message) {
throw new Error('Invalid event format in export');
}
}
return data;
} catch (error) {
throw new Error(`Failed to parse replay export: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Import events from base64 encoded string (from URL)
* Uses browser's atob with UTF-8 decoding support
*/
export function importFromBase64Browser(base64String: string): ReplayExport {
try {
const jsonString = decodeURIComponent(atob(base64String).split('').map(c => {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return importFromJson(jsonString);
} catch (error) {
throw new Error(`Failed to decode replay data: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Generate a shareable URL with replay data
*/
export function generateShareableUrl(
events: LogEvent[],
baseUrl: string
): string {
const base64Data = exportToBase64Browser(events);
const url = new URL(baseUrl);
url.searchParams.set('replay', base64Data);
return url.toString();
}
/**
* Extract replay data from URL parameters
*/
export function extractReplayFromUrl(): ReplayExport | null {
try {
const replayParam = new URLSearchParams(window.location.search).get('replay');
if (!replayParam) {
return null;
}
return importFromBase64Browser(replayParam);
} catch {
return null;
}
}
/**
* Generate a filename for the export
*/
export function generateExportFilename(metadata: ReplayExportMetadata): string {
const date = new Date(metadata.sessionStart);
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD
const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS
return `session-${dateStr}-${timeStr}.fabric-replay`;
}
/**
* Validate a replay export
*/
export function validateReplayExport(data: unknown): data is ReplayExport {
if (typeof data !== 'object' || data === null) {
return false;
}
const obj = data as Record<string, unknown>;
// Check required fields
if (typeof obj.version !== 'string') return false;
if (typeof obj.exportedAt !== 'number') return false;
if (typeof obj.eventCount !== 'number') return false;
if (!Array.isArray(obj.events)) return false;
if (typeof obj.metadata !== 'object' || obj.metadata === null) return false;
// Validate metadata
const metadata = obj.metadata as Record<string, unknown>;
if (typeof metadata.sessionStart !== 'number') return false;
if (typeof metadata.sessionEnd !== 'number') return false;
if (typeof metadata.workerCount !== 'number') return false;
return true;
}
// Type aliases for compatibility
export type ReplayExportWeb = ReplayExport;
export const createReplayExportWeb = createReplayExport;
export const exportToJsonWeb = exportToJson;
export const importFromJsonWeb = importFromJson;
export default {
REPLAY_EXPORT_VERSION,
createReplayExport,
exportToJson,
exportToBase64Browser,
importFromJson,
importFromBase64Browser,
generateShareableUrl,
extractReplayFromUrl,
generateExportFilename,
validateReplayExport,
};