feat(client): refine trajectory timeline interaction
This commit is contained in:
@@ -159,7 +159,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
|||||||
|
|
||||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
||||||
const plot = page.getByLabel('Timeline overview; drag horizontally to filter events')
|
const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
|
||||||
const before = await page.locator('tr[data-kind]').count()
|
const before = await page.locator('tr[data-kind]').count()
|
||||||
const box = await plot.boundingBox()
|
const box = await plot.boundingBox()
|
||||||
if (box === null) throw new Error('trajectory timeline plot has no layout box')
|
if (box === null) throw new Error('trajectory timeline plot has no layout box')
|
||||||
@@ -167,11 +167,11 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
|||||||
await page.mouse.down()
|
await page.mouse.down()
|
||||||
await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2)
|
await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2)
|
||||||
await page.mouse.up()
|
await page.mouse.up()
|
||||||
await page.getByRole('button', { name: 'Clear selection' }).waitFor()
|
await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 })
|
||||||
await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 })
|
.toBeGreaterThan(0)
|
||||||
.toBeLessThan(before)
|
|
||||||
await page.getByRole('button', { name: 'Clear selection' }).click()
|
|
||||||
await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before)
|
await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before)
|
||||||
|
await plot.click({ button: 'right' })
|
||||||
|
await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
|
|
||||||
it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {
|
it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
- text: Trajectory
|
- text: Trajectory
|
||||||
- button "Collapse calls"
|
- button "Collapse calls"
|
||||||
- button "Collapse turns"
|
- button "Collapse turns"
|
||||||
- region "Trajectory timeline": Overview 9 timed events
|
- region "Trajectory timeline"
|
||||||
- table:
|
- table:
|
||||||
- rowgroup:
|
- rowgroup:
|
||||||
- row "SYSTEM, Initial System Prompt":
|
- row "SYSTEM, Initial System Prompt":
|
||||||
|
|||||||
@@ -76,7 +76,13 @@
|
|||||||
.table tbody tr:not([data-collapsed-summary]) {
|
.table tbody tr:not([data-collapsed-summary]) {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: background-color 120ms var(--ds-ease-in-out);
|
transition:
|
||||||
|
background-color 120ms var(--ds-ease-in-out),
|
||||||
|
opacity 120ms var(--ds-ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr[data-timeline-focus='outside'] {
|
||||||
|
opacity: 0.24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {
|
.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {
|
||||||
|
|||||||
@@ -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 { useRef, useState } from 'react'
|
import { useEffect, 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,
|
||||||
@@ -217,6 +217,10 @@ export interface TrajectoryTableProps {
|
|||||||
requestNumbers?: readonly TrajectoryRequestNumber[]
|
requestNumbers?: readonly TrajectoryRequestNumber[]
|
||||||
/** Grouped records in display order. */
|
/** Grouped records in display order. */
|
||||||
turns: readonly TrajectoryTurnModel[]
|
turns: readonly TrajectoryTurnModel[]
|
||||||
|
/** Record indexes emphasized by the active timeline focus. */
|
||||||
|
timelineFocusIndexes?: ReadonlySet<number> | null
|
||||||
|
/** Report the record currently selected in the local inspector. */
|
||||||
|
onSelectedIndexChange?: (index: number | null) => 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. */
|
||||||
collapsedTurns: ReadonlySet<number>
|
collapsedTurns: ReadonlySet<number>
|
||||||
/** Toggle one turn between folded and expanded. */
|
/** Toggle one turn between folded and expanded. */
|
||||||
@@ -1357,6 +1361,8 @@ function OverviewSection({
|
|||||||
export function TrajectoryTable({
|
export function TrajectoryTable({
|
||||||
requestNumbers: sessionRequestNumbers,
|
requestNumbers: sessionRequestNumbers,
|
||||||
turns,
|
turns,
|
||||||
|
timelineFocusIndexes = null,
|
||||||
|
onSelectedIndexChange,
|
||||||
collapsedTurns,
|
collapsedTurns,
|
||||||
onToggleTurn,
|
onToggleTurn,
|
||||||
collapsedAssistants,
|
collapsedAssistants,
|
||||||
@@ -1370,6 +1376,9 @@ export function TrajectoryTable({
|
|||||||
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 tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
|
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
|
||||||
|
useEffect(() => {
|
||||||
|
onSelectedIndexChange?.(selectedIndex)
|
||||||
|
}, [onSelectedIndexChange, selectedIndex])
|
||||||
const allRecords = flattenRecords(turns)
|
const allRecords = flattenRecords(turns)
|
||||||
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
|
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
|
||||||
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
|
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
|
||||||
@@ -1570,6 +1579,9 @@ export function TrajectoryTable({
|
|||||||
data-turn-end={record.turnEnd || undefined}
|
data-turn-end={record.turnEnd || undefined}
|
||||||
data-collapsed-summary={record.collapsedSummaryKind}
|
data-collapsed-summary={record.collapsedSummaryKind}
|
||||||
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
|
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
|
||||||
|
data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
|
||||||
|
? undefined
|
||||||
|
: timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
|
||||||
onClick={isRequestOnly
|
onClick={isRequestOnly
|
||||||
? undefined
|
? undefined
|
||||||
: isCollapsedSummary
|
: isCollapsedSummary
|
||||||
|
|||||||
@@ -1,130 +1,150 @@
|
|||||||
.root {
|
.root {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 8px 16px 12px;
|
|
||||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||||
background: var(--dsw-alias-bg-layer-1);
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.plot {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
min-height: 24px;
|
height: 50px;
|
||||||
gap: 8px;
|
overflow: hidden;
|
||||||
|
background: var(--dsw-alias-bg-layer-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.labels {
|
||||||
color: var(--dsw-alias-label-primary);
|
position: relative;
|
||||||
font: var(--dsw-font-xs-13);
|
border-right: 1px solid var(--dsw-alias-border-l1);
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
flex: 1;
|
|
||||||
color: var(--dsw-alias-label-caption);
|
color: var(--dsw-alias-label-caption);
|
||||||
font: var(--dsw-font-xs-13);
|
font: var(--dsw-font-xs-13);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear {
|
.labels span {
|
||||||
flex: none;
|
position: absolute;
|
||||||
padding: 2px 8px;
|
right: 6px;
|
||||||
border: 0;
|
display: flex;
|
||||||
border-radius: 4px;
|
align-items: center;
|
||||||
color: var(--dsw-alias-state-business-primary);
|
justify-content: flex-end;
|
||||||
background: transparent;
|
height: 8px;
|
||||||
font: var(--dsw-font-xs-13);
|
text-align: right;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear:hover {
|
.labels span:nth-child(1) {
|
||||||
background: var(--dsw-alias-interactive-bg-hover);
|
top: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear:focus-visible {
|
.labels span:nth-child(2) {
|
||||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
top: 21px;
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot {
|
.labels span:nth-child(3) {
|
||||||
|
top: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 72px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--dsw-alias-border-l2);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--dsw-alias-bg-layer-2);
|
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot:focus-visible {
|
.empty {
|
||||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 8px auto;
|
top: 50%;
|
||||||
height: 20px;
|
left: 50%;
|
||||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
transform: translate(-50%, -50%);
|
||||||
}
|
|
||||||
|
|
||||||
.tick {
|
|
||||||
position: absolute;
|
|
||||||
left: var(--trajectory-tick-left);
|
|
||||||
padding: 2px 4px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--dsw-alias-label-caption);
|
color: var(--dsw-alias-label-caption);
|
||||||
font: var(--dsw-font-xs-13);
|
font: var(--dsw-font-xs-13);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tick:first-child {
|
.track:focus-visible {
|
||||||
transform: none;
|
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||||
}
|
outline-offset: -1px;
|
||||||
|
|
||||||
.tick:last-child {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tick::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
bottom: -52px;
|
|
||||||
left: 50%;
|
|
||||||
width: 1px;
|
|
||||||
background: var(--dsw-alias-border-l1);
|
|
||||||
content: '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lanes {
|
.lanes {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 24px 8px 6px;
|
z-index: 2;
|
||||||
|
inset: 7px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnBoundaries {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnBoundary {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--trajectory-turn-left);
|
||||||
|
width: 1px;
|
||||||
|
background: var(--dsw-alias-border-l2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.span {
|
.span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(var(--trajectory-span-lane) * 14px);
|
top: calc(var(--trajectory-span-lane) * 14px);
|
||||||
left: var(--trajectory-span-left);
|
left: calc(var(--trajectory-span-left) + 1px);
|
||||||
width: var(--trajectory-span-width);
|
width: max(2px, calc(var(--trajectory-span-width) - 2px));
|
||||||
height: 8px;
|
height: 8px;
|
||||||
min-width: 2px;
|
min-width: 2px;
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
background: var(--dsw-alias-label-tertiary);
|
background: var(--dsw-alias-label-secondary);
|
||||||
opacity: 0.72;
|
opacity: 0.78;
|
||||||
}
|
}
|
||||||
|
|
||||||
.span[data-timeline-span='message'],
|
.span[data-timeline-span='user'] {
|
||||||
.span[data-timeline-span='compacted'] {
|
|
||||||
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.span[data-timeline-span='tool'],
|
|
||||||
.span[data-timeline-span='subtool'] {
|
|
||||||
background: var(--dsw-alias-state-business-primary);
|
background: var(--dsw-alias-state-business-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.span[data-timeline-span='context'] {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--dsw-alias-state-success-primary) 68%,
|
||||||
|
var(--dsw-alias-label-secondary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.span[data-timeline-span='message'] {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
|
||||||
|
var(--dsw-alias-state-error-secondary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.span[data-timeline-span='tool'] {
|
||||||
|
background: var(--dsw-alias-state-warn-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.span[data-timeline-span='subtool'] {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--dsw-alias-state-warn-label) 62%,
|
||||||
|
var(--dsw-alias-label-tertiary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.span[data-selected='false'] {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span[data-current='true'] {
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px var(--dsw-alias-bg-layer-2),
|
||||||
|
0 0 0 2px var(--dsw-alias-state-business-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.selection {
|
.selection {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: var(--trajectory-selection-left);
|
left: var(--trajectory-selection-left);
|
||||||
@@ -168,14 +188,3 @@
|
|||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.root {
|
|
||||||
padding-right: 12px;
|
|
||||||
padding-left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plot {
|
|
||||||
height: 64px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
|
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
|
||||||
|
|
||||||
import {
|
import {
|
||||||
memo, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent,
|
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
|
||||||
|
type PointerEvent, type WheelEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import type { TrajectoryTurnModel } from './layout.ts'
|
import type { TrajectoryTurnModel } from './layout.ts'
|
||||||
import {
|
import {
|
||||||
deriveTrajectoryTimeline,
|
deriveTrajectoryTimeline,
|
||||||
filterTrajectoryTimelineRange,
|
type TrajectoryTimelineMode,
|
||||||
formatTimelineOffset,
|
|
||||||
type TrajectoryTimeRange,
|
type TrajectoryTimeRange,
|
||||||
} from './timeline.ts'
|
} from './timeline.ts'
|
||||||
import css from './TrajectoryTimeline.module.css'
|
import css from './TrajectoryTimeline.module.css'
|
||||||
|
|
||||||
const TICK_COUNT = 5
|
|
||||||
const MINIMUM_DRAG_PX = 3
|
const MINIMUM_DRAG_PX = 3
|
||||||
|
const MINIMUM_ZOOM_OPERATIONS = 4
|
||||||
|
|
||||||
interface FractionRange {
|
interface FractionRange {
|
||||||
start: number
|
start: number
|
||||||
@@ -23,8 +23,11 @@ interface FractionRange {
|
|||||||
/** 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[]
|
||||||
|
mode: TrajectoryTimelineMode
|
||||||
range: TrajectoryTimeRange | null
|
range: TrajectoryTimeRange | null
|
||||||
|
selectedIndex?: number | null
|
||||||
onRangeChange: (range: TrajectoryTimeRange | null) => void
|
onRangeChange: (range: TrajectoryTimeRange | null) => void
|
||||||
|
onRecordFocus?: (index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderedRange(left: number, right: number): FractionRange {
|
function orderedRange(left: number, right: number): FractionRange {
|
||||||
@@ -46,33 +49,77 @@ function rangeFraction(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LaneLabels() {
|
||||||
|
return (
|
||||||
|
<div className={css.labels} aria-hidden="true">
|
||||||
|
<span>Input</span>
|
||||||
|
<span>Model</span>
|
||||||
|
<span>Tools</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Overview renderer with drag-to-filter and Escape/clear reset. */
|
/** Overview renderer with drag-to-filter and Escape/clear reset. */
|
||||||
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||||
turns,
|
turns,
|
||||||
|
mode,
|
||||||
range,
|
range,
|
||||||
|
selectedIndex = null,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
|
onRecordFocus,
|
||||||
}: TrajectoryTimelineProps) {
|
}: TrajectoryTimelineProps) {
|
||||||
const model = useMemo(() => deriveTrajectoryTimeline(turns), [turns])
|
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
|
||||||
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
|
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
|
||||||
const [draft, setDraft] = useState<FractionRange | null>(null)
|
const [draft, setDraft] = useState<FractionRange | null>(null)
|
||||||
const domainDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
|
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
model !== null
|
||||||
|
&& range !== null
|
||||||
|
&& (range.end < model.start || range.start > model.end)
|
||||||
|
) {
|
||||||
|
onRangeChange(null)
|
||||||
|
}
|
||||||
|
}, [model, onRangeChange, range])
|
||||||
|
useEffect(() => {
|
||||||
|
if (model === null) return
|
||||||
|
setViewport(current =>
|
||||||
|
current !== null && (current.end < model.start || current.start > model.end)
|
||||||
|
? null
|
||||||
|
: current)
|
||||||
|
}, [model])
|
||||||
|
const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
|
||||||
|
const viewportDuration = Math.min(
|
||||||
|
fullDuration,
|
||||||
|
Math.max(1, (viewport?.end ?? 0) - (viewport?.start ?? 0)),
|
||||||
|
)
|
||||||
|
const viewportStart = model === null || viewport === null
|
||||||
|
? model?.start ?? 0
|
||||||
|
: Math.min(
|
||||||
|
Math.max(viewport.start, model.start),
|
||||||
|
model.end - viewportDuration,
|
||||||
|
)
|
||||||
|
const domainDuration = viewport === null ? fullDuration : viewportDuration
|
||||||
|
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
|
||||||
const committed = model === null || range === null
|
const committed = model === null || range === null
|
||||||
? null
|
? null
|
||||||
: rangeFraction(range, model.start, domainDuration)
|
: rangeFraction(range, domainStart, domainDuration)
|
||||||
const visibleRange = draft ?? committed
|
const visibleRange = draft ?? committed
|
||||||
const focusedCount = useMemo(
|
const activeRange = draft === null
|
||||||
() => range === null
|
? range
|
||||||
? model?.spans.length ?? 0
|
: {
|
||||||
: deriveTrajectoryTimeline(filterTrajectoryTimelineRange(turns, range))?.spans.length ?? 0,
|
start: domainStart + draft.start * domainDuration,
|
||||||
[model?.spans.length, range, turns],
|
end: domainStart + draft.end * domainDuration,
|
||||||
)
|
}
|
||||||
|
|
||||||
if (model === null) {
|
if (model === null) {
|
||||||
return (
|
return (
|
||||||
<section className={css.root} aria-label="Trajectory timeline">
|
<section className={css.root} aria-label="Trajectory timeline">
|
||||||
<div className={css.header}>
|
<div className={css.plot}>
|
||||||
<span className={css.title}>Overview</span>
|
<LaneLabels />
|
||||||
<span className={css.summary}>No timing data</span>
|
<div className={css.track}>
|
||||||
|
<span className={css.empty}>No timing data</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@@ -85,8 +132,8 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
|
|
||||||
const commit = (fraction: FractionRange) => {
|
const commit = (fraction: FractionRange) => {
|
||||||
onRangeChange({
|
onRangeChange({
|
||||||
start: model.start + fraction.start * domainDuration,
|
start: domainStart + fraction.start * domainDuration,
|
||||||
end: model.start + fraction.end * domainDuration,
|
end: domainStart + fraction.end * domainDuration,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +162,17 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
setDraft(null)
|
setDraft(null)
|
||||||
if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) {
|
if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) {
|
||||||
onRangeChange(null)
|
onRangeChange(null)
|
||||||
|
const point = domainStart + selected.start * domainDuration
|
||||||
|
const nearest = model.spans.reduce((candidate, span) => {
|
||||||
|
const candidateDistance = point < candidate.start
|
||||||
|
? candidate.start - point
|
||||||
|
: point > candidate.end ? point - candidate.end : 0
|
||||||
|
const spanDistance = point < span.start
|
||||||
|
? span.start - point
|
||||||
|
: point > span.end ? point - span.end : 0
|
||||||
|
return spanDistance < candidateDistance ? span : candidate
|
||||||
|
})
|
||||||
|
onRecordFocus?.(nearest.index)
|
||||||
} else {
|
} else {
|
||||||
commit(selected)
|
commit(selected)
|
||||||
}
|
}
|
||||||
@@ -131,85 +189,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
setDraft(null)
|
setDraft(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticks = Array.from({ length: TICK_COUNT }, (_, index) => {
|
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||||
const fraction = index / (TICK_COUNT - 1)
|
event.preventDefault()
|
||||||
return {
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
fraction,
|
const anchorFraction =
|
||||||
label: formatTimelineOffset(fraction * domainDuration),
|
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||||
|
const nextDuration = Math.min(
|
||||||
|
fullDuration,
|
||||||
|
Math.max(
|
||||||
|
Math.min(mode === 'actual' ? 20 : MINIMUM_ZOOM_OPERATIONS, fullDuration),
|
||||||
|
domainDuration * Math.exp(event.deltaY * 0.0015),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (nextDuration >= fullDuration * 0.999) {
|
||||||
|
setViewport(null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
const anchorTime = domainStart + anchorFraction * domainDuration
|
||||||
const summary = range === null
|
const nextStart = Math.min(
|
||||||
? `${model.spans.length} timed events`
|
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
|
||||||
: `${focusedCount} of ${model.spans.length} events · ${formatTimelineOffset(range.start - model.start)}–${formatTimelineOffset(range.end - model.start)}`
|
model.end - nextDuration,
|
||||||
|
)
|
||||||
|
setViewport({ start: nextStart, end: nextStart + nextDuration })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={css.root} aria-label="Trajectory timeline">
|
<section className={css.root} aria-label="Trajectory timeline">
|
||||||
<div className={css.header}>
|
<div className={css.plot}>
|
||||||
<span className={css.title}>Overview</span>
|
<LaneLabels />
|
||||||
<span className={css.summary} aria-live="polite">{summary}</span>
|
<div
|
||||||
{range !== null && (
|
className={css.track}
|
||||||
<button
|
aria-label="Timeline overview; drag horizontally to focus events"
|
||||||
className={css.clear}
|
tabIndex={0}
|
||||||
type="button"
|
onKeyDown={onKeyDown}
|
||||||
onClick={() => {
|
onPointerDown={onPointerDown}
|
||||||
onRangeChange(null)
|
onPointerMove={onPointerMove}
|
||||||
}}
|
onPointerUp={onPointerEnd}
|
||||||
>
|
onPointerCancel={onPointerCancel}
|
||||||
Clear selection
|
onWheel={onWheel}
|
||||||
</button>
|
onContextMenu={(event) => {
|
||||||
)}
|
event.preventDefault()
|
||||||
</div>
|
onRangeChange(null)
|
||||||
<div
|
setViewport(null)
|
||||||
className={css.plot}
|
}}
|
||||||
aria-label="Timeline overview; drag horizontally to filter events"
|
>
|
||||||
tabIndex={0}
|
{visibleRange !== null && (
|
||||||
onKeyDown={onKeyDown}
|
<div
|
||||||
onPointerDown={onPointerDown}
|
className={css.selection}
|
||||||
onPointerMove={onPointerMove}
|
data-dragging={draft === null ? undefined : 'true'}
|
||||||
onPointerUp={onPointerEnd}
|
aria-hidden="true"
|
||||||
onPointerCancel={onPointerCancel}
|
style={{
|
||||||
>
|
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
|
||||||
<div className={css.ticks} aria-hidden="true">
|
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
|
||||||
{ticks.map(tick => (
|
} as CSSProperties}
|
||||||
<span
|
/>
|
||||||
className={css.tick}
|
)}
|
||||||
key={tick.fraction}
|
<div className={css.turnBoundaries} aria-hidden="true">
|
||||||
style={{ '--trajectory-tick-left': `${tick.fraction * 100}%` } as CSSProperties}
|
{model.turnBoundaries
|
||||||
>
|
.slice(1)
|
||||||
{tick.label}
|
.filter(boundary =>
|
||||||
</span>
|
boundary.time >= domainStart
|
||||||
))}
|
&& boundary.time <= domainStart + domainDuration)
|
||||||
|
.map(boundary => (
|
||||||
|
<span
|
||||||
|
className={css.turnBoundary}
|
||||||
|
data-turn={boundary.turn}
|
||||||
|
key={boundary.turn}
|
||||||
|
style={{
|
||||||
|
'--trajectory-turn-left':
|
||||||
|
`${(boundary.time - domainStart) / domainDuration * 100}%`,
|
||||||
|
} as CSSProperties}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={css.lanes} aria-hidden="true">
|
||||||
|
{model.spans
|
||||||
|
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
|
||||||
|
.map((span) => {
|
||||||
|
const left = (span.start - domainStart) / domainDuration
|
||||||
|
const width = (span.end - span.start) / domainDuration
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={css.span}
|
||||||
|
data-timeline-span={span.kind}
|
||||||
|
data-current={span.index === selectedIndex || undefined}
|
||||||
|
data-selected={activeRange === null
|
||||||
|
? undefined
|
||||||
|
: span.start <= activeRange.end && span.end >= activeRange.start
|
||||||
|
? 'true'
|
||||||
|
: 'false'}
|
||||||
|
key={span.index}
|
||||||
|
title={span.label}
|
||||||
|
style={{
|
||||||
|
'--trajectory-span-left': `${left * 100}%`,
|
||||||
|
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
|
||||||
|
'--trajectory-span-lane': span.lane,
|
||||||
|
} as CSSProperties}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.lanes} aria-hidden="true">
|
|
||||||
{model.spans.map((span) => {
|
|
||||||
const left = (span.start - model.start) / domainDuration
|
|
||||||
const width = (span.end - span.start) / domainDuration
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={css.span}
|
|
||||||
data-timeline-span={span.kind}
|
|
||||||
key={span.index}
|
|
||||||
title={`${span.label} · ${formatTimelineOffset(span.end - span.start)}`}
|
|
||||||
style={{
|
|
||||||
'--trajectory-span-left': `${left * 100}%`,
|
|
||||||
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
|
|
||||||
'--trajectory-span-lane': span.lane,
|
|
||||||
} as CSSProperties}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{visibleRange !== null && (
|
|
||||||
<div
|
|
||||||
className={css.selection}
|
|
||||||
data-dragging={draft === null ? undefined : 'true'}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
|
|
||||||
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
|
|
||||||
} as CSSProperties}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,61 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modeSwitch {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: 5px;
|
||||||
|
padding: 0 7px;
|
||||||
|
gap: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font: var(--dsw-font-xs-13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeSwitch:hover {
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeSwitch:focus-visible {
|
||||||
|
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeTrack {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 26px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--dsw-alias-border-l2);
|
||||||
|
transition: background-color 120ms var(--ds-ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeThumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--dsw-alias-bg-layer-1);
|
||||||
|
transition: transform 120ms var(--ds-ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeSwitch[aria-checked='true'] .modeTrack {
|
||||||
|
background: var(--dsw-alias-state-business-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeSwitch[aria-checked='true'] .modeThumb {
|
||||||
|
transform: translateX(12px);
|
||||||
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import css from './TrajectoryToolbar.module.css'
|
import css from './TrajectoryToolbar.module.css'
|
||||||
|
|
||||||
export interface TrajectoryToolbarProps {
|
export interface TrajectoryToolbarProps {
|
||||||
|
/** Whether the timeline uses recorded durations instead of equal-width operations. */
|
||||||
|
actualTime: boolean
|
||||||
|
/** Select the timeline's recorded-time or equal-width projection. */
|
||||||
|
onActualTimeChange: (actualTime: boolean) => void
|
||||||
/** Number of turns containing more than one row. */
|
/** Number of turns containing more than one row. */
|
||||||
collapsibleTurns: number
|
collapsibleTurns: number
|
||||||
/** Whether every collapsible turn is currently folded. */
|
/** Whether every collapsible turn is currently folded. */
|
||||||
@@ -23,6 +27,8 @@ export interface TrajectoryToolbarProps {
|
|||||||
* @returns the toolbar element.
|
* @returns the toolbar element.
|
||||||
*/
|
*/
|
||||||
export function TrajectoryToolbar({
|
export function TrajectoryToolbar({
|
||||||
|
actualTime,
|
||||||
|
onActualTimeChange,
|
||||||
collapsibleTurns,
|
collapsibleTurns,
|
||||||
allTurnsCollapsed,
|
allTurnsCollapsed,
|
||||||
onToggleAllTurns,
|
onToggleAllTurns,
|
||||||
@@ -37,6 +43,18 @@ export function TrajectoryToolbar({
|
|||||||
<span className={css.title}>Trajectory</span>
|
<span className={css.title}>Trajectory</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.actions}>
|
<div className={css.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.modeSwitch}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={actualTime}
|
||||||
|
onClick={() => { onActualTimeChange(!actualTime) }}
|
||||||
|
>
|
||||||
|
<span>Actual time</span>
|
||||||
|
<span className={css.modeTrack} aria-hidden="true">
|
||||||
|
<span className={css.modeThumb} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.action}
|
className={css.action}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
|
|||||||
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
|
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
|
||||||
import { deriveTrajectoryLayout } from './layout.ts'
|
import { deriveTrajectoryLayout } from './layout.ts'
|
||||||
import {
|
import {
|
||||||
filterTrajectoryTimelineRange, type TrajectoryTimeRange,
|
trajectoryTimelineFocusIndexes,
|
||||||
|
type TrajectoryTimelineMode,
|
||||||
|
type TrajectoryTimeRange,
|
||||||
} from './timeline.ts'
|
} from './timeline.ts'
|
||||||
import css from './views.module.css'
|
import css from './views.module.css'
|
||||||
|
|
||||||
@@ -81,6 +83,9 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
|||||||
branchId: number
|
branchId: number
|
||||||
range: TrajectoryTimeRange
|
range: TrajectoryTimeRange
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const [timelineMode, setTimelineMode] = useState<TrajectoryTimelineMode>('sequence')
|
||||||
|
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||||
|
const ledgerRef = useRef<HTMLDivElement>(null)
|
||||||
const nodes = useSession(s => s.nodes)
|
const nodes = useSession(s => s.nodes)
|
||||||
const inspection = useSession(s => s.inspection)
|
const inspection = useSession(s => s.inspection)
|
||||||
const hasMore = useSession(s => s.hasMore)
|
const hasMore = useSession(s => s.hasMore)
|
||||||
@@ -260,12 +265,33 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
|||||||
const timelineRange = timelineSelection?.branchId === currentBranch.id
|
const timelineRange = timelineSelection?.branchId === currentBranch.id
|
||||||
? timelineSelection.range
|
? timelineSelection.range
|
||||||
: null
|
: null
|
||||||
const focusedTurns = useMemo(
|
const timelineFocusIndexes = useMemo(
|
||||||
() => filterTrajectoryTimelineRange(turns, timelineRange),
|
() => timelineRange === null
|
||||||
[timelineRange, turns],
|
? null
|
||||||
|
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
|
||||||
|
[timelineMode, timelineRange, turns],
|
||||||
)
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
|
||||||
|
const ledger = ledgerRef.current
|
||||||
|
if (ledger === null) return
|
||||||
|
const focusedRows = [
|
||||||
|
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
|
||||||
|
]
|
||||||
|
const first = focusedRows.at(0)
|
||||||
|
const last = focusedRows.at(-1)
|
||||||
|
if (first === undefined || last === undefined) return
|
||||||
|
const focusHeight =
|
||||||
|
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
|
||||||
|
if (focusHeight > ledger.clientHeight) {
|
||||||
|
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
focusedRows[Math.floor((focusedRows.length - 1) / 2)]
|
||||||
|
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}, [timelineFocusIndexes])
|
||||||
const collapsibleTurnIds = useMemo(
|
const collapsibleTurnIds = useMemo(
|
||||||
() => focusedTurns
|
() => turns
|
||||||
.filter(turn =>
|
.filter(turn =>
|
||||||
turn.groups.reduce(
|
turn.groups.reduce(
|
||||||
(count, group) =>
|
(count, group) =>
|
||||||
@@ -274,13 +300,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
|||||||
0,
|
0,
|
||||||
) > 1)
|
) > 1)
|
||||||
.map(turn => turn.turn),
|
.map(turn => turn.turn),
|
||||||
[focusedTurns],
|
[turns],
|
||||||
)
|
)
|
||||||
const allTurnsCollapsed = collapsibleTurnIds.length > 0
|
const allTurnsCollapsed = collapsibleTurnIds.length > 0
|
||||||
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
|
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
|
||||||
const collapsibleAssistantIds = useMemo(() => {
|
const collapsibleAssistantIds = useMemo(() => {
|
||||||
const ids: number[] = []
|
const ids: number[] = []
|
||||||
for (const turn of focusedTurns) {
|
for (const turn of turns) {
|
||||||
const cells = turn.groups.flatMap(group => group.cells)
|
const cells = turn.groups.flatMap(group => group.cells)
|
||||||
for (let i = 0; i < cells.length; i++) {
|
for (let i = 0; i < cells.length; i++) {
|
||||||
const cell = cells[i]
|
const cell = cells[i]
|
||||||
@@ -290,7 +316,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}, [focusedTurns])
|
}, [turns])
|
||||||
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
|
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
|
||||||
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
|
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
|
||||||
|
|
||||||
@@ -339,6 +365,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
|||||||
return (
|
return (
|
||||||
<div className={css.root}>
|
<div className={css.root}>
|
||||||
<TrajectoryToolbar
|
<TrajectoryToolbar
|
||||||
|
actualTime={timelineMode === 'actual'}
|
||||||
|
onActualTimeChange={(actualTime) => {
|
||||||
|
setTimelineMode(actualTime ? 'actual' : 'sequence')
|
||||||
|
setTimelineSelection(null)
|
||||||
|
}}
|
||||||
collapsibleTurns={collapsibleTurnIds.length}
|
collapsibleTurns={collapsibleTurnIds.length}
|
||||||
allTurnsCollapsed={allTurnsCollapsed}
|
allTurnsCollapsed={allTurnsCollapsed}
|
||||||
onToggleAllTurns={toggleAllTurns}
|
onToggleAllTurns={toggleAllTurns}
|
||||||
@@ -348,16 +379,25 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
|||||||
/>
|
/>
|
||||||
<TrajectoryTimeline
|
<TrajectoryTimeline
|
||||||
turns={turns}
|
turns={turns}
|
||||||
|
mode={timelineMode}
|
||||||
range={timelineRange}
|
range={timelineRange}
|
||||||
|
selectedIndex={selectedTimelineIndex}
|
||||||
onRangeChange={(range) => {
|
onRangeChange={(range) => {
|
||||||
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
|
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
|
||||||
}}
|
}}
|
||||||
|
onRecordFocus={(index) => {
|
||||||
|
ledgerRef.current
|
||||||
|
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
|
||||||
|
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className={css.ledger}>
|
<div ref={ledgerRef} className={css.ledger}>
|
||||||
<TrajectoryTable
|
<TrajectoryTable
|
||||||
key={`${currentBranch.id}:${timelineRange?.start ?? 'all'}:${timelineRange?.end ?? 'all'}`}
|
key={currentBranch.id}
|
||||||
requestNumbers={requestNumbers}
|
requestNumbers={requestNumbers}
|
||||||
turns={focusedTurns}
|
turns={turns}
|
||||||
|
timelineFocusIndexes={timelineFocusIndexes}
|
||||||
|
onSelectedIndexChange={setSelectedTimelineIndex}
|
||||||
collapsedTurns={collapsedTurns}
|
collapsedTurns={collapsedTurns}
|
||||||
onToggleTurn={toggleTurn}
|
onToggleTurn={toggleTurn}
|
||||||
collapsedAssistants={collapsedAssistants}
|
collapsedAssistants={collapsedAssistants}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
/** Time-domain projection and filtering for the trajectory overview. */
|
/** Operation-sequence and recorded-time projections for the trajectory overview. */
|
||||||
|
|
||||||
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
|
|
||||||
import type { TrajectoryTurnModel } from './layout.ts'
|
import type { TrajectoryTurnModel } from './layout.ts'
|
||||||
|
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
|
||||||
|
|
||||||
/** Inclusive absolute-time selection in Unix epoch milliseconds. */
|
/** Horizontal projection used by the trajectory timeline. */
|
||||||
|
export type TrajectoryTimelineMode = 'sequence' | 'actual'
|
||||||
|
|
||||||
|
/** Inclusive selection in the active timeline projection's domain. */
|
||||||
export interface TrajectoryTimeRange {
|
export interface TrajectoryTimeRange {
|
||||||
start: number
|
start: number
|
||||||
end: number
|
end: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One timed ledger record projected into the overview. */
|
/** One ledger record projected into the active timeline domain. */
|
||||||
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
|
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
|
||||||
index: number
|
index: number
|
||||||
kind: TrajectoryCellKind
|
kind: TrajectoryCellKind
|
||||||
@@ -17,9 +20,22 @@ export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
|
|||||||
lane: number
|
lane: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One turn boundary in the active timeline domain. */
|
||||||
|
export interface TrajectoryTimelineTurnBoundary {
|
||||||
|
turn: number
|
||||||
|
time: number
|
||||||
|
}
|
||||||
|
|
||||||
/** Full-domain model used by the overview. */
|
/** Full-domain model used by the overview. */
|
||||||
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
|
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
|
||||||
spans: readonly TrajectoryTimelineSpan[]
|
spans: readonly TrajectoryTimelineSpan[]
|
||||||
|
turnBoundaries: readonly TrajectoryTimelineTurnBoundary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneFor(kind: TrajectoryCellKind): number {
|
||||||
|
if (kind === 'tool' || kind === 'subtool') return 2
|
||||||
|
if (kind === 'message' || kind === 'compacted') return 1
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function finite(value: number | null | undefined): value is number {
|
function finite(value: number | null | undefined): value is number {
|
||||||
@@ -34,22 +50,58 @@ function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
|
|||||||
return { start: cell.startedAt, end: cell.startedAt + durationMs }
|
return { start: cell.startedAt, end: cell.startedAt + durationMs }
|
||||||
}
|
}
|
||||||
|
|
||||||
function laneFor(kind: TrajectoryCellKind): number {
|
|
||||||
if (kind === 'tool' || kind === 'subtool') return 2
|
|
||||||
if (kind === 'message' || kind === 'compacted') return 1
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project every visible timed record into a stable three-lane overview.
|
* Project every visible record into a stable three-lane timeline.
|
||||||
* @param turns - Unfiltered trajectory layout.
|
* @param turns - Unfiltered trajectory layout.
|
||||||
* @returns Timeline model, or `null` when no record carries a start time.
|
* @param mode - Equal-width operation sequence or recorded wall-clock timing.
|
||||||
|
* @returns Timeline model, or `null` when no record is visible.
|
||||||
*/
|
*/
|
||||||
export function deriveTrajectoryTimeline(
|
export function deriveTrajectoryTimeline(
|
||||||
turns: readonly TrajectoryTurnModel[],
|
turns: readonly TrajectoryTurnModel[],
|
||||||
|
mode: TrajectoryTimelineMode = 'sequence',
|
||||||
): TrajectoryTimelineModel | null {
|
): TrajectoryTimelineModel | null {
|
||||||
const spans = turns.flatMap(turn =>
|
if (mode === 'actual') return deriveActualTimeline(turns)
|
||||||
turn.groups.flatMap(group =>
|
const spans: TrajectoryTimelineSpan[] = []
|
||||||
|
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||||
|
|
||||||
|
for (const turn of turns) {
|
||||||
|
const cells = turn.groups.flatMap(group =>
|
||||||
|
group.cells.filter(cell => cell.requestOnly !== true),
|
||||||
|
)
|
||||||
|
if (cells.length === 0) continue
|
||||||
|
turnBoundaries.push({
|
||||||
|
turn: turn.turn,
|
||||||
|
time: spans.length,
|
||||||
|
})
|
||||||
|
spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
|
||||||
|
start: spans.length + offset,
|
||||||
|
end: spans.length + offset + 1,
|
||||||
|
index: cell.index,
|
||||||
|
kind: cell.kind,
|
||||||
|
label: cell.text,
|
||||||
|
lane: laneFor(cell.kind),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spans.length === 0) return null
|
||||||
|
return {
|
||||||
|
start: 0,
|
||||||
|
end: spans.length,
|
||||||
|
spans,
|
||||||
|
turnBoundaries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveActualTimeline(
|
||||||
|
turns: readonly TrajectoryTurnModel[],
|
||||||
|
): TrajectoryTimelineModel | null {
|
||||||
|
const spans: TrajectoryTimelineSpan[] = []
|
||||||
|
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||||
|
let removedUserIdle = 0
|
||||||
|
let previousTurnEnd: number | null = null
|
||||||
|
|
||||||
|
for (const turn of turns) {
|
||||||
|
const rawSpans = turn.groups.flatMap(group =>
|
||||||
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
|
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
|
||||||
if (cell.requestOnly === true) return []
|
if (cell.requestOnly === true) return []
|
||||||
const range = cellRange(cell)
|
const range = cellRange(cell)
|
||||||
@@ -63,48 +115,53 @@ export function deriveTrajectoryTimeline(
|
|||||||
lane: laneFor(cell.kind),
|
lane: laneFor(cell.kind),
|
||||||
}]
|
}]
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
)
|
if (rawSpans.length === 0) continue
|
||||||
|
|
||||||
|
const turnStart = Math.min(...rawSpans.map(span => span.start))
|
||||||
|
const turnEnd = Math.max(...rawSpans.map(span => span.end))
|
||||||
|
if (previousTurnEnd !== null) {
|
||||||
|
removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
|
||||||
|
}
|
||||||
|
spans.push(...rawSpans.map(span => ({
|
||||||
|
...span,
|
||||||
|
start: span.start - removedUserIdle,
|
||||||
|
end: span.end - removedUserIdle,
|
||||||
|
})))
|
||||||
|
turnBoundaries.push({
|
||||||
|
turn: turn.turn,
|
||||||
|
time: turnStart - removedUserIdle,
|
||||||
|
})
|
||||||
|
previousTurnEnd = previousTurnEnd === null
|
||||||
|
? turnEnd
|
||||||
|
: Math.max(previousTurnEnd, turnEnd)
|
||||||
|
}
|
||||||
|
|
||||||
if (spans.length === 0) return null
|
if (spans.length === 0) return null
|
||||||
return {
|
return {
|
||||||
start: Math.min(...spans.map(span => span.start)),
|
start: Math.min(...spans.map(span => span.start)),
|
||||||
end: Math.max(...spans.map(span => span.end)),
|
end: Math.max(...spans.map(span => span.end)),
|
||||||
spans,
|
spans,
|
||||||
|
turnBoundaries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function overlaps(cell: TrajectoryCellProps, range: TrajectoryTimeRange): boolean {
|
|
||||||
const timed = cellRange(cell)
|
|
||||||
return timed !== null && timed.start <= range.end && timed.end >= range.start
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep records active at any point inside an inclusive selected interval.
|
* Identify records active at any point inside an inclusive selected interval.
|
||||||
* @param turns - Unfiltered trajectory layout.
|
* @param turns - Unfiltered trajectory layout.
|
||||||
* @param range - Absolute selected interval, or `null` for the full ledger.
|
* @param range - Selected interval in the active projection.
|
||||||
* @returns A layout retaining original turn, group, and record identities.
|
* @param mode - Equal-width operation sequence or recorded wall-clock timing.
|
||||||
|
* @returns Record indexes inside the focus interval.
|
||||||
*/
|
*/
|
||||||
export function filterTrajectoryTimelineRange(
|
export function trajectoryTimelineFocusIndexes(
|
||||||
turns: readonly TrajectoryTurnModel[],
|
turns: readonly TrajectoryTurnModel[],
|
||||||
range: TrajectoryTimeRange | null,
|
range: TrajectoryTimeRange,
|
||||||
): readonly TrajectoryTurnModel[] {
|
mode: TrajectoryTimelineMode = 'sequence',
|
||||||
if (range === null) return turns
|
): ReadonlySet<number> {
|
||||||
return turns.flatMap((turn): TrajectoryTurnModel[] => {
|
const model = deriveTrajectoryTimeline(turns, mode)
|
||||||
const groups = turn.groups.flatMap((group) => {
|
return new Set(
|
||||||
const cells = group.cells.filter(cell => overlaps(cell, range))
|
model?.spans
|
||||||
return cells.length === 0 ? [] : [{ ...group, cells }]
|
.filter(span => span.start <= range.end && span.end >= range.start)
|
||||||
})
|
.map(span => span.index),
|
||||||
return groups.length === 0 ? [] : [{ ...turn, groups }]
|
)
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a relative timeline offset with a compact unit.
|
|
||||||
* @param milliseconds - Non-negative relative offset.
|
|
||||||
* @returns Millisecond or second label.
|
|
||||||
*/
|
|
||||||
export function formatTimelineOffset(milliseconds: number): string {
|
|
||||||
if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
|
|
||||||
const seconds = milliseconds / 1_000
|
|
||||||
return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,18 +19,13 @@ import type {
|
|||||||
ConversationSnapshot, RequestView, SessionId, SessionListState, WorkspaceListState,
|
ConversationSnapshot, RequestView, SessionId, SessionListState, WorkspaceListState,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
// Export discipline: packages/client/AGENTS.md.
|
|
||||||
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||||
import type { TrajectoryTurnModel } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/layout.ts'
|
|
||||||
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
|
|
||||||
import {
|
|
||||||
deriveTrajectoryTimeline,
|
|
||||||
filterTrajectoryTimelineRange,
|
|
||||||
formatTimelineOffset,
|
|
||||||
} from '@deepseek-ai/dsh-client-ui-trajectory/src/client/timeline.ts'
|
|
||||||
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 { TrajectoryView } from '../src/client/TrajectoryView.tsx'
|
||||||
|
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||||
|
|
||||||
const SID = 's1' as SessionId
|
const SID = 's1' as SessionId
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
@@ -229,11 +224,11 @@ describe('tab switching in ConversationRoot', () => {
|
|||||||
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
|
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dragging the overview focuses overlapping records and clear restores the ledger', async () => {
|
it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
|
||||||
const b = await bench()
|
const b = await bench()
|
||||||
mount(b.slots)
|
mount(b.slots)
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to filter events')
|
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||||
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
|
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
|
||||||
toJSON: () => ({}),
|
toJSON: () => ({}),
|
||||||
@@ -242,10 +237,11 @@ describe('tab switching in ConversationRoot', () => {
|
|||||||
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
|
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
|
||||||
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
|
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
|
||||||
|
|
||||||
expect(screen.queryByRole('row', { name: /USER/ })).toBeNull()
|
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
||||||
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeTruthy()
|
.toBe('outside')
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }))
|
fireEvent.contextMenu(plot)
|
||||||
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
||||||
|
.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('empty window keeps the toolbar and reports no timing data', async () => {
|
it('empty window keeps the toolbar and reports no timing data', async () => {
|
||||||
@@ -272,25 +268,57 @@ describe('timeline projection', () => {
|
|||||||
}],
|
}],
|
||||||
}] satisfies readonly TrajectoryTurnModel[]
|
}] satisfies readonly TrajectoryTurnModel[]
|
||||||
|
|
||||||
it('uses real start/duration timing and stable semantic lanes', () => {
|
it('uses equal-width operation slots and stable semantic lanes', () => {
|
||||||
expect(deriveTrajectoryTimeline(turns)).toEqual({
|
expect(deriveTrajectoryTimeline(turns)).toEqual({
|
||||||
start: 1_000,
|
start: 0,
|
||||||
end: 3_000,
|
end: 3,
|
||||||
spans: [
|
spans: [
|
||||||
{
|
{
|
||||||
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 1_000, end: 2_000,
|
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
|
||||||
},
|
},
|
||||||
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 2_000, end: 3_000 },
|
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
|
||||||
|
{ index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
|
||||||
],
|
],
|
||||||
|
turnBoundaries: [{ turn: 1, time: 0 }],
|
||||||
})
|
})
|
||||||
expect(formatTimelineOffset(999)).toBe('999 ms')
|
|
||||||
expect(formatTimelineOffset(1_500)).toBe('1.5 s')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters inclusively and drops records without known timing', () => {
|
it('ignores durations and idle gaps while retaining turn boundaries', () => {
|
||||||
const focused = filterTrajectoryTimelineRange(turns, { start: 2_000, end: 2_000 })
|
const separatedTurns = [
|
||||||
expect(focused[0]?.groups[0]?.cells.map(cell => cell.index)).toEqual([1, 2])
|
{
|
||||||
expect(filterTrajectoryTimelineRange(turns, null)).toBe(turns)
|
turn: 1,
|
||||||
|
groups: [{
|
||||||
|
title: 'Step 1',
|
||||||
|
cells: [
|
||||||
|
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
|
||||||
|
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
turn: 2,
|
||||||
|
groups: [{
|
||||||
|
title: 'Step 1',
|
||||||
|
cells: [
|
||||||
|
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
] satisfies readonly TrajectoryTurnModel[]
|
||||||
|
|
||||||
|
expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
|
||||||
|
start: 0,
|
||||||
|
end: 3,
|
||||||
|
spans: [
|
||||||
|
{ index: 1, start: 0, end: 1 },
|
||||||
|
{ index: 2, start: 1, end: 2 },
|
||||||
|
{ index: 3, start: 2, end: 3 },
|
||||||
|
],
|
||||||
|
turnBoundaries: [
|
||||||
|
{ turn: 1, time: 0 },
|
||||||
|
{ turn: 2, time: 2 },
|
||||||
|
],
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('empty inputs produce no model and the standalone view reports its empty form', () => {
|
it('empty inputs produce no model and the standalone view reports its empty form', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user