feat(ui): refine trajectory timeline controls

This commit is contained in:
_Kerman
2026-07-28 21:10:48 +08:00
parent 0e1e594314
commit f3ba3d16be
11 changed files with 513 additions and 143 deletions

View File

@@ -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). */

View File

@@ -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 })}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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'] {

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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(

View File

@@ -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;