diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 09ad60b427..82769b3d6f 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,6 +1,6 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, @@ -225,6 +225,8 @@ export interface TrajectoryTableProps { onSelectedIndexChange?: (index: number | null) => void /** Report a direct user selection from a ledger row. */ onRecordSelect?: (index: number) => void + /** One externally requested record selection; a new object repeats the request. */ + recordSelection?: { readonly index: number } | null /** Clear selection state owned by the ledger host. */ onClearSelection?: () => void /** Turn ids whose rows after the first are folded into a summary. */ @@ -1397,6 +1399,7 @@ export function TrajectoryTable({ searchMatchIndexes = null, onSelectedIndexChange, onRecordSelect, + recordSelection = null, onClearSelection, collapsedTurns, onToggleTurn, @@ -1410,11 +1413,12 @@ export function TrajectoryTable({ const [detailsWidth, setDetailsWidth] = useState(null) const [toolRequestOffset, setToolRequestOffset] = useState(null) const detailsResizeDrag = useRef(null) + const appliedRecordSelection = useRef(null) const tabHistory = useRef>(new Set(['overview'])) useEffect(() => { onSelectedIndexChange?.(selectedIndex) }, [onSelectedIndexChange, selectedIndex]) - const allRecords = flattenRecords(turns) + const allRecords = useMemo(() => flattenRecords(turns), [turns]) const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers) const records = searchMatchIndexes === null ? collapseAssistantRecords( @@ -1531,7 +1535,7 @@ export function TrajectoryTable({ onClearSelection?.() } - const selectRecord = (index: number) => { + const selectRecord = useCallback((index: number) => { const record = allRecords.find(candidate => candidate.cell.index === index) onRecordSelect?.(index) setSelectedRequest(null) @@ -1541,7 +1545,15 @@ export function TrajectoryTable({ const available = new Set(tabs.map(tab => tab.id)) const recent = [...tabHistory.current].reverse().find(tab => available.has(tab)) setActiveTab(recent ?? tabs[0]?.id ?? 'overview') - } + }, [allRecords, onRecordSelect]) + useEffect(() => { + if ( + recordSelection === null + || appliedRecordSelection.current === recordSelection + ) return + appliedRecordSelection.current = recordSelection + selectRecord(recordSelection.index) + }, [recordSelection, selectRecord]) const selectRequest = ( request: SelectedRequest, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 734b2f3326..7d4fae9cad 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -70,16 +70,29 @@ .lanes { position: absolute; z-index: 2; - inset: 7px 0; + top: 7px; + bottom: 7px; + left: var(--trajectory-domain-left); + width: var(--trajectory-domain-width); } .turnBoundaries { position: absolute; z-index: 3; - inset: 0; + top: 0; + bottom: 0; + left: var(--trajectory-domain-left); + width: var(--trajectory-domain-width); pointer-events: none; } +@media (prefers-reduced-motion: no-preference) { + .lanes[data-animate-viewport='true'], + .turnBoundaries[data-animate-viewport='true'] { + transition: left 180ms ease-out; + } +} + .turnBoundary { position: absolute; top: 0; @@ -142,6 +155,18 @@ opacity: 0.2; } +.span[data-hovered='true']:not([data-current='true']) { + z-index: 1; + opacity: 0.78; + box-shadow: + 0 0 0 1px var(--dsw-alias-bg-layer-2), + 0 0 0 2px color-mix( + in srgb, + var(--dsw-alias-state-business-primary) 80%, + transparent + ); +} + .span[data-current='true'] { z-index: 1; opacity: 1; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 8ad1afefc1..58270f36e7 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css' const MINIMUM_DRAG_PX = 3 const MINIMUM_ZOOM_OPERATIONS = 4 +const EDGE_PAN_ZONE_FRACTION = 0.08 +const EDGE_PAN_STEP_FRACTION = 0.025 +const MAXIMUM_EDGE_PAN_PX = 32 interface FractionRange { start: number end: number } +interface HoverPoint { + fraction: number + recordIndex: number | null +} + /** Props for the fixed full-domain overview above the trajectory ledger. */ export interface TrajectoryTimelineProps { turns: readonly TrajectoryTurnModel[] @@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps { /** Record indexes matching the active ledger search, or null without a query. */ searchMatchIndexes?: ReadonlySet | null onRangeChange: (range: TrajectoryTimeRange | null) => void + /** Select a directly clicked timeline block. */ + onRecordSelect?: (index: number) => void + /** Bring the nearest record into view after clicking timeline whitespace. */ onRecordFocus?: (index: number) => void } @@ -41,11 +52,16 @@ function clampFraction(value: number): number { return Math.min(1, Math.max(0, value)) } -function centeredRange(center: number, width: number): FractionRange { - const clampedWidth = Math.min(1, Math.max(0, width)) +function centeredRange( + center: number, + width: number, + minimum: number, + maximum: number, +): FractionRange { + const clampedWidth = Math.min(maximum - minimum, Math.max(0, width)) const start = Math.min( - Math.max(center - clampedWidth / 2, 0), - 1 - clampedWidth, + Math.max(center - clampedWidth / 2, minimum), + maximum - clampedWidth, ) return { start, end: start + clampedWidth } } @@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ selectedIndex = null, searchMatchIndexes = null, onRangeChange, + onRecordSelect, onRecordFocus, }: TrajectoryTimelineProps) { const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns]) @@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ )), [turns], ) - const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null) - const [draft, setDraft] = useState(null) - const [hover, setHover] = useState(null) + const dragRef = useRef<{ + pointerId: number + anchorTime: number + anchorClientX: number + recordIndex: number | null + } | null>(null) + const [draft, setDraft] = useState(null) + const [hover, setHover] = useState(null) const [viewport, setViewport] = useState(null) + const [animateViewport, setAnimateViewport] = useState(false) useEffect(() => { if ( model !== null @@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ }, [model, onRangeChange, range]) useEffect(() => { if (model === null) return + setAnimateViewport(false) setViewport(current => current !== null && (current.end < model.start || current.start > model.end) ? null : current) }, [model]) + useEffect(() => { + if (model === null || selectedIndex === null) return + const selectedSpan = model.spans.find(span => span.index === selectedIndex) + if (selectedSpan === undefined) return + setAnimateViewport(true) + setViewport((current) => { + if (current === null) return current + if ( + selectedSpan.end > current.start + && selectedSpan.start < current.end + ) return current + const duration = Math.max(1, current.end - current.start) + const desiredStart = selectedSpan.end <= current.start + ? selectedSpan.start + : selectedSpan.end - duration + const nextStart = Math.min( + Math.max(desiredStart, model.start), + Math.max(model.start, model.end - duration), + ) + if (nextStart === current.start) return current + return { start: nextStart, end: nextStart + duration } + }) + }, [model, selectedIndex]) const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0)) const viewportDuration = Math.min( fullDuration, @@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) const domainDuration = viewport === null ? fullDuration : viewportDuration const domainStart = viewport === null ? model?.start ?? 0 : viewportStart + const projectedDomainStyle = model === null + ? undefined + : { + '--trajectory-domain-left': + `${-(domainStart - model.start) / domainDuration * 100}%`, + '--trajectory-domain-width': `${fullDuration / domainDuration * 100}%`, + } as CSSProperties const committed = model === null || range === null ? null : rangeFraction(range, domainStart, domainDuration) - const visibleRange = draft ?? committed - const activeRange = draft === null - ? range - : { - start: domainStart + draft.start * domainDuration, - end: domainStart + draft.end * domainDuration, - } + const draftFraction = model === null || draft === null + ? null + : rangeFraction(draft, domainStart, domainDuration) + const visibleRange = draftFraction ?? committed + const activeRange = draft ?? range if (model === null) { return ( @@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) } - const minimumSelectionFraction = Math.min( - 1, - fullDuration / domainDuration / model.spans.length, + const minimumSelectionDuration = Math.min( + domainDuration, + fullDuration / model.spans.length, ) const fractionAt = (event: PointerEvent): number => { @@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) } - const commit = (fraction: FractionRange) => { - onRangeChange({ - start: domainStart + fraction.start * domainDuration, - end: domainStart + fraction.end * domainDuration, - }) + const recordIndexAt = (event: PointerEvent): number | null => { + const target = event.target instanceof HTMLElement ? event.target : null + const value = target?.closest('[data-timeline-record-index]') + ?.dataset.timelineRecordIndex + if (value === undefined) return null + const index = Number(value) + return Number.isFinite(index) ? index : null + } + + const commit = (nextRange: TrajectoryTimeRange) => { + onRangeChange(nextRange) } const onPointerDown = (event: PointerEvent) => { if (event.button !== 0) return - const rect = event.currentTarget.getBoundingClientRect() const anchor = fractionAt(event) - setHover(anchor) - dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) } + const anchorTime = domainStart + anchor * domainDuration + const recordIndex = recordIndexAt(event) + setHover({ fraction: anchor, recordIndex }) + dragRef.current = { + pointerId: event.pointerId, + anchorTime, + anchorClientX: event.clientX, + recordIndex, + } if (typeof event.currentTarget.setPointerCapture === 'function') { event.currentTarget.setPointerCapture(event.pointerId) } - setDraft({ start: anchor, end: anchor }) + setDraft({ start: anchorTime, end: anchorTime }) } const onPointerMove = (event: PointerEvent) => { const drag = dragRef.current + const rect = event.currentTarget.getBoundingClientRect() const fraction = fractionAt(event) - setHover(fraction) + setHover({ fraction, recordIndex: recordIndexAt(event) }) if (drag === null || drag.pointerId !== event.pointerId) return - setDraft(orderedRange(drag.anchor, fraction)) + let nextDomainStart = domainStart + if (viewport !== null) { + const localX = event.clientX - rect.left + const edgeWidth = Math.min( + MAXIMUM_EDGE_PAN_PX, + Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION), + ) + const direction = localX < edgeWidth + ? -1 + : localX > rect.width - edgeWidth ? 1 : 0 + if (direction !== 0) { + const edgeDistance = direction < 0 + ? edgeWidth - localX + : localX - (rect.width - edgeWidth) + const strength = clampFraction(edgeDistance / edgeWidth) + const desiredStart = domainStart + + direction * domainDuration * EDGE_PAN_STEP_FRACTION + * Math.max(0.2, strength) + nextDomainStart = Math.min( + Math.max(desiredStart, model.start), + model.end - domainDuration, + ) + if (nextDomainStart !== domainStart) { + setAnimateViewport(false) + setViewport({ + start: nextDomainStart, + end: nextDomainStart + domainDuration, + }) + } + } + } + const pointTime = nextDomainStart + fraction * domainDuration + setDraft(orderedRange(drag.anchorTime, pointTime)) } const onPointerEnd = (event: PointerEvent) => { const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return - const point = fractionAt(event) - const selected = orderedRange(drag.anchor, point) - setHover(point) + const pointFraction = fractionAt(event) + const pointTime = domainStart + pointFraction * domainDuration + const selected = orderedRange(drag.anchorTime, pointTime) + setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) }) dragRef.current = null setDraft(null) - const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX - const committedRange = selected.end - selected.start < minimumSelectionFraction + const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX + const clickedSpan = click && drag.recordIndex !== null + ? model.spans.find(span => span.index === drag.recordIndex) + : undefined + if (clickedSpan !== undefined) { + onRangeChange(null) + onRecordSelect?.(clickedSpan.index) + return + } + const committedRange = selected.end - selected.start < minimumSelectionDuration ? centeredRange( click ? selected.start : (selected.start + selected.end) / 2, - minimumSelectionFraction, + minimumSelectionDuration, + model.start, + model.end, ) : selected commit(committedRange) if (click) { - const timelinePoint = domainStart + selected.start * domainDuration + const timelinePoint = selected.start const nearest = model.spans.reduce((candidate, span) => { const candidateDistance = timelinePoint < candidate.start ? candidate.start - timelinePoint @@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ const onWheel = (event: WheelEvent) => { event.preventDefault() + setAnimateViewport(false) const rect = event.currentTarget.getBoundingClientRect() const anchorFraction = clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) @@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ onWheel={onWheel} onContextMenu={(event) => { event.preventDefault() + setAnimateViewport(false) onRangeChange(null) setViewport(null) }} > - {hover !== null && draft === null && ( + {hover !== null && hover.recordIndex === null && draft === null && (