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 f29a4f956a..e3766fd0ed 100644 --- a/src/components/ReplayProgressBar/index.scss +++ b/src/components/ReplayProgressBar/index.scss @@ -17,6 +17,10 @@ border-radius: 1px; } + &.replay-progress-bar--segmented { + 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. @@ -25,3 +29,87 @@ 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 { + 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) 70%, transparent); + } + + &[data-color-index="0"] { + background: color-mix( + in srgb, + var(--color-primary-6) 38%, + var(--color-fill-3) + ); + } + &[data-color-index="1"] { + background: color-mix( + in srgb, + var(--color-success-6) 36%, + var(--color-fill-3) + ); + } + &[data-color-index="2"] { + background: color-mix( + in srgb, + var(--color-warning-6) 36%, + var(--color-fill-3) + ); + } + &[data-color-index="3"] { + background: color-mix( + in srgb, + var(--color-purple-6) 36%, + var(--color-fill-3) + ); + } + &[data-color-index="4"] { + background: color-mix( + in srgb, + var(--color-danger-6) 30%, + var(--color-fill-3) + ); + } + &[data-color-index="5"] { + background: color-mix( + in srgb, + var(--color-neutral-6) 34%, + var(--color-fill-3) + ); + } + } +} diff --git a/src/components/ReplayProgressBar/index.tsx b/src/components/ReplayProgressBar/index.tsx index 7cc4fee8db..c745bc3a2b 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 ReplayTurnTimeline from "./ReplayTurnTimeline"; 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..e20290c700 --- /dev/null +++ b/src/components/ReplayProgressBar/types.ts @@ -0,0 +1,10 @@ +export interface ReplayProgressSegment { + id: string; + turnNumber: number; + leftPercent: number; + widthPercent: 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(