feat(replay): add session replay export functionality

Add comprehensive export/import functionality for session replay in both TUI and web UI:

- TUI: Add keyboard shortcuts [e] export file, [E] export base64, [m] export markdown, [i] import
- Web UI: Add export dropdown with JSON, Markdown, and shareable link options
- Web UI: Add "Import from URL" option for loading replay data
- Auto-import from URL parameters on page load for shared links

Export formats:
- JSON (.fabric-replay): Full event data with metadata
- Markdown (.md): Human-readable session summary with tables
- Base64 URL: Shareable link for collaboration

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-28 14:38:25 -04:00
parent a6418ac539
commit a05281c796
4 changed files with 450 additions and 2 deletions

View file

@ -156,6 +156,12 @@ export class SessionReplay extends EventEmitter {
this.container.key(['3'], () => this.setSpeed(2));
this.container.key(['4'], () => this.setSpeed(5));
this.container.key(['5'], () => this.setSpeed(10));
// Export shortcuts
this.container.key(['e'], () => this.handleExportFile());
this.container.key(['E'], () => this.handleExportBase64());
this.container.key(['m'], () => this.handleExportMarkdown());
this.container.key(['i'], () => this.handleImportPrompt());
}
/**
@ -529,7 +535,91 @@ export class SessionReplay extends EventEmitter {
*/
private formatControls(): string {
const speedDisplay = `${this.speed}x`;
return ` Speed: ${speedDisplay} | [Space] Play/Pause | [←/→] Step | [↑/↓] Speed | [1-5] 0.5x-10x | [Home/End] Jump | [r] Reset`;
return ` Speed: ${speedDisplay} | [Space] Play/Pause | [←/→] Step | [↑/↓] Speed | [1-5] 0.5x-10x | [Home/End] Jump | [r] Reset | [e/m/E] Export | [i] Import`;
}
/**
* Handle export to file
*/
private handleExportFile(): void {
try {
const filePath = this.exportToFile();
this.logBox.log(`{green-fg}Exported to: ${filePath}{/}`);
} catch (err) {
this.logBox.log(`{red-fg}Export failed: ${err instanceof Error ? err.message : 'Unknown error'}{/}`);
}
}
/**
* Handle export to base64 (for sharing)
*/
private handleExportBase64(): void {
try {
const base64 = this.exportToBase64();
const url = `https://example.com/replay?data=${base64.slice(0, 50)}...`;
this.logBox.log(`{green-fg}Shareable URL generated (${base64.length} bytes){/}`);
this.logBox.log(`{yellow-fg}Use this base64 data to share the replay{/}`);
this.logBox.log(`{cyan-fg}${base64.slice(0, 100)}...{/}`);
} catch (err) {
this.logBox.log(`{red-fg}Export failed: ${err instanceof Error ? err.message : 'Unknown error'}{/}`);
}
}
/**
* Handle export to markdown
*/
private handleExportMarkdown(): void {
try {
const { exportToMarkdown } = require('../../utils/replayExport.js');
const eventsToExport = this.filteredEvents.length > 0 ? this.filteredEvents : this.events;
if (eventsToExport.length === 0) {
this.logBox.log(`{red-fg}No events to export{/}`);
return;
}
const markdown = exportToMarkdown(eventsToExport, {
sourcePath: this.sourcePath || undefined,
});
// Generate filename
const timestamps = eventsToExport.map(e => e.ts);
const sessionStart = Math.min(...timestamps);
const date = new Date(sessionStart);
const dateStr = date.toISOString().split('T')[0];
const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-');
const filename = `session-${dateStr}-${timeStr}.md`;
// Write file
const path = require('path');
const fs = require('fs');
const exportPath = path.join(process.cwd(), filename);
fs.writeFileSync(exportPath, markdown, 'utf-8');
this.logBox.log(`{green-fg}Exported Markdown to: ${exportPath}{/}`);
} catch (err) {
this.logBox.log(`{red-fg}Markdown export failed: ${err instanceof Error ? err.message : 'Unknown error'}{/}`);
}
}
/**
* Handle import from file prompt
*/
private handleImportPrompt(): void {
this.logBox.log(`{yellow-fg}Import: Use importFromFile(path) or importFromBase64(string) methods{/}`);
}
/**
* Import from URL (requires base64 data from URL)
* Call this method programmatically with the base64 data
*/
importFromUrlData(base64Data: string): { eventCount: number; metadata: object } {
try {
return this.importFromBase64(base64Data);
} catch (err) {
this.logBox.log(`{red-fg}Import from URL failed: ${err instanceof Error ? err.message : 'Unknown error'}{/}`);
throw err;
}
}
/**

View file

@ -239,6 +239,122 @@ export function validateReplayExport(data: unknown): data is ReplayExport {
return true;
}
/**
* Export events to Markdown string
*/
export function exportToMarkdown(events: LogEvent[], options?: {
sourcePath?: string;
description?: string;
}): string {
const exportData = createReplayExport(events, options);
return formatAsMarkdown(exportData);
}
/**
* Format a replay export as Markdown
*/
export function formatAsMarkdown(exportData: ReplayExport): string {
const lines: string[] = [];
lines.push('# Session Replay');
lines.push('');
// Session info
lines.push('## Session Information');
lines.push('');
lines.push(`- **Exported At:** ${new Date(exportData.exportedAt).toLocaleString()}`);
lines.push(`- **Version:** ${exportData.version}`);
lines.push(`- **Events:** ${exportData.eventCount}`);
lines.push('');
const metadata = exportData.metadata;
lines.push(`- **Session Start:** ${new Date(metadata.sessionStart).toLocaleString()}`);
lines.push(`- **Session End:** ${new Date(metadata.sessionEnd).toLocaleString()}`);
lines.push(`- **Duration:** ${formatDuration(metadata.sessionEnd - metadata.sessionStart)}`);
lines.push(`- **Workers:** ${metadata.workerCount}`);
if (metadata.sourcePath) {
lines.push(`- **Source:** ${metadata.sourcePath}`);
}
if (metadata.description) {
lines.push(`- **Description:** ${metadata.description}`);
}
lines.push('');
// Events table
lines.push('## Events');
lines.push('');
if (exportData.events.length === 0) {
lines.push('_No events in this session_');
} else {
lines.push('| Time | Worker | Level | Tool | Message |');
lines.push('|------|--------|-------|------|---------|');
for (const event of exportData.events) {
const time = new Date(event.ts).toLocaleTimeString();
const worker = event.worker.slice(0, 8);
const level = event.level.toUpperCase();
const tool = event.tool || '';
const message = (event.msg || '').replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 100);
lines.push(`| ${time} | ${worker} | ${level} | ${tool} | ${message} |`);
}
}
lines.push('');
// Worker breakdown
lines.push('## Worker Breakdown');
lines.push('');
const workerCounts = new Map<string, number>();
for (const event of exportData.events) {
workerCounts.set(event.worker, (workerCounts.get(event.worker) || 0) + 1);
}
lines.push('| Worker | Events |');
lines.push('|--------|--------|');
for (const [worker, count] of workerCounts.entries()) {
lines.push(`| ${worker.slice(0, 8)} | ${count} |`);
}
lines.push('');
// Level breakdown
lines.push('## Log Level Distribution');
lines.push('');
const levelCounts = new Map<string, number>();
for (const event of exportData.events) {
levelCounts.set(event.level, (levelCounts.get(event.level) || 0) + 1);
}
lines.push('| Level | Count |');
lines.push('|-------|-------|');
for (const [level, count] of levelCounts.entries()) {
lines.push(`| ${level.toUpperCase()} | ${count} |`);
}
lines.push('');
lines.push('---');
lines.push(`*Generated by FABRIC at ${new Date().toLocaleString()}*`);
return lines.join('\n');
}
/**
* Format duration in milliseconds to human readable format
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) {
const mins = Math.floor(ms / 60000);
const secs = Math.floor((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
}
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
return `${hours}h ${mins}m`;
}
// ============================================
// Web-specific utilities (for browser environment)
// ============================================
@ -374,6 +490,8 @@ export default {
createReplayExport,
exportToJson,
exportToBase64,
exportToMarkdown,
formatAsMarkdown,
importFromJson,
importFromBase64,
generateShareableUrl,

View file

@ -13,6 +13,7 @@ import {
importFromBase64Browser,
exportToJsonWeb,
importFromJsonWeb,
exportToMarkdown,
ReplayExportWeb,
} from '../utils/replayExport';
@ -196,6 +197,28 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
}
}, [showExportMenu]);
// Check for replay data in URL on mount
useEffect(() => {
const checkUrlForReplay = () => {
try {
const replayParam = new URLSearchParams(window.location.search).get('replay');
if (replayParam) {
const importData = importFromBase64Browser(replayParam);
if (importData.events.length > 0) {
onImport?.(importData.events, importData.metadata);
setExportSuccess(`Imported ${importData.eventCount} events from URL`);
setTimeout(() => setExportSuccess(null), 3000);
}
}
} catch (err) {
setImportError(`Failed to import from URL: ${err instanceof Error ? err.message : 'Unknown error'}`);
setTimeout(() => setImportError(null), 5000);
}
};
checkUrlForReplay();
}, []);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -457,6 +480,90 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
fileInputRef.current?.click();
}, []);
/**
* Export as Markdown file
*/
const handleExportMarkdown = useCallback(() => {
if (filteredEvents.length === 0) {
setImportError('No events to export');
return;
}
try {
// Convert web LogEvent format to core LogEvent format for exportToMarkdown
const coreEvents = filteredEvents.map(e => ({
ts: new Date(e.timestamp).getTime(),
worker: e.worker,
level: e.level,
msg: e.message,
tool: e.tool,
path: e.path,
bead: e.bead,
sequence: e.sequence,
}));
const markdown = exportToMarkdown(coreEvents);
const blob = new Blob([markdown], { type: 'text/markdown' });
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}.md`;
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 markdown: ${err instanceof Error ? err.message : 'Unknown error'}`);
setTimeout(() => setImportError(null), 5000);
}
}, [filteredEvents]);
/**
* Import from URL
*/
const handleImportUrl = useCallback(() => {
const url = prompt('Enter replay URL or paste the base64 data:');
if (!url) return;
try {
// Check if it's a full URL or just base64 data
let base64Data = url;
if (url.startsWith('http://') || url.startsWith('https://')) {
const parsedUrl = new URL(url);
const replayParam = parsedUrl.searchParams.get('replay');
if (!replayParam) {
throw new Error('No replay data found in URL');
}
base64Data = replayParam;
}
const importData = importFromBase64Browser(base64Data);
if (importData.events.length === 0) {
setImportError('No events found in import data');
return;
}
onImport?.(importData.events, importData.metadata);
setExportSuccess(`Imported ${importData.eventCount} events`);
setTimeout(() => setExportSuccess(null), 3000);
setShowExportMenu(false);
} catch (err) {
setImportError(`Failed to import from URL: ${err instanceof Error ? err.message : 'Unknown error'}`);
setTimeout(() => setImportError(null), 5000);
}
}, [onImport]);
// Format event for display
const formatEvent = (event: LogEvent): React.ReactNode => {
const time = new Date(event.timestamp).toLocaleTimeString();
@ -617,7 +724,15 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
disabled={filteredEvents.length === 0}
title="Download as .fabric-replay file"
>
💾 Export File
💾 Export JSON
</button>
<button
className="replay-dropdown-item"
onClick={handleExportMarkdown}
disabled={filteredEvents.length === 0}
title="Export as Markdown file"
>
📝 Export Markdown
</button>
<button
className="replay-dropdown-item"
@ -626,6 +741,13 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
>
📂 Import File
</button>
<button
className="replay-dropdown-item"
onClick={handleImportUrl}
title="Import from URL or base64 data"
>
🌐 Import from URL
</button>
</div>
)}
</div>

View file

@ -221,6 +221,122 @@ export function validateReplayExport(data: unknown): data is ReplayExport {
return true;
}
/**
* Export events to Markdown string
*/
export function exportToMarkdown(events: LogEvent[], options?: {
sourcePath?: string;
description?: string;
}): string {
const exportData = createReplayExport(events, options);
return formatAsMarkdown(exportData);
}
/**
* Format a replay export as Markdown
*/
export function formatAsMarkdown(exportData: ReplayExport): string {
const lines: string[] = [];
lines.push('# Session Replay');
lines.push('');
// Session info
lines.push('## Session Information');
lines.push('');
lines.push(`- **Exported At:** ${new Date(exportData.exportedAt).toLocaleString()}`);
lines.push(`- **Version:** ${exportData.version}`);
lines.push(`- **Events:** ${exportData.eventCount}`);
lines.push('');
const metadata = exportData.metadata;
lines.push(`- **Session Start:** ${new Date(metadata.sessionStart).toLocaleString()}`);
lines.push(`- **Session End:** ${new Date(metadata.sessionEnd).toLocaleString()}`);
lines.push(`- **Duration:** ${formatDuration(metadata.sessionEnd - metadata.sessionStart)}`);
lines.push(`- **Workers:** ${metadata.workerCount}`);
if (metadata.sourcePath) {
lines.push(`- **Source:** ${metadata.sourcePath}`);
}
if (metadata.description) {
lines.push(`- **Description:** ${metadata.description}`);
}
lines.push('');
// Events table
lines.push('## Events');
lines.push('');
if (exportData.events.length === 0) {
lines.push('_No events in this session_');
} else {
lines.push('| Time | Worker | Level | Tool | Message |');
lines.push('|------|--------|-------|------|---------|');
for (const event of exportData.events) {
const time = new Date(event.timestamp).toLocaleTimeString();
const worker = event.worker.slice(0, 8);
const level = event.level.toUpperCase();
const tool = event.tool || '';
const message = (event.message || '').replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 100);
lines.push(`| ${time} | ${worker} | ${level} | ${tool} | ${message} |`);
}
}
lines.push('');
// Worker breakdown
lines.push('## Worker Breakdown');
lines.push('');
const workerCounts = new Map<string, number>();
for (const event of exportData.events) {
workerCounts.set(event.worker, (workerCounts.get(event.worker) || 0) + 1);
}
lines.push('| Worker | Events |');
lines.push('|--------|--------|');
for (const [worker, count] of workerCounts.entries()) {
lines.push(`| ${worker.slice(0, 8)} | ${count} |`);
}
lines.push('');
// Level breakdown
lines.push('## Log Level Distribution');
lines.push('');
const levelCounts = new Map<string, number>();
for (const event of exportData.events) {
levelCounts.set(event.level, (levelCounts.get(event.level) || 0) + 1);
}
lines.push('| Level | Count |');
lines.push('|-------|-------|');
for (const [level, count] of levelCounts.entries()) {
lines.push(`| ${level.toUpperCase()} | ${count} |`);
}
lines.push('');
lines.push('---');
lines.push(`*Generated by FABRIC at ${new Date().toLocaleString()}*`);
return lines.join('\n');
}
/**
* Format duration in milliseconds to human readable format
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) {
const mins = Math.floor(ms / 60000);
const secs = Math.floor((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
}
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
return `${hours}h ${mins}m`;
}
// Type aliases for compatibility
export type ReplayExportWeb = ReplayExport;
export const createReplayExportWeb = createReplayExport;
@ -232,6 +348,8 @@ export default {
createReplayExport,
exportToJson,
exportToBase64Browser,
exportToMarkdown,
formatAsMarkdown,
importFromJson,
importFromBase64Browser,
generateShareableUrl,