From a05281c7963273cfa5838d521635f5b423b708dc Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 28 Apr 2026 14:38:25 -0400 Subject: [PATCH] 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 --- src/tui/components/SessionReplay.ts | 92 ++++++++++++- src/utils/replayExport.ts | 118 +++++++++++++++++ .../frontend/src/components/SessionReplay.tsx | 124 +++++++++++++++++- src/web/frontend/src/utils/replayExport.ts | 118 +++++++++++++++++ 4 files changed, 450 insertions(+), 2 deletions(-) diff --git a/src/tui/components/SessionReplay.ts b/src/tui/components/SessionReplay.ts index f927247..bb01a27 100644 --- a/src/tui/components/SessionReplay.ts +++ b/src/tui/components/SessionReplay.ts @@ -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; + } } /** diff --git a/src/utils/replayExport.ts b/src/utils/replayExport.ts index f9ea390..b3f6e46 100644 --- a/src/utils/replayExport.ts +++ b/src/utils/replayExport.ts @@ -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(); + 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(); + 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, diff --git a/src/web/frontend/src/components/SessionReplay.tsx b/src/web/frontend/src/components/SessionReplay.tsx index 7932c79..988b24c 100644 --- a/src/web/frontend/src/components/SessionReplay.tsx +++ b/src/web/frontend/src/components/SessionReplay.tsx @@ -13,6 +13,7 @@ import { importFromBase64Browser, exportToJsonWeb, importFromJsonWeb, + exportToMarkdown, ReplayExportWeb, } from '../utils/replayExport'; @@ -196,6 +197,28 @@ const SessionReplay: React.FC = ({ } }, [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 = ({ 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 = ({ disabled={filteredEvents.length === 0} title="Download as .fabric-replay file" > - 💾 Export File + 💾 Export JSON + + + )} diff --git a/src/web/frontend/src/utils/replayExport.ts b/src/web/frontend/src/utils/replayExport.ts index 4dec1d1..16175f9 100644 --- a/src/web/frontend/src/utils/replayExport.ts +++ b/src/web/frontend/src/utils/replayExport.ts @@ -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(); + 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(); + 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,