Skip to content
Open
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
65 changes: 65 additions & 0 deletions src/components/ReplayProgressBar/ReplayTurnTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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<ReplayTurnTimelineProps> = 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 (
<div className="replay-turn-timeline">
<div
className="replay-turn-timeline__track mx-2"
role="list"
aria-label={t("tools.replay.turnTrackAria")}
>
{segments.map((segment) => (
<Tooltip
key={segment.id}
content={segment.tooltip}
position="bottom"
mouseEnterDelay={80}
>
<button
type="button"
role="listitem"
data-testid="replay-turn-segment"
data-active={segment.isActive ? "true" : undefined}
data-color-index={segment.colorIndex % 6}
aria-label={segment.ariaLabel}
className="replay-turn-timeline__segment"
style={{
left: `${segment.leftPercent}%`,
width: `${segment.widthPercent}%`,
}}
onClick={handleClick(segment)}
/>
</Tooltip>
))}
</div>
</div>
);
}
);

ReplayTurnTimeline.displayName = "ReplayTurnTimeline";

export default ReplayTurnTimeline;
88 changes: 88 additions & 0 deletions src/components/ReplayProgressBar/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
);
}
}
}
19 changes: 18 additions & 1 deletion src/components/ReplayProgressBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]. */
Expand All @@ -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<ReplayProgressBarProps> = memo(
Expand All @@ -55,10 +61,14 @@ const ReplayProgressBar: React.FC<ReplayProgressBarProps> = memo(
disabled = false,
ariaLabel,
className,
segments,
onSegmentClick,
}) => {
const showSegments = segments && segments.length > 1;

return (
<div
className={`replay-progress-bar relative z-40 w-full overflow-visible ${className ?? ""}`}
className={`replay-progress-bar relative z-40 w-full overflow-visible ${showSegments ? "replay-progress-bar--segmented" : ""} ${className ?? ""}`}
role="group"
aria-label={ariaLabel}
data-follow-mode={isFollowMode ? "true" : undefined}
Expand Down Expand Up @@ -94,6 +104,13 @@ const ReplayProgressBar: React.FC<ReplayProgressBarProps> = 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. */}
<div className="absolute right-0 top-0 h-[1px] w-2 bg-fill-3" />

{showSegments ? (
<ReplayTurnTimeline
segments={segments}
onSegmentClick={onSegmentClick}
/>
) : null}
</div>
);
}
Expand Down
10 changes: 10 additions & 0 deletions src/components/ReplayProgressBar/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface ReplayProgressSegment {
id: string;
turnNumber: number;
leftPercent: number;
widthPercent: number;
colorIndex: number;
tooltip: string;
ariaLabel: string;
isActive?: boolean;
}
75 changes: 48 additions & 27 deletions src/engines/ChatPanel/InputArea/components/TurnCollapsePinBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,10 +77,13 @@ const TurnCollapsePinBar: React.FC<TurnCollapsePinBarProps> = 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 =
Expand Down Expand Up @@ -125,6 +134,10 @@ const TurnCollapsePinBar: React.FC<TurnCollapsePinBarProps> = memo(
turnId,
]);

const handleReplayNavigate = useCallback(() => {
replayEventById(turnId);
}, [replayEventById, turnId]);

const labelKey =
labelVariant === "agents"
? "tools.turnCollapse.agentsWorkedFor"
Expand All @@ -148,33 +161,41 @@ const TurnCollapsePinBar: React.FC<TurnCollapsePinBarProps> = memo(

return (
<div className="mt-1">
<button
type="button"
aria-expanded={expanded}
className="group/turn-collapse chat-block-header flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent px-2 text-left transition-colors hover:bg-fill-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-6/30"
onClick={(event) => {
event.stopPropagation();
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
void handleToggle();
}}
>
<ChevronIcon
size={CHEVRON_SIZE}
strokeWidth={1.75}
className="shrink-0 text-text-2 transition-colors group-hover/turn-collapse:text-text-1"
/>
<span className="inline-flex min-w-0 flex-1 items-center gap-2 leading-tight">
<span className="shrink-0 select-text whitespace-nowrap font-medium text-text-2 transition-colors group-hover/turn-collapse:text-text-1">
{label}
</span>
{showRange && (
<span className="min-w-0 select-text truncate text-text-3">
{rangeLabel}
<div className="group/turn-collapse group/chat-block-header chat-block-header flex h-8 w-full items-center gap-1 rounded-lg px-2 transition-colors hover:bg-fill-2">
<button
type="button"
aria-expanded={expanded}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 border-0 bg-transparent px-0 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-6/30"
onClick={(event) => {
event.stopPropagation();
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
void handleToggle();
}}
>
<ChevronIcon
size={CHEVRON_SIZE}
strokeWidth={1.75}
className="shrink-0 text-text-2 transition-colors group-hover/turn-collapse:text-text-1"
/>
<span className="inline-flex min-w-0 flex-1 items-center gap-2 leading-tight">
<span className="shrink-0 select-text whitespace-nowrap font-medium text-text-2 transition-colors group-hover/turn-collapse:text-text-1">
{label}
</span>
)}
</span>
</button>
{showRange && (
<span className="min-w-0 select-text truncate text-text-3">
{rangeLabel}
</span>
)}
</span>
</button>
{showReplayNavigate ? (
<EventNavigateIcon
onClick={handleReplayNavigate}
ariaLabel={t("tools.replay.title")}
/>
) : null}
</div>
<div aria-hidden="true" className="h-px w-full bg-border-1" />
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventNavigateIconProps> = memo(
({ onClick, variant = "header" }) => {
({ onClick, variant = "header", ariaLabel }) => {
const handleClick = (event: React.MouseEvent) => {
event.stopPropagation();
onClick();
Expand All @@ -49,6 +50,7 @@ const EventNavigateIcon: React.FC<EventNavigateIconProps> = memo(
<button
type="button"
data-testid="event-navigate"
aria-label={ariaLabel}
className={className}
onClick={handleClick}
tabIndex={-1}
Expand Down
Loading
Loading