From 27e013aefc0b8ad5117c7e8005194a899d0e60b3 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 26 Aug 2026 19:33:33 +0800 Subject: [PATCH 1/4] feat(replay): add turn bands and pin-bar jump to WorkStation Color-code the simulator replay scrubber by chat turn and restore the hover navigate icon on turn collapse bars so users can orient and seek directly from the transcript. Pre-commit hook ran. Total eslint: 8, total circular: 0 --- .../ReplayTurnSegmentLane.tsx | 75 ++++++++ src/components/ReplayProgressBar/index.scss | 58 ++++++ src/components/ReplayProgressBar/index.tsx | 20 +- src/components/ReplayProgressBar/types.ts | 9 + .../components/TurnCollapsePinBar.tsx | 75 +++++--- .../blocks/primitives/EventNavigateIcon.tsx | 4 +- .../__tests__/replayTurnSegments.test.ts | 180 ++++++++++++++++++ .../SessionCore/replay/replayTurnSegments.ts | 149 +++++++++++++++ .../__tests__/replayTurnSegmentLabels.test.ts | 77 ++++++++ .../components/MusicPlayerReplayBar/index.tsx | 46 +++++ .../replayTurnSegmentLabels.ts | 47 +++++ src/i18n/locales/en/sessions.json | 3 + src/i18n/locales/zh/sessions.json | 3 + 13 files changed, 717 insertions(+), 29 deletions(-) create mode 100644 src/components/ReplayProgressBar/ReplayTurnSegmentLane.tsx create mode 100644 src/components/ReplayProgressBar/types.ts create mode 100644 src/engines/SessionCore/replay/__tests__/replayTurnSegments.test.ts create mode 100644 src/engines/SessionCore/replay/replayTurnSegments.ts create mode 100644 src/engines/Simulator/components/MusicPlayerReplayBar/__tests__/replayTurnSegmentLabels.test.ts create mode 100644 src/engines/Simulator/components/MusicPlayerReplayBar/replayTurnSegmentLabels.ts diff --git a/src/components/ReplayProgressBar/ReplayTurnSegmentLane.tsx b/src/components/ReplayProgressBar/ReplayTurnSegmentLane.tsx new file mode 100644 index 0000000000..4265b588b2 --- /dev/null +++ b/src/components/ReplayProgressBar/ReplayTurnSegmentLane.tsx @@ -0,0 +1,75 @@ +import React, { memo, useCallback } from "react"; + +import Tooltip from "@src/components/Tooltip"; + +import type { ReplayProgressSegment } from "./types"; + +export interface ReplayTurnSegmentLaneProps { + segments: readonly ReplayProgressSegment[]; + max: number; + onSegmentClick?: (segment: ReplayProgressSegment) => void; +} + +function getSegmentBandSpan( + segment: ReplayProgressSegment, + nextSegment: ReplayProgressSegment | undefined, + max: number +): { widthPercent: number } { + if (max <= 0) return { widthPercent: 0 }; + + const displayEnd = nextSegment?.startValue ?? max; + const rawWidth = ((displayEnd - segment.startValue) / max) * 100; + return { + widthPercent: Math.max(rawWidth, 0.75), + }; +} + +const ReplayTurnSegmentLane: React.FC = memo( + ({ segments, max, onSegmentClick }) => { + const handleClick = useCallback( + (segment: ReplayProgressSegment) => (event: React.MouseEvent) => { + event.stopPropagation(); + onSegmentClick?.(segment); + }, + [onSegmentClick] + ); + + if (segments.length <= 1) return null; + + return ( +
+ {segments.map((segment, index) => { + const { widthPercent } = getSegmentBandSpan( + segment, + segments[index + 1], + max + ); + + return ( + +
+ ); + } +); + +ReplayTurnSegmentLane.displayName = "ReplayTurnSegmentLane"; + +export default ReplayTurnSegmentLane; diff --git a/src/components/ReplayProgressBar/index.scss b/src/components/ReplayProgressBar/index.scss index f29a4f956a..8a577f5be6 100644 --- a/src/components/ReplayProgressBar/index.scss +++ b/src/components/ReplayProgressBar/index.scss @@ -17,6 +17,64 @@ border-radius: 1px; } + &.replay-progress-bar--segmented { + padding-bottom: 2px; + } + + &__segment { + opacity: 0.72; + border-radius: 1px; + + &[data-active="true"] { + opacity: 1; + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--color-primary-6) 55%, transparent); + } + + &[data-color-index="0"] { + background: color-mix( + in srgb, + var(--color-primary-6) 22%, + var(--color-fill-2) + ); + } + &[data-color-index="1"] { + background: color-mix( + in srgb, + var(--color-success-6) 22%, + var(--color-fill-2) + ); + } + &[data-color-index="2"] { + background: color-mix( + in srgb, + var(--color-warning-6) 22%, + var(--color-fill-2) + ); + } + &[data-color-index="3"] { + background: color-mix( + in srgb, + var(--color-purple-6) 22%, + var(--color-fill-2) + ); + } + &[data-color-index="4"] { + background: color-mix( + in srgb, + var(--color-danger-6) 18%, + var(--color-fill-2) + ); + } + &[data-color-index="5"] { + background: color-mix( + in srgb, + var(--color-neutral-6) 24%, + var(--color-fill-2) + ); + } + } + // In follow mode the playhead is pinned to the right edge, so showing // the drag handle would just be visual noise. Hide it. In replay mode // the handle is always visible for easy scrubbing. diff --git a/src/components/ReplayProgressBar/index.tsx b/src/components/ReplayProgressBar/index.tsx index 7cc4fee8db..019cfb7e55 100644 --- a/src/components/ReplayProgressBar/index.tsx +++ b/src/components/ReplayProgressBar/index.tsx @@ -24,7 +24,9 @@ import React, { memo } from "react"; import Slider from "@src/components/Slider"; +import ReplayTurnSegmentLane from "./ReplayTurnSegmentLane"; import "./index.scss"; +import type { ReplayProgressSegment } from "./types"; export interface ReplayProgressBarProps { /** Current slider position in [0, max]. */ @@ -43,6 +45,10 @@ export interface ReplayProgressBarProps { ariaLabel?: string; /** Optional extra class for the root (e.g. for caller-specific z-index). */ className?: string; + /** Turn bands rendered beneath the scrubber rail. */ + segments?: readonly ReplayProgressSegment[]; + /** Seek to the start of a turn band. */ + onSegmentClick?: (segment: ReplayProgressSegment) => void; } const ReplayProgressBar: React.FC = memo( @@ -55,10 +61,14 @@ const ReplayProgressBar: React.FC = memo( disabled = false, ariaLabel, className, + segments, + onSegmentClick, }) => { + const showSegments = segments && segments.length > 1; + return (
= memo( {/* Right edge fill — 1px to match the rail (non-blue). Anchored at top:0 so its top edge aligns with the rail's top edge. */}
+ + {showSegments ? ( + + ) : null}
); } diff --git a/src/components/ReplayProgressBar/types.ts b/src/components/ReplayProgressBar/types.ts new file mode 100644 index 0000000000..4cb543056d --- /dev/null +++ b/src/components/ReplayProgressBar/types.ts @@ -0,0 +1,9 @@ +export interface ReplayProgressSegment { + id: string; + startValue: number; + endValue: number; + colorIndex: number; + tooltip: string; + ariaLabel: string; + isActive?: boolean; +} diff --git a/src/engines/ChatPanel/InputArea/components/TurnCollapsePinBar.tsx b/src/engines/ChatPanel/InputArea/components/TurnCollapsePinBar.tsx index 3531c4d261..9df32bb74d 100644 --- a/src/engines/ChatPanel/InputArea/components/TurnCollapsePinBar.tsx +++ b/src/engines/ChatPanel/InputArea/components/TurnCollapsePinBar.tsx @@ -20,13 +20,19 @@ * Completed turns are collapsed by default; the override atom only * records explicit user toggles. The currently active (tail) turn is * never collapsed while the agent is still streaming. + * + * Hover reveals a navigate icon that jumps to this turn in WorkStation replay. + * Hidden inside the Simulator Messages replay surface (no-op jump). */ import { useAtomValue, useSetAtom } from "jotai"; import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"; -import React, { memo, useCallback, useState } from "react"; +import React, { memo, useCallback, useContext, useState } from "react"; import { useTranslation } from "react-i18next"; import { getTurnTimingLabels } from "@src/engines/ChatPanel/ChatHistory/utils/turnTimingFormatting"; +import EventNavigateIcon from "@src/engines/ChatPanel/blocks/primitives/EventNavigateIcon"; +import { InSimulatorReplayContext } from "@src/engines/ChatPanel/blocks/primitives/inSimulatorReplayContext"; +import { useChatEventReplay } from "@src/engines/ChatPanel/hooks/useChatEventReplay"; import { createLogger } from "@src/hooks/logger"; import { collapseAllCommandAtom, @@ -71,10 +77,13 @@ const TurnCollapsePinBar: React.FC = memo( onExpand, }) => { const { t } = useTranslation("sessions"); + const inSimulatorReplay = useContext(InSimulatorReplayContext); + const { replayEventById, canReplay } = useChatEventReplay(); const overrideMap = useAtomValue(turnCollapseOverrideAtom); const collapseAllCommand = useAtomValue(collapseAllCommandAtom); const setOverride = useSetAtom(setTurnCollapseOverrideAtom); const [isLoading, setIsLoading] = useState(false); + const showReplayNavigate = canReplay && !inSimulatorReplay; const override = overrideMap.get(turnId); const forcedCollapsed = @@ -125,6 +134,10 @@ const TurnCollapsePinBar: React.FC = memo( turnId, ]); + const handleReplayNavigate = useCallback(() => { + replayEventById(turnId); + }, [replayEventById, turnId]); + const labelKey = labelVariant === "agents" ? "tools.turnCollapse.agentsWorkedFor" @@ -148,33 +161,41 @@ const TurnCollapsePinBar: React.FC = memo( return (
- + {showRange && ( + + {rangeLabel} + + )} + + + {showReplayNavigate ? ( + + ) : null} +
); diff --git a/src/engines/ChatPanel/blocks/primitives/EventNavigateIcon.tsx b/src/engines/ChatPanel/blocks/primitives/EventNavigateIcon.tsx index 901b404c53..c4889fa87a 100644 --- a/src/engines/ChatPanel/blocks/primitives/EventNavigateIcon.tsx +++ b/src/engines/ChatPanel/blocks/primitives/EventNavigateIcon.tsx @@ -26,10 +26,11 @@ export interface EventNavigateIconProps { onClick: () => void; /** "header" hides until header hover; "footer" is always visible; "footer-hover" hides until agent-message hover. */ variant?: "header" | "footer" | "footer-hover"; + ariaLabel?: string; } const EventNavigateIcon: React.FC = memo( - ({ onClick, variant = "header" }) => { + ({ onClick, variant = "header", ariaLabel }) => { const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); onClick(); @@ -49,6 +50,7 @@ const EventNavigateIcon: React.FC = memo(
- ); - } -); - -ReplayTurnSegmentLane.displayName = "ReplayTurnSegmentLane"; - -export default ReplayTurnSegmentLane; diff --git a/src/components/ReplayProgressBar/ReplayTurnTimeline.tsx b/src/components/ReplayProgressBar/ReplayTurnTimeline.tsx new file mode 100644 index 0000000000..7a80aceaf4 --- /dev/null +++ b/src/components/ReplayProgressBar/ReplayTurnTimeline.tsx @@ -0,0 +1,65 @@ +import React, { memo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import Tooltip from "@src/components/Tooltip"; + +import type { ReplayProgressSegment } from "./types"; + +export interface ReplayTurnTimelineProps { + segments: readonly ReplayProgressSegment[]; + onSegmentClick?: (segment: ReplayProgressSegment) => void; +} + +const ReplayTurnTimeline: React.FC = memo( + ({ segments, onSegmentClick }) => { + const { t } = useTranslation("sessions"); + + const handleClick = useCallback( + (segment: ReplayProgressSegment) => (event: React.MouseEvent) => { + event.stopPropagation(); + onSegmentClick?.(segment); + }, + [onSegmentClick] + ); + + if (segments.length <= 1) return null; + + return ( +
+
+ {segments.map((segment) => ( + +
+
+ ); + } +); + +ReplayTurnTimeline.displayName = "ReplayTurnTimeline"; + +export default ReplayTurnTimeline; diff --git a/src/components/ReplayProgressBar/index.scss b/src/components/ReplayProgressBar/index.scss index 8a577f5be6..e3766fd0ed 100644 --- a/src/components/ReplayProgressBar/index.scss +++ b/src/components/ReplayProgressBar/index.scss @@ -18,68 +18,98 @@ } &.replay-progress-bar--segmented { - padding-bottom: 2px; + padding-bottom: 0; + } + + // In follow mode the playhead is pinned to the right edge, so showing + // the drag handle would just be visual noise. Hide it. In replay mode + // the handle is always visible for easy scrubbing. + &[data-follow-mode="true"] .slider-handle { + opacity: 0; + pointer-events: none; + } +} + +.replay-turn-timeline { + margin-top: 6px; + overflow: visible; + + &__track { + position: relative; + height: 6px; + overflow: hidden; + border-radius: 999px; + background: var(--color-fill-2); } &__segment { - opacity: 0.72; - border-radius: 1px; + position: absolute; + top: 0; + height: 100%; + min-width: 3px; + border: 0; + padding: 0; + opacity: 0.55; + border-radius: 999px; + transition: + opacity 120ms ease, + transform 120ms ease, + box-shadow 120ms ease; + + &:hover, + &:focus-visible { + opacity: 0.95; + transform: scaleY(1.08); + z-index: 1; + } &[data-active="true"] { opacity: 1; box-shadow: inset 0 0 0 1px - color-mix(in srgb, var(--color-primary-6) 55%, transparent); + color-mix(in srgb, var(--color-primary-6) 70%, transparent); } &[data-color-index="0"] { background: color-mix( in srgb, - var(--color-primary-6) 22%, - var(--color-fill-2) + var(--color-primary-6) 38%, + var(--color-fill-3) ); } &[data-color-index="1"] { background: color-mix( in srgb, - var(--color-success-6) 22%, - var(--color-fill-2) + var(--color-success-6) 36%, + var(--color-fill-3) ); } &[data-color-index="2"] { background: color-mix( in srgb, - var(--color-warning-6) 22%, - var(--color-fill-2) + var(--color-warning-6) 36%, + var(--color-fill-3) ); } &[data-color-index="3"] { background: color-mix( in srgb, - var(--color-purple-6) 22%, - var(--color-fill-2) + var(--color-purple-6) 36%, + var(--color-fill-3) ); } &[data-color-index="4"] { background: color-mix( in srgb, - var(--color-danger-6) 18%, - var(--color-fill-2) + var(--color-danger-6) 30%, + var(--color-fill-3) ); } &[data-color-index="5"] { background: color-mix( in srgb, - var(--color-neutral-6) 24%, - var(--color-fill-2) + var(--color-neutral-6) 34%, + var(--color-fill-3) ); } } - - // In follow mode the playhead is pinned to the right edge, so showing - // the drag handle would just be visual noise. Hide it. In replay mode - // the handle is always visible for easy scrubbing. - &[data-follow-mode="true"] .slider-handle { - opacity: 0; - pointer-events: none; - } } diff --git a/src/components/ReplayProgressBar/index.tsx b/src/components/ReplayProgressBar/index.tsx index 019cfb7e55..c745bc3a2b 100644 --- a/src/components/ReplayProgressBar/index.tsx +++ b/src/components/ReplayProgressBar/index.tsx @@ -24,7 +24,7 @@ import React, { memo } from "react"; import Slider from "@src/components/Slider"; -import ReplayTurnSegmentLane from "./ReplayTurnSegmentLane"; +import ReplayTurnTimeline from "./ReplayTurnTimeline"; import "./index.scss"; import type { ReplayProgressSegment } from "./types"; @@ -106,9 +106,8 @@ const ReplayProgressBar: React.FC = memo(
{showSegments ? ( - ) : null} diff --git a/src/components/ReplayProgressBar/types.ts b/src/components/ReplayProgressBar/types.ts index 4cb543056d..e20290c700 100644 --- a/src/components/ReplayProgressBar/types.ts +++ b/src/components/ReplayProgressBar/types.ts @@ -1,7 +1,8 @@ export interface ReplayProgressSegment { id: string; - startValue: number; - endValue: number; + turnNumber: number; + leftPercent: number; + widthPercent: number; colorIndex: number; tooltip: string; ariaLabel: string; diff --git a/src/engines/SessionCore/replay/__tests__/replayTurnSegments.test.ts b/src/engines/SessionCore/replay/__tests__/replayTurnSegments.test.ts index 6a6394dc85..37ed1a7240 100644 --- a/src/engines/SessionCore/replay/__tests__/replayTurnSegments.test.ts +++ b/src/engines/SessionCore/replay/__tests__/replayTurnSegments.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { SimulatorEventPreview } from "../../core/types"; import { + applyReplayTurnSegmentLayout, buildReplayTurnSegments, findActiveReplayTurnSegment, indexToReplaySliderValue, @@ -21,8 +22,8 @@ function preview( displayText: "Read file", displayStatus: "completed", displayVariant: "tool_call", - activityStatus: "agent", - filterCategory: "explore", + activityStatus: "completed", + filterCategory: "file", ...overrides, }; } @@ -112,7 +113,50 @@ describe("buildReplayTurnSegments", () => { startIndex: 4, endIndex: 4, endValue: 200, + leftPercent: 100, + widthPercent: 0.75, }); + expect(segments[0]?.leftPercent).toBe(0); + expect(segments[0]?.widthPercent).toBeGreaterThan(0); + }); + + it("precomputes band layout percentages", () => { + const segments = applyReplayTurnSegmentLayout( + [ + { + turnId: "u1", + turnNumber: 1, + startIndex: 0, + endIndex: 1, + startMs: null, + endMs: null, + durationMs: 0, + startValue: 0, + endValue: 100, + colorIndex: 0, + leftPercent: 0, + widthPercent: 0, + }, + { + turnId: "u2", + turnNumber: 2, + startIndex: 2, + endIndex: 3, + startMs: null, + endMs: null, + durationMs: 0, + startValue: 100, + endValue: 200, + colorIndex: 1, + leftPercent: 0, + widthPercent: 0, + }, + ], + 200 + ); + + expect(segments[0]).toMatchObject({ leftPercent: 0, widthPercent: 50 }); + expect(segments[1]).toMatchObject({ leftPercent: 50, widthPercent: 50 }); }); it("merges tiny trailing segments into the previous band", () => { diff --git a/src/engines/SessionCore/replay/replayTurnSegments.ts b/src/engines/SessionCore/replay/replayTurnSegments.ts index 3263942df9..750bc773ca 100644 --- a/src/engines/SessionCore/replay/replayTurnSegments.ts +++ b/src/engines/SessionCore/replay/replayTurnSegments.ts @@ -17,6 +17,9 @@ export interface ReplayTurnSegment { startValue: number; endValue: number; colorIndex: number; + /** Pre-computed layout for the segment band (% of track width). */ + leftPercent: number; + widthPercent: number; } export interface BuildReplayTurnSegmentsInput { @@ -76,6 +79,25 @@ function mergeTinyReplayTurnSegments( return merged; } +/** Assign stable band geometry once segments and merges are finalized. */ +export function applyReplayTurnSegmentLayout( + segments: ReplayTurnSegment[], + maxValue: number +): ReplayTurnSegment[] { + if (maxValue <= 0 || segments.length === 0) return segments; + + return segments.map((segment, index) => { + const displayEnd = segments[index + 1]?.startValue ?? maxValue; + const leftPercent = (segment.startValue / maxValue) * 100; + const rawWidth = ((displayEnd - segment.startValue) / maxValue) * 100; + return { + ...segment, + leftPercent, + widthPercent: Math.max(rawWidth, 0.75), + }; + }); +} + /** * Partition the effective simulator timeline into turn bands for the replay * scrubber. Turn boundaries follow the same user-message rule as chat grouping. @@ -128,11 +150,16 @@ export function buildReplayTurnSegments( ), endValue: indexToReplaySliderValue(endIndex, eventIds.length, maxValue), colorIndex: turnOffset % REPLAY_TURN_SEGMENT_COLOR_COUNT, + leftPercent: 0, + widthPercent: 0, }; } ); - return mergeTinyReplayTurnSegments(segments, minSegmentSpan, maxValue); + return applyReplayTurnSegmentLayout( + mergeTinyReplayTurnSegments(segments, minSegmentSpan, maxValue), + maxValue + ); } export function findActiveReplayTurnSegment( diff --git a/src/engines/Simulator/components/MusicPlayerReplayBar/__tests__/replayTurnSegmentLabels.test.ts b/src/engines/Simulator/components/MusicPlayerReplayBar/__tests__/replayTurnSegmentLabels.test.ts index 74892afe13..7437020d9f 100644 --- a/src/engines/Simulator/components/MusicPlayerReplayBar/__tests__/replayTurnSegmentLabels.test.ts +++ b/src/engines/Simulator/components/MusicPlayerReplayBar/__tests__/replayTurnSegmentLabels.test.ts @@ -1,4 +1,3 @@ -import type { TFunction } from "i18next"; import { describe, expect, it, vi } from "vitest"; import { @@ -15,9 +14,9 @@ describe("formatReplayTurnSegmentLabels", () => { return `Replay turn ${params?.number}`; } return key; - }) as unknown as TFunction<"sessions">; + }); - it("formats tooltip with wall-clock range", () => { + it("formats tooltip and aria labels", () => { const labels = formatReplayTurnSegmentLabels( { turnId: "u1", @@ -30,6 +29,8 @@ describe("formatReplayTurnSegmentLabels", () => { startValue: 0, endValue: 100, colorIndex: 1, + leftPercent: 0, + widthPercent: 50, }, t ); @@ -40,7 +41,7 @@ describe("formatReplayTurnSegmentLabels", () => { }); describe("toReplayProgressSegments", () => { - it("marks the active turn segment", () => { + it("marks the active turn segment and preserves layout", () => { const segments = toReplayProgressSegments( [ { @@ -54,6 +55,8 @@ describe("toReplayProgressSegments", () => { startValue: 0, endValue: 0, colorIndex: 0, + leftPercent: 0, + widthPercent: 50, }, { turnId: "u2", @@ -66,13 +69,16 @@ describe("toReplayProgressSegments", () => { startValue: 200, endValue: 200, colorIndex: 1, + leftPercent: 50, + widthPercent: 50, }, ], "u2", - ((key: string) => key) as unknown as TFunction<"sessions"> + (key) => key ); expect(segments[0]?.isActive).toBe(false); expect(segments[1]?.isActive).toBe(true); + expect(segments[1]?.leftPercent).toBe(50); }); }); diff --git a/src/engines/Simulator/components/MusicPlayerReplayBar/index.tsx b/src/engines/Simulator/components/MusicPlayerReplayBar/index.tsx index 1aa7ee5733..6059e5d006 100644 --- a/src/engines/Simulator/components/MusicPlayerReplayBar/index.tsx +++ b/src/engines/Simulator/components/MusicPlayerReplayBar/index.tsx @@ -117,14 +117,23 @@ const MusicPlayerReplayBar: React.FC = memo(() => { [eventIds, previewById] ); + const segmentViews = useMemo( + () => toReplayProgressSegments(turnSegments, null, t), + [t, turnSegments] + ); + const activeTurn = useMemo( () => findActiveReplayTurnSegment(turnSegments, currentIndex), [turnSegments, currentIndex] ); const segments = useMemo( - () => toReplayProgressSegments(turnSegments, activeTurn?.turnId ?? null, t), - [activeTurn?.turnId, t, turnSegments] + () => + segmentViews.map((segment) => ({ + ...segment, + isActive: segment.id === activeTurn?.turnId, + })), + [activeTurn?.turnId, segmentViews] ); const handleSegmentClick = useCallback( diff --git a/src/engines/Simulator/components/MusicPlayerReplayBar/replayTurnSegmentLabels.ts b/src/engines/Simulator/components/MusicPlayerReplayBar/replayTurnSegmentLabels.ts index 9cdcc37fad..c39d36fc7d 100644 --- a/src/engines/Simulator/components/MusicPlayerReplayBar/replayTurnSegmentLabels.ts +++ b/src/engines/Simulator/components/MusicPlayerReplayBar/replayTurnSegmentLabels.ts @@ -38,8 +38,9 @@ export function toReplayProgressSegments( ): ReplayProgressSegment[] { return segments.map((segment) => ({ id: segment.turnId, - startValue: segment.startValue, - endValue: segment.endValue, + turnNumber: segment.turnNumber, + leftPercent: segment.leftPercent, + widthPercent: segment.widthPercent, colorIndex: segment.colorIndex, isActive: activeTurnId === segment.turnId, ...formatReplayTurnSegmentLabels(segment, t), diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 1c62401858..9a8691c14f 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -1384,6 +1384,7 @@ "play": "Play replay", "duration": "{{value}}", "segmentAria": "Replay turn {{number}}", + "turnTrackAria": "Replay turn track", "segmentTooltip": "Turn {{number}} · {{duration}} · {{start}}–{{end}}", "segmentTooltipNoRange": "Turn {{number}} · {{duration}}", "toolCalls": "{{count}} tool calls", diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index 113c04f26a..6eea72e1f6 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -1354,6 +1354,7 @@ "play": "播放回放", "duration": "{{value}}", "segmentAria": "回放第 {{number}} 轮", + "turnTrackAria": "Replay 轮次色带", "segmentTooltip": "第 {{number}} 轮 · {{duration}} · {{start}}–{{end}}", "segmentTooltipNoRange": "第 {{number}} 轮 · {{duration}}", "toolCalls": "{{count}} 次工具调用", From 1d0bd12cad7114ccce45b6c8adb227ace21deab4 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:54:10 +0800 Subject: [PATCH 4/4] test(replay): preserve fixture compatibility after timeline rewrite Keep replay preview enums and i18n test doubles aligned with current shared types after the timeline-track test expansion. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../SessionCore/replay/__tests__/replayTurnSegments.test.ts | 4 ++-- .../__tests__/replayTurnSegmentLabels.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/engines/SessionCore/replay/__tests__/replayTurnSegments.test.ts b/src/engines/SessionCore/replay/__tests__/replayTurnSegments.test.ts index 37ed1a7240..db90569792 100644 --- a/src/engines/SessionCore/replay/__tests__/replayTurnSegments.test.ts +++ b/src/engines/SessionCore/replay/__tests__/replayTurnSegments.test.ts @@ -22,8 +22,8 @@ function preview( displayText: "Read file", displayStatus: "completed", displayVariant: "tool_call", - activityStatus: "completed", - filterCategory: "file", + activityStatus: "agent", + filterCategory: "explore", ...overrides, }; } diff --git a/src/engines/Simulator/components/MusicPlayerReplayBar/__tests__/replayTurnSegmentLabels.test.ts b/src/engines/Simulator/components/MusicPlayerReplayBar/__tests__/replayTurnSegmentLabels.test.ts index 7437020d9f..137a97ac5c 100644 --- a/src/engines/Simulator/components/MusicPlayerReplayBar/__tests__/replayTurnSegmentLabels.test.ts +++ b/src/engines/Simulator/components/MusicPlayerReplayBar/__tests__/replayTurnSegmentLabels.test.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import { describe, expect, it, vi } from "vitest"; import { @@ -14,7 +15,7 @@ describe("formatReplayTurnSegmentLabels", () => { return `Replay turn ${params?.number}`; } return key; - }); + }) as unknown as TFunction<"sessions">; it("formats tooltip and aria labels", () => { const labels = formatReplayTurnSegmentLabels( @@ -74,7 +75,7 @@ describe("toReplayProgressSegments", () => { }, ], "u2", - (key) => key + ((key: string) => key) as unknown as TFunction<"sessions"> ); expect(segments[0]?.isActive).toBe(false);