Skip to content

Commit 3f0f520

Browse files
committed
feat: build interactive replay page with timeline, ANSI rendering, fork tree, and live mode
1 parent b5c7afa commit 3f0f520

10 files changed

Lines changed: 1574 additions & 114 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useMemo } from 'react'
2+
import type { CSSProperties } from 'react'
3+
import { parseAnsi } from './ansi-parser'
4+
5+
interface AnsiTextProps {
6+
text: string
7+
className?: string | undefined
8+
}
9+
10+
export default function AnsiText({ text, className }: AnsiTextProps) {
11+
const segments = useMemo(() => parseAnsi(text), [text])
12+
13+
return (
14+
<pre className={className ?? 'replay-terminal'}>
15+
{segments.map((seg, i) => {
16+
const style: CSSProperties = {}
17+
if (seg.fg) style.color = seg.fg
18+
if (seg.bg) style.backgroundColor = seg.bg
19+
if (seg.bold) style.fontWeight = 700
20+
if (seg.dim) style.opacity = 0.6
21+
if (seg.underline) style.textDecoration = 'underline'
22+
23+
const hasStyle = seg.fg || seg.bg || seg.bold || seg.dim || seg.underline
24+
if (!hasStyle) return seg.text
25+
26+
return (
27+
<span key={i} style={style}>
28+
{seg.text}
29+
</span>
30+
)
31+
})}
32+
</pre>
33+
)
34+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import Link from 'next/link'
5+
import type { ReplayForkTreeNode } from '@sandchest/contract'
6+
import { formatRelativeTime } from '@/lib/format'
7+
8+
interface ForkTreeProps {
9+
tree: ReplayForkTreeNode
10+
currentId: string
11+
}
12+
13+
function TreeNode({
14+
node,
15+
currentId,
16+
depth,
17+
isLast,
18+
}: {
19+
node: ReplayForkTreeNode
20+
currentId: string
21+
depth: number
22+
isLast: boolean
23+
}) {
24+
const [collapsed, setCollapsed] = useState(false)
25+
const isCurrent = node.sandbox_id === currentId
26+
const hasChildren = node.children.length > 0
27+
28+
return (
29+
<div className="ft-node-wrapper">
30+
<div className={`ft-node ${isCurrent ? 'ft-current' : ''}`}>
31+
{depth > 0 && (
32+
<span className="ft-branch">{isLast ? '\u2514\u2500 ' : '\u251c\u2500 '}</span>
33+
)}
34+
35+
<span className="ft-dot" />
36+
37+
{isCurrent ? (
38+
<span className="ft-id ft-id-current">{node.sandbox_id}</span>
39+
) : (
40+
<Link href={`/s/${node.sandbox_id}`} className="ft-id ft-id-link">
41+
{node.sandbox_id}
42+
</Link>
43+
)}
44+
45+
{isCurrent && <span className="ft-badge">current</span>}
46+
47+
{node.forked_at && (
48+
<span className="ft-time">
49+
forked {formatRelativeTime(node.forked_at)}
50+
</span>
51+
)}
52+
53+
{hasChildren && (
54+
<button
55+
type="button"
56+
className="ft-collapse-btn"
57+
onClick={() => setCollapsed(!collapsed)}
58+
aria-label={collapsed ? 'Expand' : 'Collapse'}
59+
>
60+
{collapsed ? `+${node.children.length}` : '\u2212'}
61+
</button>
62+
)}
63+
</div>
64+
65+
{hasChildren && !collapsed && (
66+
<div className="ft-children" style={{ marginLeft: depth > 0 ? 20 : 12 }}>
67+
{node.children.map((child, i) => (
68+
<TreeNode
69+
key={child.sandbox_id}
70+
node={child}
71+
currentId={currentId}
72+
depth={depth + 1}
73+
isLast={i === node.children.length - 1}
74+
/>
75+
))}
76+
</div>
77+
)}
78+
</div>
79+
)
80+
}
81+
82+
export default function ForkTree({ tree, currentId }: ForkTreeProps) {
83+
if (tree.children.length === 0 && tree.sandbox_id === currentId) {
84+
return null
85+
}
86+
87+
return (
88+
<div className="ft-container">
89+
<h3 className="replay-section-title">Fork Tree</h3>
90+
<TreeNode node={tree} currentId={currentId} depth={0} isLast={true} />
91+
</div>
92+
)
93+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { readFileSync } from 'fs'
3+
import { join } from 'path'
4+
5+
const viewerSrc = readFileSync(
6+
join(import.meta.dir, 'ReplayViewer.tsx'),
7+
'utf-8',
8+
)
9+
10+
const timelineSrc = readFileSync(
11+
join(import.meta.dir, 'Timeline.tsx'),
12+
'utf-8',
13+
)
14+
15+
const forkTreeSrc = readFileSync(
16+
join(import.meta.dir, 'ForkTree.tsx'),
17+
'utf-8',
18+
)
19+
20+
const ansiTextSrc = readFileSync(
21+
join(import.meta.dir, 'AnsiText.tsx'),
22+
'utf-8',
23+
)
24+
25+
describe('ReplayViewer', () => {
26+
test('marks as client component', () => {
27+
expect(viewerSrc).toMatch(/^'use client'/)
28+
})
29+
30+
test('uses TanStack Query hooks for data fetching', () => {
31+
expect(viewerSrc).toContain('useReplayBundle')
32+
expect(viewerSrc).toContain('useReplayEvents')
33+
})
34+
35+
test('includes timeline tab as default', () => {
36+
expect(viewerSrc).toContain("useState<Tab>('timeline')")
37+
})
38+
39+
test('includes all four tabs', () => {
40+
expect(viewerSrc).toContain("'timeline'")
41+
expect(viewerSrc).toContain("'execs'")
42+
expect(viewerSrc).toContain("'sessions'")
43+
expect(viewerSrc).toContain("'artifacts'")
44+
})
45+
46+
test('renders live badge when in_progress', () => {
47+
expect(viewerSrc).toContain('replay-live-badge')
48+
expect(viewerSrc).toContain('replay-live-dot')
49+
})
50+
51+
test('renders copy URL button', () => {
52+
expect(viewerSrc).toContain('CopyButton')
53+
expect(viewerSrc).toContain('Copy URL')
54+
})
55+
56+
test('passes isLive to Timeline', () => {
57+
expect(viewerSrc).toContain('isLive={isLive}')
58+
})
59+
60+
test('renders ForkTree component', () => {
61+
expect(viewerSrc).toContain('<ForkTree')
62+
expect(viewerSrc).toContain('tree={bundle.fork_tree}')
63+
})
64+
65+
test('uses AnsiText for exec output', () => {
66+
expect(viewerSrc).toContain('<AnsiText')
67+
expect(viewerSrc).toContain('text={combinedOutput}')
68+
})
69+
70+
test('no console.log in production code', () => {
71+
expect(viewerSrc).not.toContain('console.log')
72+
})
73+
})
74+
75+
describe('Timeline', () => {
76+
test('marks as client component', () => {
77+
expect(timelineSrc).toMatch(/^'use client'/)
78+
})
79+
80+
test('includes search functionality', () => {
81+
expect(timelineSrc).toContain('tl-search')
82+
expect(timelineSrc).toContain('Search events')
83+
})
84+
85+
test('includes expand/collapse toggle', () => {
86+
expect(timelineSrc).toContain('Collapse all')
87+
expect(timelineSrc).toContain('Expand all')
88+
})
89+
90+
test('groups exec output events', () => {
91+
expect(timelineSrc).toContain('groupEvents')
92+
expect(timelineSrc).toContain("'exec.output'")
93+
})
94+
95+
test('auto-scrolls in live mode', () => {
96+
expect(timelineSrc).toContain('scrollIntoView')
97+
expect(timelineSrc).toContain('isLive')
98+
})
99+
100+
test('renders elapsed timestamps', () => {
101+
expect(timelineSrc).toContain('formatElapsed')
102+
expect(timelineSrc).toContain('tl-time')
103+
})
104+
105+
test('handles all event types', () => {
106+
const eventTypes = [
107+
'sandbox.created', 'sandbox.ready', 'sandbox.forked',
108+
'sandbox.stopping', 'sandbox.stopped', 'sandbox.failed',
109+
'sandbox.ttl_warning', 'exec.started', 'exec.completed',
110+
'exec.failed', 'session.created', 'session.destroyed',
111+
'file.written', 'file.deleted', 'artifact.registered',
112+
'artifact.collected',
113+
]
114+
for (const type of eventTypes) {
115+
expect(timelineSrc).toContain(`'${type}'`)
116+
}
117+
})
118+
119+
test('no console.log in production code', () => {
120+
expect(timelineSrc).not.toContain('console.log')
121+
})
122+
})
123+
124+
describe('ForkTree', () => {
125+
test('marks as client component', () => {
126+
expect(forkTreeSrc).toMatch(/^'use client'/)
127+
})
128+
129+
test('supports collapsing nodes', () => {
130+
expect(forkTreeSrc).toContain('collapsed')
131+
expect(forkTreeSrc).toContain('ft-collapse-btn')
132+
})
133+
134+
test('highlights current sandbox', () => {
135+
expect(forkTreeSrc).toContain('ft-current')
136+
expect(forkTreeSrc).toContain('ft-id-current')
137+
})
138+
139+
test('renders tree branch characters', () => {
140+
expect(forkTreeSrc).toContain('\\u2514\\u2500')
141+
expect(forkTreeSrc).toContain('\\u251c\\u2500')
142+
})
143+
144+
test('links to other sandboxes', () => {
145+
expect(forkTreeSrc).toContain('ft-id-link')
146+
expect(forkTreeSrc).toContain('href={`/s/${node.sandbox_id}`}')
147+
})
148+
149+
test('returns null for single-node tree', () => {
150+
expect(forkTreeSrc).toContain('tree.children.length === 0')
151+
expect(forkTreeSrc).toContain('return null')
152+
})
153+
154+
test('no console.log in production code', () => {
155+
expect(forkTreeSrc).not.toContain('console.log')
156+
})
157+
})
158+
159+
describe('AnsiText', () => {
160+
test('uses parseAnsi and useMemo', () => {
161+
expect(ansiTextSrc).toContain('parseAnsi')
162+
expect(ansiTextSrc).toContain('useMemo')
163+
})
164+
165+
test('applies inline styles for ANSI attributes', () => {
166+
expect(ansiTextSrc).toContain('style.color')
167+
expect(ansiTextSrc).toContain('style.backgroundColor')
168+
expect(ansiTextSrc).toContain('style.fontWeight')
169+
expect(ansiTextSrc).toContain('style.opacity')
170+
expect(ansiTextSrc).toContain('style.textDecoration')
171+
})
172+
173+
test('renders plain text without wrapping spans', () => {
174+
expect(ansiTextSrc).toContain('return seg.text')
175+
})
176+
177+
test('no console.log in production code', () => {
178+
expect(ansiTextSrc).not.toContain('console.log')
179+
})
180+
})

0 commit comments

Comments
 (0)