feat(ui): refine trajectory timeline interaction
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
/** Turn-aware trajectory event ledger with a local record inspector. */
|
/** 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 type { CSSProperties, ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
|
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
|
||||||
@@ -225,6 +225,8 @@ export interface TrajectoryTableProps {
|
|||||||
onSelectedIndexChange?: (index: number | null) => void
|
onSelectedIndexChange?: (index: number | null) => void
|
||||||
/** Report a direct user selection from a ledger row. */
|
/** Report a direct user selection from a ledger row. */
|
||||||
onRecordSelect?: (index: number) => void
|
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. */
|
/** Clear selection state owned by the ledger host. */
|
||||||
onClearSelection?: () => void
|
onClearSelection?: () => void
|
||||||
/** Turn ids whose rows after the first are folded into a summary. */
|
/** Turn ids whose rows after the first are folded into a summary. */
|
||||||
@@ -1397,6 +1399,7 @@ export function TrajectoryTable({
|
|||||||
searchMatchIndexes = null,
|
searchMatchIndexes = null,
|
||||||
onSelectedIndexChange,
|
onSelectedIndexChange,
|
||||||
onRecordSelect,
|
onRecordSelect,
|
||||||
|
recordSelection = null,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
collapsedTurns,
|
collapsedTurns,
|
||||||
onToggleTurn,
|
onToggleTurn,
|
||||||
@@ -1410,11 +1413,12 @@ export function TrajectoryTable({
|
|||||||
const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
|
const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
|
||||||
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
|
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
|
||||||
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
|
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
|
||||||
|
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
|
||||||
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
|
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSelectedIndexChange?.(selectedIndex)
|
onSelectedIndexChange?.(selectedIndex)
|
||||||
}, [onSelectedIndexChange, selectedIndex])
|
}, [onSelectedIndexChange, selectedIndex])
|
||||||
const allRecords = flattenRecords(turns)
|
const allRecords = useMemo(() => flattenRecords(turns), [turns])
|
||||||
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
|
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
|
||||||
const records = searchMatchIndexes === null
|
const records = searchMatchIndexes === null
|
||||||
? collapseAssistantRecords(
|
? collapseAssistantRecords(
|
||||||
@@ -1531,7 +1535,7 @@ export function TrajectoryTable({
|
|||||||
onClearSelection?.()
|
onClearSelection?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectRecord = (index: number) => {
|
const selectRecord = useCallback((index: number) => {
|
||||||
const record = allRecords.find(candidate => candidate.cell.index === index)
|
const record = allRecords.find(candidate => candidate.cell.index === index)
|
||||||
onRecordSelect?.(index)
|
onRecordSelect?.(index)
|
||||||
setSelectedRequest(null)
|
setSelectedRequest(null)
|
||||||
@@ -1541,7 +1545,15 @@ export function TrajectoryTable({
|
|||||||
const available = new Set(tabs.map(tab => tab.id))
|
const available = new Set(tabs.map(tab => tab.id))
|
||||||
const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
|
const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
|
||||||
setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
|
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 = (
|
const selectRequest = (
|
||||||
request: SelectedRequest,
|
request: SelectedRequest,
|
||||||
|
|||||||
@@ -70,16 +70,29 @@
|
|||||||
.lanes {
|
.lanes {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
inset: 7px 0;
|
top: 7px;
|
||||||
|
bottom: 7px;
|
||||||
|
left: var(--trajectory-domain-left);
|
||||||
|
width: var(--trajectory-domain-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.turnBoundaries {
|
.turnBoundaries {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
inset: 0;
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--trajectory-domain-left);
|
||||||
|
width: var(--trajectory-domain-width);
|
||||||
pointer-events: none;
|
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 {
|
.turnBoundary {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -142,6 +155,18 @@
|
|||||||
opacity: 0.2;
|
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'] {
|
.span[data-current='true'] {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css'
|
|||||||
|
|
||||||
const MINIMUM_DRAG_PX = 3
|
const MINIMUM_DRAG_PX = 3
|
||||||
const MINIMUM_ZOOM_OPERATIONS = 4
|
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 {
|
interface FractionRange {
|
||||||
start: number
|
start: number
|
||||||
end: number
|
end: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HoverPoint {
|
||||||
|
fraction: number
|
||||||
|
recordIndex: number | null
|
||||||
|
}
|
||||||
|
|
||||||
/** Props for the fixed full-domain overview above the trajectory ledger. */
|
/** Props for the fixed full-domain overview above the trajectory ledger. */
|
||||||
export interface TrajectoryTimelineProps {
|
export interface TrajectoryTimelineProps {
|
||||||
turns: readonly TrajectoryTurnModel[]
|
turns: readonly TrajectoryTurnModel[]
|
||||||
@@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps {
|
|||||||
/** Record indexes matching the active ledger search, or null without a query. */
|
/** Record indexes matching the active ledger search, or null without a query. */
|
||||||
searchMatchIndexes?: ReadonlySet<number> | null
|
searchMatchIndexes?: ReadonlySet<number> | null
|
||||||
onRangeChange: (range: TrajectoryTimeRange | null) => void
|
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
|
onRecordFocus?: (index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,11 +52,16 @@ function clampFraction(value: number): number {
|
|||||||
return Math.min(1, Math.max(0, value))
|
return Math.min(1, Math.max(0, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function centeredRange(center: number, width: number): FractionRange {
|
function centeredRange(
|
||||||
const clampedWidth = Math.min(1, Math.max(0, width))
|
center: number,
|
||||||
|
width: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
|
): FractionRange {
|
||||||
|
const clampedWidth = Math.min(maximum - minimum, Math.max(0, width))
|
||||||
const start = Math.min(
|
const start = Math.min(
|
||||||
Math.max(center - clampedWidth / 2, 0),
|
Math.max(center - clampedWidth / 2, minimum),
|
||||||
1 - clampedWidth,
|
maximum - clampedWidth,
|
||||||
)
|
)
|
||||||
return { start, end: start + clampedWidth }
|
return { start, end: start + clampedWidth }
|
||||||
}
|
}
|
||||||
@@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
selectedIndex = null,
|
selectedIndex = null,
|
||||||
searchMatchIndexes = null,
|
searchMatchIndexes = null,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
|
onRecordSelect,
|
||||||
onRecordFocus,
|
onRecordFocus,
|
||||||
}: TrajectoryTimelineProps) {
|
}: TrajectoryTimelineProps) {
|
||||||
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
|
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
|
||||||
@@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
)),
|
)),
|
||||||
[turns],
|
[turns],
|
||||||
)
|
)
|
||||||
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
|
const dragRef = useRef<{
|
||||||
const [draft, setDraft] = useState<FractionRange | null>(null)
|
pointerId: number
|
||||||
const [hover, setHover] = useState<number | null>(null)
|
anchorTime: number
|
||||||
|
anchorClientX: number
|
||||||
|
recordIndex: number | null
|
||||||
|
} | null>(null)
|
||||||
|
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
|
||||||
|
const [hover, setHover] = useState<HoverPoint | null>(null)
|
||||||
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
||||||
|
const [animateViewport, setAnimateViewport] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
model !== null
|
model !== null
|
||||||
@@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
}, [model, onRangeChange, range])
|
}, [model, onRangeChange, range])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model === null) return
|
if (model === null) return
|
||||||
|
setAnimateViewport(false)
|
||||||
setViewport(current =>
|
setViewport(current =>
|
||||||
current !== null && (current.end < model.start || current.start > model.end)
|
current !== null && (current.end < model.start || current.start > model.end)
|
||||||
? null
|
? null
|
||||||
: current)
|
: current)
|
||||||
}, [model])
|
}, [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 fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
|
||||||
const viewportDuration = Math.min(
|
const viewportDuration = Math.min(
|
||||||
fullDuration,
|
fullDuration,
|
||||||
@@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
)
|
)
|
||||||
const domainDuration = viewport === null ? fullDuration : viewportDuration
|
const domainDuration = viewport === null ? fullDuration : viewportDuration
|
||||||
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
|
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
|
const committed = model === null || range === null
|
||||||
? null
|
? null
|
||||||
: rangeFraction(range, domainStart, domainDuration)
|
: rangeFraction(range, domainStart, domainDuration)
|
||||||
const visibleRange = draft ?? committed
|
const draftFraction = model === null || draft === null
|
||||||
const activeRange = draft === null
|
? null
|
||||||
? range
|
: rangeFraction(draft, domainStart, domainDuration)
|
||||||
: {
|
const visibleRange = draftFraction ?? committed
|
||||||
start: domainStart + draft.start * domainDuration,
|
const activeRange = draft ?? range
|
||||||
end: domainStart + draft.end * domainDuration,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model === null) {
|
if (model === null) {
|
||||||
return (
|
return (
|
||||||
@@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const minimumSelectionFraction = Math.min(
|
const minimumSelectionDuration = Math.min(
|
||||||
1,
|
domainDuration,
|
||||||
fullDuration / domainDuration / model.spans.length,
|
fullDuration / model.spans.length,
|
||||||
)
|
)
|
||||||
|
|
||||||
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
|
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
|
||||||
@@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||||
}
|
}
|
||||||
|
|
||||||
const commit = (fraction: FractionRange) => {
|
const recordIndexAt = (event: PointerEvent<HTMLDivElement>): number | null => {
|
||||||
onRangeChange({
|
const target = event.target instanceof HTMLElement ? event.target : null
|
||||||
start: domainStart + fraction.start * domainDuration,
|
const value = target?.closest<HTMLElement>('[data-timeline-record-index]')
|
||||||
end: domainStart + fraction.end * domainDuration,
|
?.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<HTMLDivElement>) => {
|
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
const rect = event.currentTarget.getBoundingClientRect()
|
|
||||||
const anchor = fractionAt(event)
|
const anchor = fractionAt(event)
|
||||||
setHover(anchor)
|
const anchorTime = domainStart + anchor * domainDuration
|
||||||
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
|
const recordIndex = recordIndexAt(event)
|
||||||
|
setHover({ fraction: anchor, recordIndex })
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
anchorTime,
|
||||||
|
anchorClientX: event.clientX,
|
||||||
|
recordIndex,
|
||||||
|
}
|
||||||
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
}
|
}
|
||||||
setDraft({ start: anchor, end: anchor })
|
setDraft({ start: anchorTime, end: anchorTime })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
const drag = dragRef.current
|
const drag = dragRef.current
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
const fraction = fractionAt(event)
|
const fraction = fractionAt(event)
|
||||||
setHover(fraction)
|
setHover({ fraction, recordIndex: recordIndexAt(event) })
|
||||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
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<HTMLDivElement>) => {
|
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
const drag = dragRef.current
|
const drag = dragRef.current
|
||||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||||
const point = fractionAt(event)
|
const pointFraction = fractionAt(event)
|
||||||
const selected = orderedRange(drag.anchor, point)
|
const pointTime = domainStart + pointFraction * domainDuration
|
||||||
setHover(point)
|
const selected = orderedRange(drag.anchorTime, pointTime)
|
||||||
|
setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) })
|
||||||
dragRef.current = null
|
dragRef.current = null
|
||||||
setDraft(null)
|
setDraft(null)
|
||||||
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
|
const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX
|
||||||
const committedRange = selected.end - selected.start < minimumSelectionFraction
|
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(
|
? centeredRange(
|
||||||
click ? selected.start : (selected.start + selected.end) / 2,
|
click ? selected.start : (selected.start + selected.end) / 2,
|
||||||
minimumSelectionFraction,
|
minimumSelectionDuration,
|
||||||
|
model.start,
|
||||||
|
model.end,
|
||||||
)
|
)
|
||||||
: selected
|
: selected
|
||||||
commit(committedRange)
|
commit(committedRange)
|
||||||
if (click) {
|
if (click) {
|
||||||
const timelinePoint = domainStart + selected.start * domainDuration
|
const timelinePoint = selected.start
|
||||||
const nearest = model.spans.reduce((candidate, span) => {
|
const nearest = model.spans.reduce((candidate, span) => {
|
||||||
const candidateDistance = timelinePoint < candidate.start
|
const candidateDistance = timelinePoint < candidate.start
|
||||||
? candidate.start - timelinePoint
|
? candidate.start - timelinePoint
|
||||||
@@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
|
|
||||||
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
setAnimateViewport(false)
|
||||||
const rect = event.currentTarget.getBoundingClientRect()
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
const anchorFraction =
|
const anchorFraction =
|
||||||
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||||
@@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
setAnimateViewport(false)
|
||||||
onRangeChange(null)
|
onRangeChange(null)
|
||||||
setViewport(null)
|
setViewport(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hover !== null && draft === null && (
|
{hover !== null && hover.recordIndex === null && draft === null && (
|
||||||
<div
|
<div
|
||||||
className={css.hoverLine}
|
className={css.hoverLine}
|
||||||
|
data-timeline-hover-line
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
'--trajectory-hover-left': `${hover * 100}%`,
|
'--trajectory-hover-left': `${hover.fraction * 100}%`,
|
||||||
} as CSSProperties}
|
} as CSSProperties}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -313,7 +424,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={css.turnBoundaries} aria-hidden="true">
|
<div
|
||||||
|
className={css.turnBoundaries}
|
||||||
|
data-animate-viewport={animateViewport || undefined}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={projectedDomainStyle}
|
||||||
|
>
|
||||||
{model.turnBoundaries
|
{model.turnBoundaries
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.filter(boundary =>
|
.filter(boundary =>
|
||||||
@@ -326,24 +442,34 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
key={boundary.turn}
|
key={boundary.turn}
|
||||||
style={{
|
style={{
|
||||||
'--trajectory-turn-left':
|
'--trajectory-turn-left':
|
||||||
`${(boundary.time - domainStart) / domainDuration * 100}%`,
|
`${(boundary.time - model.start) / fullDuration * 100}%`,
|
||||||
} as CSSProperties}
|
} as CSSProperties}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={css.lanes} aria-hidden="true">
|
<div
|
||||||
|
className={css.lanes}
|
||||||
|
data-animate-viewport={animateViewport || undefined}
|
||||||
|
data-timeline-domain
|
||||||
|
aria-hidden="true"
|
||||||
|
style={projectedDomainStyle}
|
||||||
|
>
|
||||||
{model.spans
|
{model.spans
|
||||||
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
|
.filter(span =>
|
||||||
|
span.index === selectedIndex
|
||||||
|
|| (span.end >= domainStart && span.start <= domainStart + domainDuration))
|
||||||
.map((span) => {
|
.map((span) => {
|
||||||
const left = (span.start - domainStart) / domainDuration
|
const left = (span.start - model.start) / fullDuration
|
||||||
const width = (span.end - span.start) / domainDuration
|
const width = (span.end - span.start) / fullDuration
|
||||||
const durationMs = durationByIndex.get(span.index)
|
const durationMs = durationByIndex.get(span.index)
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={css.span}
|
className={css.span}
|
||||||
data-timeline-span={span.kind}
|
data-timeline-span={span.kind}
|
||||||
|
data-timeline-record-index={span.index}
|
||||||
data-equal-duration={mode === 'time' || undefined}
|
data-equal-duration={mode === 'time' || undefined}
|
||||||
data-current={span.index === selectedIndex || undefined}
|
data-current={span.index === selectedIndex || undefined}
|
||||||
|
data-hovered={hover?.recordIndex === span.index || undefined}
|
||||||
data-search-match={searchMatchIndexes === null
|
data-search-match={searchMatchIndexes === null
|
||||||
? undefined
|
? undefined
|
||||||
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
|
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export function TrajectoryView({
|
|||||||
const [actualTime, setActualTime] = useState(false)
|
const [actualTime, setActualTime] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||||
|
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
|
||||||
|
readonly index: number
|
||||||
|
} | null>(null)
|
||||||
const ledgerRef = useRef<HTMLDivElement>(null)
|
const ledgerRef = useRef<HTMLDivElement>(null)
|
||||||
const inspection = useHistory(snapshot => snapshot.inspection)
|
const inspection = useHistory(snapshot => snapshot.inspection)
|
||||||
const nodes = inspection.eventNodes
|
const nodes = inspection.eventNodes
|
||||||
@@ -478,7 +481,20 @@ export function TrajectoryView({
|
|||||||
selectedIndex={selectedTimelineIndex}
|
selectedIndex={selectedTimelineIndex}
|
||||||
searchMatchIndexes={searchMatchIndexes}
|
searchMatchIndexes={searchMatchIndexes}
|
||||||
onRangeChange={(range) => {
|
onRangeChange={(range) => {
|
||||||
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
|
setTimelineSelection(range === null ? null : {
|
||||||
|
branchId: currentBranch.id,
|
||||||
|
range,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onRecordSelect={(index) => {
|
||||||
|
setTimelineSelection(null)
|
||||||
|
setTimelineRecordSelection({ index })
|
||||||
|
setSelectedTimelineIndex(index)
|
||||||
|
const row = ledgerRef.current
|
||||||
|
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
|
||||||
|
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
|
||||||
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onRecordFocus={(index) => {
|
onRecordFocus={(index) => {
|
||||||
const row = ledgerRef.current
|
const row = ledgerRef.current
|
||||||
@@ -497,6 +513,7 @@ export function TrajectoryView({
|
|||||||
searchMatchIndexes={searchMatchIndexes}
|
searchMatchIndexes={searchMatchIndexes}
|
||||||
onSelectedIndexChange={setSelectedTimelineIndex}
|
onSelectedIndexChange={setSelectedTimelineIndex}
|
||||||
onRecordSelect={handleRecordSelect}
|
onRecordSelect={handleRecordSelect}
|
||||||
|
recordSelection={timelineRecordSelection}
|
||||||
onClearSelection={() => { setTimelineSelection(null) }}
|
onClearSelection={() => { setTimelineSelection(null) }}
|
||||||
collapsedTurns={collapsedTurns}
|
collapsedTurns={collapsedTurns}
|
||||||
onToggleTurn={toggleTurn}
|
onToggleTurn={toggleTurn}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
|
|||||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
||||||
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
||||||
|
import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
|
||||||
import {
|
import {
|
||||||
TrajectoryView, type TrajectoryViewInjected,
|
TrajectoryView, type TrajectoryViewInjected,
|
||||||
} from '../src/client/TrajectoryView.tsx'
|
} from '../src/client/TrajectoryView.tsx'
|
||||||
@@ -309,6 +310,44 @@ describe('tab switching in ConversationRoot', () => {
|
|||||||
.toBeNull()
|
.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
|
||||||
|
const b = await bench()
|
||||||
|
const view = mount(b.slots)
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||||
|
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||||
|
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
})
|
||||||
|
const toolSpan = view.container.querySelector<HTMLElement>(
|
||||||
|
'[data-timeline-span="tool"]',
|
||||||
|
)
|
||||||
|
expect(toolSpan).not.toBeNull()
|
||||||
|
const recordIndex = toolSpan?.dataset.timelineRecordIndex
|
||||||
|
expect(recordIndex).toBeTruthy()
|
||||||
|
|
||||||
|
fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 })
|
||||||
|
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
|
||||||
|
expect(toolSpan?.getAttribute('data-hovered')).toBe('true')
|
||||||
|
|
||||||
|
fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 })
|
||||||
|
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
|
||||||
|
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
|
||||||
|
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy()
|
||||||
|
|
||||||
|
fireEvent.pointerDown(toolSpan as HTMLElement, {
|
||||||
|
button: 0, clientX: 50, pointerId: 2,
|
||||||
|
})
|
||||||
|
fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 })
|
||||||
|
|
||||||
|
const selectedRow = view.container.querySelector<HTMLElement>(
|
||||||
|
`tr[data-record-index="${recordIndex}"]`,
|
||||||
|
)
|
||||||
|
expect(selectedRow?.getAttribute('aria-selected')).toBe('true')
|
||||||
|
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull()
|
||||||
|
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
it('empty window keeps the toolbar and reports no timing data', async () => {
|
it('empty window keeps the toolbar and reports no timing data', async () => {
|
||||||
const b = await bench(historySnapshot([]))
|
const b = await bench(historySnapshot([]))
|
||||||
mount(b.slots)
|
mount(b.slots)
|
||||||
@@ -332,6 +371,97 @@ describe('timeline projection', () => {
|
|||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
}] satisfies readonly TrajectoryTurnModel[]
|
}] satisfies readonly TrajectoryTurnModel[]
|
||||||
|
const longTurns = [{
|
||||||
|
turn: 1,
|
||||||
|
groups: [{
|
||||||
|
title: 'Step 1',
|
||||||
|
cells: Array.from({ length: 10 }, (_, index) => ({
|
||||||
|
index,
|
||||||
|
kind: 'message' as const,
|
||||||
|
text: `record ${index}`,
|
||||||
|
timeSeconds: 1,
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
}] satisfies readonly TrajectoryTurnModel[]
|
||||||
|
|
||||||
|
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
|
||||||
|
const onRangeChange = vi.fn()
|
||||||
|
const view = render(
|
||||||
|
<TrajectoryTimeline
|
||||||
|
turns={longTurns}
|
||||||
|
mode="sequence"
|
||||||
|
range={null}
|
||||||
|
onRangeChange={onRangeChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||||
|
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
})
|
||||||
|
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<TrajectoryTimeline
|
||||||
|
turns={longTurns}
|
||||||
|
mode="sequence"
|
||||||
|
range={null}
|
||||||
|
selectedIndex={1}
|
||||||
|
onRangeChange={onRangeChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const domain = view.container.querySelector<HTMLElement>(
|
||||||
|
'[data-timeline-domain]',
|
||||||
|
)
|
||||||
|
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
|
||||||
|
})
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<TrajectoryTimeline
|
||||||
|
turns={longTurns}
|
||||||
|
mode="sequence"
|
||||||
|
range={null}
|
||||||
|
selectedIndex={8}
|
||||||
|
onRangeChange={onRangeChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const domain = view.container.querySelector<HTMLElement>(
|
||||||
|
'[data-timeline-domain]',
|
||||||
|
)
|
||||||
|
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
|
||||||
|
const onRangeChange = vi.fn()
|
||||||
|
render(
|
||||||
|
<TrajectoryTimeline
|
||||||
|
turns={longTurns}
|
||||||
|
mode="sequence"
|
||||||
|
range={null}
|
||||||
|
onRangeChange={onRangeChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||||
|
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
})
|
||||||
|
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
|
||||||
|
fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
|
||||||
|
for (let index = 0; index < 24; index++) {
|
||||||
|
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
|
||||||
|
}
|
||||||
|
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
|
||||||
|
|
||||||
|
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
|
||||||
|
| { start: number; end: number }
|
||||||
|
| undefined
|
||||||
|
expect(selectedRange).toBeDefined()
|
||||||
|
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses equal-width operation slots and stable semantic lanes', () => {
|
it('uses equal-width operation slots and stable semantic lanes', () => {
|
||||||
expect(deriveTrajectoryTimeline(turns)).toEqual({
|
expect(deriveTrajectoryTimeline(turns)).toEqual({
|
||||||
|
|||||||
Reference in New Issue
Block a user