Skip to content

Commit c0117f6

Browse files
fahchenclaude
andcommitted
feat(dashboard): timeline jump-to-v0 + jump-to-live icon buttons
Compact icon-only controls book-ending the existing step-back / step- forward arrows. The four-button cluster reads `⏮ ◀ ▶ ⏭` left to right. - timeline-jump-v0 — Phosphor SkipBack icon. Disabled at min. Calls fireImmediate(min) and pauses autoplay. - timeline-jump-live — Phosphor SkipForward icon. In replay calls onExit() (drops the in-banner "Return to live" duplicate). In live mode at non-max version, fireImmediate(max). Sizes match the existing step buttons (size-sm secondary). Layout stays a single row; thumb + play + speed unchanged. EnactmentDetailPage's existing `:exit_replay` test repointed at the new timeline-jump-live testid. Tests: TimelineScrubber +4 cases (jump-v0 fires onScrub, disabled at min, jump-live in live disabled at max, jump-live in replay calls onExit). Vitest 161 → 166. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f94e507 commit c0117f6

3 files changed

Lines changed: 102 additions & 13 deletions

File tree

dashboard/ui/src/components/TimelineScrubber.test.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,69 @@ describe("TimelineScrubber autoplay", () => {
203203
expect(slider.dataset.autoplaying).toBe("false")
204204
})
205205

206+
it("Jump-to-v0 fires onScrub(min) and disables at min", () => {
207+
const { onScrub } = renderScrubber({
208+
replayState: { version: 3, replayed_from: 3 } as never
209+
})
210+
const btn = screen.getByTestId("timeline-jump-v0") as HTMLButtonElement
211+
expect(btn.disabled).toBe(false)
212+
act(() => {
213+
btn.click()
214+
})
215+
expect(onScrub).toHaveBeenCalledTimes(1)
216+
expect(onScrub).toHaveBeenLastCalledWith(1)
217+
})
218+
219+
it("Jump-to-v0 disabled when value is already at min", () => {
220+
renderScrubber({ replayState: { version: 1, replayed_from: 1 } as never })
221+
expect((screen.getByTestId("timeline-jump-v0") as HTMLButtonElement).disabled).toBe(true)
222+
})
223+
224+
it("Jump-to-live in live mode at non-max fires onScrub(max)", () => {
225+
const onScrub = vi.fn()
226+
const onExit = vi.fn()
227+
render(
228+
<TimelineScrubber
229+
range={{ min: 1, max: 5 }}
230+
liveVersion={5}
231+
replayState={null}
232+
onScrub={onScrub}
233+
onExit={onExit}
234+
isPending={false}
235+
/>
236+
)
237+
// value initializes to liveVersion (5) which equals max → disabled.
238+
const btn = screen.getByTestId("timeline-jump-live") as HTMLButtonElement
239+
expect(btn.disabled).toBe(true)
240+
expect(onScrub).not.toHaveBeenCalled()
241+
expect(onExit).not.toHaveBeenCalled()
242+
})
243+
244+
it("Jump-to-live in replay mode calls onExit (exit_replay path)", () => {
245+
const { onScrub, onExit } = renderScrubber()
246+
const btn = screen.getByTestId("timeline-jump-live") as HTMLButtonElement
247+
expect(btn.disabled).toBe(false)
248+
act(() => {
249+
btn.click()
250+
})
251+
expect(onExit).toHaveBeenCalledTimes(1)
252+
expect(onScrub).not.toHaveBeenCalled()
253+
})
254+
255+
it("Step buttons remain present and functional alongside jump buttons", () => {
256+
const { onScrub } = renderScrubber()
257+
expect(screen.getByTestId("timeline-step-back")).toBeDefined()
258+
expect(screen.getByTestId("timeline-step-forward")).toBeDefined()
259+
act(() => {
260+
screen.getByTestId("timeline-step-forward").click()
261+
})
262+
expect(onScrub).toHaveBeenLastCalledWith(2)
263+
act(() => {
264+
screen.getByTestId("timeline-step-back").click()
265+
})
266+
expect(onScrub).toHaveBeenLastCalledWith(1)
267+
})
268+
206269
it("Home / End jump to extremes via the slider", () => {
207270
const { onScrub } = renderScrubber()
208271
const slider = screen.getByTestId("timeline-slider")

dashboard/ui/src/components/TimelineScrubber.tsx

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
CaretLeftIcon,
55
CaretRightIcon,
66
PauseIcon,
7-
PlayIcon
7+
PlayIcon,
8+
SkipBackIcon,
9+
SkipForwardIcon
810
} from "@phosphor-icons/react"
911

1012
type VersionRange = ColouredFlowDashboardWeb.Views.VersionRange
@@ -234,19 +236,24 @@ export default function TimelineScrubber({
234236
End of timeline
235237
</span>
236238
) : null}
237-
{replayActive ? (
238-
<Button
239-
variant="secondary"
240-
size="sm"
241-
onClick={onExit}
242-
data-testid="timeline-exit-replay"
243-
>
244-
Return to live
245-
</Button>
246-
) : null}
247239
</div>
248240
</div>
249241
<div className="flex items-center gap-2">
242+
<Button
243+
variant="secondary"
244+
size="sm"
245+
onClick={() => {
246+
if (value === min) return
247+
setValue(min)
248+
fireImmediate(min)
249+
if (playing) setPlaying(false)
250+
}}
251+
disabled={disabled || value <= min || isPending}
252+
aria-label="Jump to v0"
253+
data-testid="timeline-jump-v0"
254+
>
255+
<SkipBackIcon size={14} weight="bold" aria-hidden />
256+
</Button>
250257
<Button
251258
variant="secondary"
252259
size="sm"
@@ -288,6 +295,25 @@ export default function TimelineScrubber({
288295
>
289296
<CaretRightIcon size={14} weight="bold" aria-hidden />
290297
</Button>
298+
<Button
299+
variant="secondary"
300+
size="sm"
301+
onClick={() => {
302+
if (replayActive) {
303+
onExit()
304+
return
305+
}
306+
if (value === max) return
307+
setValue(max)
308+
fireImmediate(max)
309+
if (playing) setPlaying(false)
310+
}}
311+
disabled={disabled || (!replayActive && value >= max) || isPending}
312+
aria-label="Jump to live"
313+
data-testid="timeline-jump-live"
314+
>
315+
<SkipForwardIcon size={14} weight="bold" aria-hidden />
316+
</Button>
291317
<Button
292318
variant={playing ? "secondary" : "primary"}
293319
size="sm"

dashboard/ui/src/routes/EnactmentDetailPage.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -945,7 +945,7 @@ describe("EnactmentDetailPage", () => {
945945
}
946946
})
947947

948-
it("Return to live button dispatches :exit_replay", async () => {
948+
it("Jump-to-live button dispatches :exit_replay while in replay", async () => {
949949
const restore = mutateReplay(
950950
{ version: 1, derived_at: "2026-05-29T01:00:00Z" },
951951
{ min: 0, max: 3 }
@@ -954,7 +954,7 @@ describe("EnactmentDetailPage", () => {
954954
try {
955955
renderRoute(<EnactmentDetailPage />)
956956
await act(async () => {
957-
fireEvent.click(screen.getByTestId("timeline-exit-replay"))
957+
fireEvent.click(screen.getByTestId("timeline-jump-live"))
958958
})
959959
expect(exitReplayMock).toHaveBeenCalledOnce()
960960
} finally {

0 commit comments

Comments
 (0)