Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,16 @@ export type ReplayVerb =
export type ReplayKind = 'action' | 'block' | 'done'

export interface ReplayFrame {
/** Seconds into the session. */
/** Seconds into the session, at which this dispatch completed. */
t: number
/**
* How long the tool ran, from the source dispatch row. `session-replay.ts`
* subtracts it from `t` to approximate when the action began, which is when
* the replay starts treating it as current. Absent on rows recorded before
* durations were persisted; negative and non-finite values fall back to the
* completion time.
*/
durationMs?: number | null
kind: ReplayKind
verb: ReplayVerb
/** Short node label, e.g. the page title or a focused element. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import { Layers } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ReplayFrame } from '@/modules/api/replay.hooks'
import { formatTime, KIND_STYLE, VERB_META } from './replay.helpers'
import type { ReplayAction } from './session-replay'

interface EventTimelineProps {
frames: readonly ReplayFrame[]
currentFrameIndex: number
onSelectFrame: (frame: ReplayFrame) => void
/** Every tool in the session, globally ordered — not filtered by tab. */
actions: readonly ReplayAction[]
/** Index of the globally current action; -1 before the first one starts. */
currentIndex: number
onSelectAction: (action: ReplayAction) => void
}

/**
* Right-rail vertical event list. Each row corresponds to one frame
* in the recording. Past frames are full-opacity, future frames dim,
* and the row matching the playhead gets an accent-tint background.
* Clicking a row seeks the player to that frame.
* Right-rail vertical list of the session's tools in chronological order.
* Rows are stamped with the action's activity start, which is both where the
* row becomes current and where clicking it seeks. Past rows are full-opacity,
* future rows dim, and the current row gets an accent-tint background.
*/
export function EventTimeline({
frames,
currentFrameIndex,
onSelectFrame,
actions,
currentIndex,
onSelectAction,
}: EventTimelineProps) {
return (
<aside className="flex w-[320px] shrink-0 flex-col border-border border-l bg-card">
Expand All @@ -27,20 +29,18 @@ export function EventTimeline({
Action timeline
</header>
<div className="flex flex-1 flex-col overflow-y-auto px-3 py-2">
{frames.map((frame, i) => {
const seen = currentFrameIndex >= 0 && i <= currentFrameIndex
const isCurrent = i === currentFrameIndex
{actions.map((action, i) => {
const { frame } = action
const seen = currentIndex >= 0 && i <= currentIndex
const isCurrent = i === currentIndex
const verb = VERB_META[frame.verb]
const kind = KIND_STYLE[frame.kind]
const showConnector = i < frames.length - 1
const showConnector = i < actions.length - 1
return (
<button
type="button"
key={
frame.dispatchId ??
`frame-${frame.kind}-${frame.verb}-${frame.t}-${i}`
}
onClick={() => onSelectFrame(frame)}
key={frame.dispatchId ?? `action-${action.sourceIndex}`}
onClick={() => onSelectAction(action)}
className={cn(
'flex gap-3 rounded-lg p-2.5 text-left transition-opacity',
isCurrent && 'bg-accent-tint',
Expand All @@ -67,7 +67,7 @@ export function EventTimeline({
{verb.label}
</span>
<span className="ml-auto font-mono text-[10.5px] text-ink-3">
{formatTime(frame.t)}
{formatTime(action.startAt)}
</span>
</div>
<p className="mt-0.5 text-ink-2 text-xs leading-snug">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { parseHTML } from 'linkedom'
import { act } from 'react'
import type { Root } from 'react-dom/client'
import { PlaybackTransport } from './PlaybackTransport'
import type { ReplayAction } from './session-replay'
import { usePlayback } from './use-playback'

const globalDescriptors = new Map(
Expand All @@ -14,14 +15,45 @@ const globalDescriptors = new Map(
let root: Root
let container: HTMLElement

const bookmarks: ReplayAction[] = [
{
frame: {
t: 6,
kind: 'block',
verb: 'read',
node: 'blocked',
caption: 'read: blocked',
dispatchId: 7,
},
startAt: 4,
completionAt: 6,
trackTabId: null,
sourceIndex: 0,
},
{
frame: {
t: 8,
kind: 'action',
verb: 'read',
node: 'plain',
caption: 'read: plain',
dispatchId: 8,
},
startAt: 8,
completionAt: 8,
trackTabId: null,
sourceIndex: 1,
},
]

function TransportHarness() {
const playback = usePlayback(10)
return (
<>
<PlaybackTransport
playback={playback}
totalSeconds={10}
frames={[]}
actions={bookmarks}
onSeek={playback.seek}
/>
<button type="button" data-command-pause onClick={playback.pause}>
Expand All @@ -33,7 +65,7 @@ function TransportHarness() {
<button
type="button"
data-command-finish
onClick={() => playback.syncFromPlayer(10)}
onClick={() => playback.seek(10)}
>
Finish
</button>
Expand All @@ -60,6 +92,12 @@ beforeEach(async () => {
value,
})
}
// linkedom ships no animation-frame API; the global clock needs one to run.
// Never fired here — these tests only exercise the transport's controls.
Object.assign(dom.window, {
requestAnimationFrame: () => 1,
cancelAnimationFrame: () => {},
})
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
writable: true,
Expand Down Expand Up @@ -145,4 +183,22 @@ describe('PlaybackTransport', () => {
expect(container.textContent).toContain('0:00 / 0:10')
expect(fourTimes?.getAttribute('aria-pressed')).toBe('true')
})

it('bookmarks non-action tools at their activity start', async () => {
await act(async () => root.render(<TransportHarness />))

const marks = [...container.querySelectorAll('button[aria-label^="Jump"]')]
expect(marks.map((mark) => mark.getAttribute('aria-label'))).toEqual([
'Jump to read: blocked',
])
// startAt 4 of 10 seconds, not the completion at 6.
expect(marks[0]?.getAttribute('style')?.replace(/\s/g, '')).toContain(
'left:40%',
)

await act(async () => {
marks[0]?.dispatchEvent(new window.Event('click', { bubbles: true }))
})
expect(container.textContent).toContain('0:04 / 0:10')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@ import { Pause, Play, RotateCcw } from 'lucide-react'
import type { ChangeEvent } from 'react'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
import type { ReplayFrame } from '@/modules/api/replay.hooks'
import { formatTime, KIND_STYLE, PLAYBACK_SPEEDS } from './replay.helpers'
import type { ReplayAction } from './session-replay'
import type { Playback } from './use-playback'

const SCRUBBER_STEP = 0.1

interface PlaybackTransportProps {
playback: Playback
/** Replayable activity for the whole session, not one tab's recording. */
totalSeconds: number
frames: readonly ReplayFrame[]
actions: readonly ReplayAction[]
onSeek: (seconds: number) => void
}

/**
* Play / pause + scrubber + speed picker. Non-action frames render as
* coloured bookmarks on the scrubber so the user can jump straight to
* a block or done moment.
* Play / pause + scrubber + speed picker for global activity time.
* Non-action tools render as coloured bookmarks on the scrubber so the user
* can jump straight to a block or done moment.
*/
export function PlaybackTransport({
playback,
totalSeconds,
frames,
actions,
onSeek,
}: PlaybackTransportProps) {
const { time, isPlaying, speed, setSpeed, togglePlay } = playback
Expand Down Expand Up @@ -62,20 +63,19 @@ export function PlaybackTransport({
className="absolute left-0 h-1.5 rounded-full bg-accent transition-[width] duration-100"
style={{ width: `${progress}%` }}
/>
{frames.map((frame, index) => {
{actions.map((action) => {
const { frame } = action
if (frame.kind === 'action') return null
const pct = totalSeconds === 0 ? 0 : (frame.t / totalSeconds) * 100
const pct =
totalSeconds === 0 ? 0 : (action.startAt / totalSeconds) * 100
const kind = KIND_STYLE[frame.kind]
return (
<button
type="button"
key={
frame.dispatchId ??
`bookmark-${frame.kind}-${frame.t}-${index}`
}
key={frame.dispatchId ?? `bookmark-${action.sourceIndex}`}
title={frame.caption}
aria-label={`Jump to ${frame.caption}`}
onClick={() => onSeek(frame.t)}
onClick={() => onSeek(action.startAt)}
style={{ left: `${pct}%` }}
className={cn(
'absolute z-10 size-2.5 -translate-x-1/2 rounded-full border-2 border-card shadow-sm',
Expand Down
Loading
Loading