feat(ui): refine trajectory timeline controls
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
/* Conversation column skeleton: header (breadcrumb row + tabs) over the view
|
||||
area, composer InputBar at the bottom. Column width/squeeze is layout's;
|
||||
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with
|
||||
a 3px active bar. */
|
||||
/* Conversation column skeleton: one header row with breadcrumbs and view
|
||||
tabs over the view area, composer InputBar at the bottom. Column
|
||||
width/squeeze is layout's; this fills its cell. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
@@ -26,6 +25,7 @@
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
@@ -72,12 +72,14 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
|
||||
/* View tabs occupy the far-right side of the session-title row. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
align-items: flex-end;
|
||||
gap: 36px;
|
||||
margin-top: 4px;
|
||||
padding-left: 8px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */
|
||||
|
||||
@@ -69,23 +69,23 @@ export function ConversationSession({
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
{tabs.map(view => (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view.id === active?.id}
|
||||
className={clsx(css.tab, view.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.setView(view.id) }}
|
||||
>
|
||||
{view.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
{tabs.map(view => (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view.id === active?.id}
|
||||
className={clsx(css.tab, view.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.setView(view.id) }}
|
||||
>
|
||||
{view.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>}
|
||||
{!blankHero && <div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
|
||||
@@ -666,12 +666,23 @@
|
||||
.detailTabs {
|
||||
display: flex;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
gap: 1px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailTabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detailTab {
|
||||
|
||||
@@ -219,8 +219,12 @@ export interface TrajectoryTableProps {
|
||||
turns: readonly TrajectoryTurnModel[]
|
||||
/** Record indexes emphasized by the active timeline focus. */
|
||||
timelineFocusIndexes?: ReadonlySet<number> | null
|
||||
/** Record indexes retained by the active live search, or null without a query. */
|
||||
searchMatchIndexes?: ReadonlySet<number> | null
|
||||
/** Report the record currently selected in the local inspector. */
|
||||
onSelectedIndexChange?: (index: number | null) => void
|
||||
/** Report a direct user selection from a ledger row. */
|
||||
onRecordSelect?: (index: number) => void
|
||||
/** Turn ids whose rows after the first are folded into a summary. */
|
||||
collapsedTurns: ReadonlySet<number>
|
||||
/** Toggle one turn between folded and expanded. */
|
||||
@@ -290,6 +294,31 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
|
||||
})
|
||||
}
|
||||
|
||||
function filterRecords(
|
||||
records: readonly TableRecord[],
|
||||
matches: ReadonlySet<number>,
|
||||
): TableRecord[] {
|
||||
const filtered = records
|
||||
.filter(record =>
|
||||
record.cell.requestOnly !== true && matches.has(record.cell.index),
|
||||
)
|
||||
.map(record => ({ ...record, groupStart: false, turnStart: false, turnEnd: false }))
|
||||
const startedTurns = new Set<number>()
|
||||
for (const [index, record] of filtered.entries()) {
|
||||
const previous = filtered[index - 1]
|
||||
const next = filtered[index + 1]
|
||||
record.groupStart = previous === undefined
|
||||
|| previous.turn !== record.turn
|
||||
|| previous.group !== record.group
|
||||
record.turnStart = !startedTurns.has(record.turn)
|
||||
&& record.cell.kind !== 'system'
|
||||
&& record.cell.kind !== 'compacted'
|
||||
if (record.turnStart) startedTurns.add(record.turn)
|
||||
record.turnEnd = next === undefined || next.turn !== record.turn
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function requestStep(group: string): number | undefined {
|
||||
if (!group.startsWith('Step ')) return undefined
|
||||
const value = Number(group.slice('Step '.length))
|
||||
@@ -1362,7 +1391,9 @@ export function TrajectoryTable({
|
||||
requestNumbers: sessionRequestNumbers,
|
||||
turns,
|
||||
timelineFocusIndexes = null,
|
||||
searchMatchIndexes = null,
|
||||
onSelectedIndexChange,
|
||||
onRecordSelect,
|
||||
collapsedTurns,
|
||||
onToggleTurn,
|
||||
collapsedAssistants,
|
||||
@@ -1381,8 +1412,12 @@ export function TrajectoryTable({
|
||||
}, [onSelectedIndexChange, selectedIndex])
|
||||
const allRecords = flattenRecords(turns)
|
||||
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
|
||||
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
|
||||
const records = collapseAssistantRecords(turnRecords, collapsedAssistants)
|
||||
const records = searchMatchIndexes === null
|
||||
? collapseAssistantRecords(
|
||||
collapseTurnRecords(allRecords, collapsedTurns),
|
||||
collapsedAssistants,
|
||||
)
|
||||
: filterRecords(allRecords, searchMatchIndexes)
|
||||
const selected = allRecords.find(record => record.cell.index === selectedIndex)
|
||||
const selectedPrompt = selected?.cell.kind === 'system'
|
||||
? selected.cell.promptDetail
|
||||
@@ -1484,6 +1519,7 @@ export function TrajectoryTable({
|
||||
|
||||
const selectRecord = (index: number) => {
|
||||
const record = allRecords.find(candidate => candidate.cell.index === index)
|
||||
onRecordSelect?.(index)
|
||||
setSelectedRequest(null)
|
||||
setSelectedIndex(index)
|
||||
if (record === undefined) return
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
.labels span {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
right: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -130,6 +130,11 @@
|
||||
);
|
||||
}
|
||||
|
||||
.span[data-equal-duration='true'] {
|
||||
width: 8px;
|
||||
min-width: 8px;
|
||||
}
|
||||
|
||||
.span[data-selected='false'] {
|
||||
opacity: 0.2;
|
||||
}
|
||||
@@ -142,6 +147,10 @@
|
||||
0 0 0 2px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.span[data-search-match='false'] {
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.selection {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -150,8 +159,6 @@
|
||||
left: var(--trajectory-selection-left);
|
||||
width: var(--trajectory-selection-width);
|
||||
min-width: 1px;
|
||||
border-right: 1px solid var(--dsw-alias-state-business-primary);
|
||||
border-left: 1px solid var(--dsw-alias-state-business-primary);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-business-primary) 12%,
|
||||
@@ -163,8 +170,34 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selection::before,
|
||||
.selection::after {
|
||||
.selectionEdges {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--trajectory-selection-left);
|
||||
width: var(--trajectory-selection-width);
|
||||
min-width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hoverLine {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: clamp(
|
||||
0px,
|
||||
calc(var(--trajectory-hover-left) - 1px),
|
||||
calc(100% - 2px)
|
||||
);
|
||||
width: 2px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selectionEdges::before,
|
||||
.selectionEdges::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@@ -173,12 +206,17 @@
|
||||
content: '';
|
||||
}
|
||||
|
||||
.selection::before {
|
||||
left: -2px;
|
||||
.selectionEdges::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.selection::after {
|
||||
right: -2px;
|
||||
.selectionEdges::after {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.selectionEdges[data-dragging='true']::before,
|
||||
.selectionEdges[data-dragging='true']::after {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.selection[data-dragging='true'] {
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface TrajectoryTimelineProps {
|
||||
mode: TrajectoryTimelineMode
|
||||
range: TrajectoryTimeRange | null
|
||||
selectedIndex?: number | null
|
||||
/** Record indexes matching the active ledger search, or null without a query. */
|
||||
searchMatchIndexes?: ReadonlySet<number> | null
|
||||
onRangeChange: (range: TrajectoryTimeRange | null) => void
|
||||
onRecordFocus?: (index: number) => void
|
||||
}
|
||||
@@ -38,6 +40,15 @@ function clampFraction(value: number): number {
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
function centeredRange(center: number, width: number): FractionRange {
|
||||
const clampedWidth = Math.min(1, Math.max(0, width))
|
||||
const start = Math.min(
|
||||
Math.max(center - clampedWidth / 2, 0),
|
||||
1 - clampedWidth,
|
||||
)
|
||||
return { start, end: start + clampedWidth }
|
||||
}
|
||||
|
||||
function rangeFraction(
|
||||
range: TrajectoryTimeRange,
|
||||
start: number,
|
||||
@@ -59,18 +70,20 @@ function LaneLabels() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Overview renderer with drag-to-filter and Escape/clear reset. */
|
||||
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
|
||||
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
turns,
|
||||
mode,
|
||||
range,
|
||||
selectedIndex = null,
|
||||
searchMatchIndexes = null,
|
||||
onRangeChange,
|
||||
onRecordFocus,
|
||||
}: TrajectoryTimelineProps) {
|
||||
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
|
||||
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
|
||||
const [draft, setDraft] = useState<FractionRange | null>(null)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -125,6 +138,11 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
)
|
||||
}
|
||||
|
||||
const minimumSelectionFraction = Math.min(
|
||||
1,
|
||||
fullDuration / domainDuration / model.spans.length,
|
||||
)
|
||||
|
||||
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||
@@ -141,6 +159,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
if (event.button !== 0) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const anchor = fractionAt(event)
|
||||
setHover(anchor)
|
||||
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
|
||||
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
@@ -150,31 +169,40 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
|
||||
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
const fraction = fractionAt(event)
|
||||
setHover(fraction)
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
setDraft(orderedRange(drag.anchor, fractionAt(event)))
|
||||
setDraft(orderedRange(drag.anchor, fraction))
|
||||
}
|
||||
|
||||
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
const selected = orderedRange(drag.anchor, fractionAt(event))
|
||||
const point = fractionAt(event)
|
||||
const selected = orderedRange(drag.anchor, point)
|
||||
setHover(point)
|
||||
dragRef.current = null
|
||||
setDraft(null)
|
||||
if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) {
|
||||
onRangeChange(null)
|
||||
const point = domainStart + selected.start * domainDuration
|
||||
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
|
||||
const committedRange = selected.end - selected.start < minimumSelectionFraction
|
||||
? centeredRange(
|
||||
click ? selected.start : (selected.start + selected.end) / 2,
|
||||
minimumSelectionFraction,
|
||||
)
|
||||
: selected
|
||||
commit(committedRange)
|
||||
if (click) {
|
||||
const timelinePoint = 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
|
||||
const candidateDistance = timelinePoint < candidate.start
|
||||
? candidate.start - timelinePoint
|
||||
: timelinePoint > candidate.end ? timelinePoint - candidate.end : 0
|
||||
const spanDistance = timelinePoint < span.start
|
||||
? span.start - timelinePoint
|
||||
: timelinePoint > span.end ? timelinePoint - span.end : 0
|
||||
return spanDistance < candidateDistance ? span : candidate
|
||||
})
|
||||
onRecordFocus?.(nearest.index)
|
||||
} else {
|
||||
commit(selected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +215,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
const onPointerCancel = () => {
|
||||
dragRef.current = null
|
||||
setDraft(null)
|
||||
setHover(null)
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||
@@ -197,7 +226,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
const nextDuration = Math.min(
|
||||
fullDuration,
|
||||
Math.max(
|
||||
Math.min(mode === 'actual' ? 20 : MINIMUM_ZOOM_OPERATIONS, fullDuration),
|
||||
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
|
||||
domainDuration * Math.exp(event.deltaY * 0.0015),
|
||||
),
|
||||
)
|
||||
@@ -226,6 +255,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerEnd}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onPointerLeave={() => {
|
||||
if (dragRef.current === null) setHover(null)
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -233,17 +269,37 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
setViewport(null)
|
||||
}}
|
||||
>
|
||||
{visibleRange !== null && (
|
||||
{hover !== null && draft === null && (
|
||||
<div
|
||||
className={css.selection}
|
||||
data-dragging={draft === null ? undefined : 'true'}
|
||||
className={css.hoverLine}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
|
||||
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
|
||||
'--trajectory-hover-left': `${hover * 100}%`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
className={css.selectionEdges}
|
||||
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 className={css.turnBoundaries} aria-hidden="true">
|
||||
{model.turnBoundaries
|
||||
.slice(1)
|
||||
@@ -272,7 +328,11 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
<span
|
||||
className={css.span}
|
||||
data-timeline-span={span.kind}
|
||||
data-equal-duration={mode === 'time' || undefined}
|
||||
data-current={span.index === selectedIndex || undefined}
|
||||
data-search-match={searchMatchIndexes === null
|
||||
? undefined
|
||||
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
|
||||
data-selected={activeRange === null
|
||||
? undefined
|
||||
: span.start <= activeRange.end && span.end >= activeRange.start
|
||||
|
||||
@@ -12,24 +12,11 @@
|
||||
.inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 14px 0 16px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xs-strong-13);
|
||||
padding: 0 10px 0 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -39,78 +26,127 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.modeSwitch {
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-right: 5px;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.modeSwitch:hover {
|
||||
.toggle: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;
|
||||
.toggle[aria-pressed='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.modeTrack {
|
||||
.toggle:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.toggleIcon {
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 88px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.control[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control:hover:not(:disabled),
|
||||
.control[aria-checked='true'],
|
||||
.control[aria-pressed='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.control:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.control:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.controlTrack {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
transition: background-color 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.modeThumb {
|
||||
.controlThumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
transition: transform 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.modeSwitch[aria-checked='true'] .modeTrack {
|
||||
.controlTrack[data-on='true'] {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.modeSwitch[aria-checked='true'] .modeThumb {
|
||||
transform: translateX(12px);
|
||||
.controlTrack[data-on='true'] .controlThumb {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
gap: 6px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
transition:
|
||||
color 120ms var(--ds-ease-in-out),
|
||||
background-color 120ms var(--ds-ease-in-out);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
@@ -120,7 +156,7 @@
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
@@ -130,11 +166,54 @@
|
||||
|
||||
.actionIcon {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: 13px/13px var(--ds-font-family-code);
|
||||
font: 14px/14px var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.summary {
|
||||
gap: 7px;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
flex: 0 1 164px;
|
||||
align-items: center;
|
||||
min-width: 84px;
|
||||
height: 22px;
|
||||
margin-left: auto;
|
||||
padding: 0 6px;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 4px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.search:hover {
|
||||
border-color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: transparent;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.searchInput::-webkit-search-cancel-button {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/** Trajectory toolbar: view identity, record totals, and the ledger fold control. */
|
||||
/** Trajectory toolbar: timeline and ledger fold controls. */
|
||||
|
||||
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './TrajectoryToolbar.module.css'
|
||||
|
||||
export interface TrajectoryToolbarProps {
|
||||
/** Whether the timeline uses recorded durations instead of equal-width operations. */
|
||||
/** Whether timeline blocks use recorded durations instead of equal widths. */
|
||||
actualDuration: boolean
|
||||
/** Select recorded-duration or equal-width blocks. */
|
||||
onActualDurationChange: (actualDuration: boolean) => void
|
||||
/** Whether recorded timing retains idle gaps between user turns. */
|
||||
actualTime: boolean
|
||||
/** Select the timeline's recorded-time or equal-width projection. */
|
||||
/** Select complete wall-clock timing or idle-compressed timing. */
|
||||
onActualTimeChange: (actualTime: boolean) => void
|
||||
/** Number of turns containing more than one row. */
|
||||
collapsibleTurns: number
|
||||
@@ -19,6 +24,10 @@ export interface TrajectoryToolbarProps {
|
||||
allAssistantsCollapsed: boolean
|
||||
/** Fold or expand tool calls under every collapsible assistant. */
|
||||
onToggleAllAssistants: () => void
|
||||
/** Current live ledger search query. */
|
||||
searchQuery: string
|
||||
/** Update the live ledger search query. */
|
||||
onSearchQueryChange: (query: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,6 +36,8 @@ export interface TrajectoryToolbarProps {
|
||||
* @returns the toolbar element.
|
||||
*/
|
||||
export function TrajectoryToolbar({
|
||||
actualDuration,
|
||||
onActualDurationChange,
|
||||
actualTime,
|
||||
onActualTimeChange,
|
||||
collapsibleTurns,
|
||||
@@ -35,48 +46,84 @@ export function TrajectoryToolbar({
|
||||
collapsibleAssistants,
|
||||
allAssistantsCollapsed,
|
||||
onToggleAllAssistants,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
}: TrajectoryToolbarProps) {
|
||||
return (
|
||||
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
|
||||
<div className={css.inner}>
|
||||
<div className={css.summary}>
|
||||
<span className={css.title}>Trajectory</span>
|
||||
</div>
|
||||
<div className={css.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.modeSwitch}
|
||||
className={css.toggle}
|
||||
aria-label="Use actual duration"
|
||||
aria-pressed={actualDuration}
|
||||
title={actualDuration ? 'Use equal-width operations' : 'Use actual duration'}
|
||||
onClick={() => { onActualDurationChange(!actualDuration) }}
|
||||
>
|
||||
<svg
|
||||
className={css.toggleIcon}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="5.25" />
|
||||
<path d="M8 4.75V8l2.25 1.5" />
|
||||
</svg>
|
||||
Duration
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.control}
|
||||
role="switch"
|
||||
aria-checked={actualTime}
|
||||
hidden
|
||||
onClick={() => { onActualTimeChange(!actualTime) }}
|
||||
>
|
||||
<span>Actual time</span>
|
||||
<span className={css.modeTrack} aria-hidden="true">
|
||||
<span className={css.modeThumb} />
|
||||
<span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
|
||||
<span className={css.controlThumb} />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
disabled={collapsibleAssistants === 0}
|
||||
onClick={onToggleAllAssistants}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allAssistantsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
{allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
aria-pressed={allTurnsCollapsed}
|
||||
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
disabled={collapsibleTurns === 0}
|
||||
onClick={onToggleAllTurns}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allTurnsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
{allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
Turns
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
aria-pressed={allAssistantsCollapsed}
|
||||
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
disabled={collapsibleAssistants === 0}
|
||||
onClick={onToggleAllAssistants}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allAssistantsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
Calls
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.search}>
|
||||
<IconSearchOutline16 size={11} className={css.searchIcon} />
|
||||
<input
|
||||
type="search"
|
||||
className={css.searchInput}
|
||||
aria-label="Search trajectory"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationContext, RequestView,
|
||||
@@ -75,6 +75,62 @@ function addUsage(
|
||||
}
|
||||
}
|
||||
|
||||
function searchableJson(value: unknown): string {
|
||||
if (value === undefined) return ''
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function searchMatches(
|
||||
turns: ReturnType<typeof deriveTrajectoryLayout>,
|
||||
query: string,
|
||||
): ReadonlySet<number> | null {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (terms.length === 0) return null
|
||||
const matches = new Set<number>()
|
||||
for (const turn of turns) {
|
||||
for (const group of turn.groups) {
|
||||
for (const cell of group.cells) {
|
||||
if (cell.requestOnly === true) continue
|
||||
const blocks = [
|
||||
...(cell.sourceBlocks ?? []),
|
||||
...(cell.outputBlocks ?? []),
|
||||
]
|
||||
const text = [
|
||||
`turn ${turn.turn}`,
|
||||
group.title,
|
||||
cell.kind,
|
||||
cell.kind === 'message' ? 'assistant' : undefined,
|
||||
cell.text,
|
||||
cell.inputDetail,
|
||||
cell.outputDetail,
|
||||
cell.thinkingDetail,
|
||||
cell.schemaDetail,
|
||||
cell.result,
|
||||
cell.callId,
|
||||
...blocks.flatMap(block => [
|
||||
block.type,
|
||||
block.content,
|
||||
block.callId,
|
||||
block.toolName,
|
||||
block.imageAlt,
|
||||
]),
|
||||
searchableJson(cell.messageSource),
|
||||
searchableJson(cell.promptDetail),
|
||||
searchableJson(cell.previousPromptDetail),
|
||||
].filter((value): value is string => typeof value === 'string')
|
||||
.join('\n')
|
||||
.toLocaleLowerCase()
|
||||
if (terms.every(term => text.includes(term))) matches.add(cell.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & TrajectoryViewInjected) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
@@ -83,7 +139,9 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
branchId: number
|
||||
range: TrajectoryTimeRange
|
||||
} | null>(null)
|
||||
const [timelineMode, setTimelineMode] = useState<TrajectoryTimelineMode>('sequence')
|
||||
const [actualDuration, setActualDuration] = useState(false)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||
const ledgerRef = useRef<HTMLDivElement>(null)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
@@ -262,6 +320,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
|
||||
],
|
||||
)
|
||||
const timelineMode: TrajectoryTimelineMode = actualDuration
|
||||
? actualTime ? 'actual' : 'duration'
|
||||
: actualTime ? 'time' : 'sequence'
|
||||
const searchMatchIndexes = useMemo(
|
||||
() => searchMatches(turns, searchQuery),
|
||||
[searchQuery, turns],
|
||||
)
|
||||
const timelineRange = timelineSelection?.branchId === currentBranch.id
|
||||
? timelineSelection.range
|
||||
: null
|
||||
@@ -271,6 +336,14 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
|
||||
[timelineMode, timelineRange, turns],
|
||||
)
|
||||
const handleRecordSelect = useCallback((index: number) => {
|
||||
if (
|
||||
timelineFocusIndexes !== null
|
||||
&& !timelineFocusIndexes.has(index)
|
||||
) {
|
||||
setTimelineSelection(null)
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
useEffect(() => {
|
||||
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
|
||||
const ledger = ledgerRef.current
|
||||
@@ -284,11 +357,15 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
const focusHeight =
|
||||
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
|
||||
if (focusHeight > ledger.clientHeight) {
|
||||
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
if (typeof first.scrollIntoView === 'function') {
|
||||
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
return
|
||||
}
|
||||
focusedRows[Math.floor((focusedRows.length - 1) / 2)]
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)]
|
||||
if (middle !== undefined && typeof middle.scrollIntoView === 'function') {
|
||||
middle.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
const collapsibleTurnIds = useMemo(
|
||||
() => turns
|
||||
@@ -365,9 +442,14 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<TrajectoryToolbar
|
||||
actualTime={timelineMode === 'actual'}
|
||||
onActualTimeChange={(actualTime) => {
|
||||
setTimelineMode(actualTime ? 'actual' : 'sequence')
|
||||
actualDuration={actualDuration}
|
||||
onActualDurationChange={(nextActualDuration) => {
|
||||
setActualDuration(nextActualDuration)
|
||||
setTimelineSelection(null)
|
||||
}}
|
||||
actualTime={actualTime}
|
||||
onActualTimeChange={(nextActualTime) => {
|
||||
setActualTime(nextActualTime)
|
||||
setTimelineSelection(null)
|
||||
}}
|
||||
collapsibleTurns={collapsibleTurnIds.length}
|
||||
@@ -376,19 +458,24 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
collapsibleAssistants={collapsibleAssistantIds.length}
|
||||
allAssistantsCollapsed={allAssistantsCollapsed}
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
/>
|
||||
<TrajectoryTimeline
|
||||
turns={turns}
|
||||
mode={timelineMode}
|
||||
range={timelineRange}
|
||||
selectedIndex={selectedTimelineIndex}
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onRangeChange={(range) => {
|
||||
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
|
||||
}}
|
||||
onRecordFocus={(index) => {
|
||||
ledgerRef.current
|
||||
const row = ledgerRef.current
|
||||
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div ref={ledgerRef} className={css.ledger}>
|
||||
@@ -397,7 +484,9 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
requestNumbers={requestNumbers}
|
||||
turns={turns}
|
||||
timelineFocusIndexes={timelineFocusIndexes}
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onSelectedIndexChange={setSelectedTimelineIndex}
|
||||
onRecordSelect={handleRecordSelect}
|
||||
collapsedTurns={collapsedTurns}
|
||||
onToggleTurn={toggleTurn}
|
||||
collapsedAssistants={collapsedAssistants}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
|
||||
|
||||
/** Horizontal projection used by the trajectory timeline. */
|
||||
export type TrajectoryTimelineMode = 'sequence' | 'actual'
|
||||
export type TrajectoryTimelineMode = 'sequence' | 'duration' | 'time' | 'actual'
|
||||
|
||||
/** Inclusive selection in the active timeline projection's domain. */
|
||||
export interface TrajectoryTimeRange {
|
||||
@@ -53,14 +53,20 @@ function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
|
||||
/**
|
||||
* Project every visible record into a stable three-lane timeline.
|
||||
* @param turns - Unfiltered trajectory layout.
|
||||
* @param mode - Equal-width operation sequence or recorded wall-clock timing.
|
||||
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
|
||||
* @returns Timeline model, or `null` when no record is visible.
|
||||
*/
|
||||
export function deriveTrajectoryTimeline(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
mode: TrajectoryTimelineMode = 'sequence',
|
||||
): TrajectoryTimelineModel | null {
|
||||
if (mode === 'actual') return deriveActualTimeline(turns)
|
||||
if (mode !== 'sequence') {
|
||||
return deriveTimedTimeline(
|
||||
turns,
|
||||
mode === 'duration' || mode === 'actual',
|
||||
mode === 'duration',
|
||||
)
|
||||
}
|
||||
const spans: TrajectoryTimelineSpan[] = []
|
||||
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||
|
||||
@@ -92,8 +98,10 @@ export function deriveTrajectoryTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
function deriveActualTimeline(
|
||||
function deriveTimedTimeline(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
actualDuration: boolean,
|
||||
removeUserIdle: boolean,
|
||||
): TrajectoryTimelineModel | null {
|
||||
const spans: TrajectoryTimelineSpan[] = []
|
||||
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||
@@ -120,13 +128,13 @@ function deriveActualTimeline(
|
||||
|
||||
const turnStart = Math.min(...rawSpans.map(span => span.start))
|
||||
const turnEnd = Math.max(...rawSpans.map(span => span.end))
|
||||
if (previousTurnEnd !== null) {
|
||||
if (removeUserIdle && previousTurnEnd !== null) {
|
||||
removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
|
||||
}
|
||||
spans.push(...rawSpans.map(span => ({
|
||||
...span,
|
||||
start: span.start - removedUserIdle,
|
||||
end: span.end - removedUserIdle,
|
||||
end: (actualDuration ? span.end : span.start) - removedUserIdle,
|
||||
})))
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
@@ -150,7 +158,7 @@ function deriveActualTimeline(
|
||||
* Identify records active at any point inside an inclusive selected interval.
|
||||
* @param turns - Unfiltered trajectory layout.
|
||||
* @param range - Selected interval in the active projection.
|
||||
* @param mode - Equal-width operation sequence or recorded wall-clock timing.
|
||||
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
|
||||
* @returns Record indexes inside the focus interval.
|
||||
*/
|
||||
export function trajectoryTimelineFocusIndexes(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Full-bleed, fixed-height host for the trajectory ledger. */
|
||||
.root {
|
||||
--dsh-trajectory-toolbar-height: 40px;
|
||||
--dsh-trajectory-toolbar-height: 32px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user