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 /* Conversation column skeleton: one header row with breadcrumbs and view
area, composer InputBar at the bottom. Column width/squeeze is layout's; tabs over the view area, composer InputBar at the bottom. Column
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with width/squeeze is layout's; this fills its cell. */
a 3px active bar. */
.root { .root {
display: flex; display: flex;
@@ -26,6 +25,7 @@
.crumbs { .crumbs {
display: flex; display: flex;
flex: 1;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
min-width: 0; min-width: 0;
@@ -72,12 +72,14 @@
cursor: default; 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 { .tabs {
display: flex; display: flex;
flex: none;
align-self: stretch;
align-items: flex-end;
gap: 36px; gap: 36px;
margin-top: 4px; margin-left: 24px;
padding-left: 8px;
} }
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */ /* 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>} {ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav> </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> </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>} </header>}
{!blankHero && <div className={css.viewArea}> {!blankHero && <div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}

View File

@@ -666,12 +666,23 @@
.detailTabs { .detailTabs {
display: flex; display: flex;
flex: none; flex: none;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
height: 34px; height: 34px;
padding: 0 8px; padding: 0 8px;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
gap: 1px; gap: 1px;
border-bottom: 1px solid var(--dsw-alias-border-l2); border-bottom: 1px solid var(--dsw-alias-border-l2);
overscroll-behavior-x: contain;
scrollbar-width: none; scrollbar-width: none;
white-space: nowrap;
}
.detailTabs::-webkit-scrollbar {
display: none;
} }
.detailTab { .detailTab {

View File

@@ -219,8 +219,12 @@ export interface TrajectoryTableProps {
turns: readonly TrajectoryTurnModel[] turns: readonly TrajectoryTurnModel[]
/** Record indexes emphasized by the active timeline focus. */ /** Record indexes emphasized by the active timeline focus. */
timelineFocusIndexes?: ReadonlySet<number> | null 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. */ /** Report the record currently selected in the local inspector. */
onSelectedIndexChange?: (index: number | null) => void 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. */ /** 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. */
@@ -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 { function requestStep(group: string): number | undefined {
if (!group.startsWith('Step ')) return undefined if (!group.startsWith('Step ')) return undefined
const value = Number(group.slice('Step '.length)) const value = Number(group.slice('Step '.length))
@@ -1362,7 +1391,9 @@ export function TrajectoryTable({
requestNumbers: sessionRequestNumbers, requestNumbers: sessionRequestNumbers,
turns, turns,
timelineFocusIndexes = null, timelineFocusIndexes = null,
searchMatchIndexes = null,
onSelectedIndexChange, onSelectedIndexChange,
onRecordSelect,
collapsedTurns, collapsedTurns,
onToggleTurn, onToggleTurn,
collapsedAssistants, collapsedAssistants,
@@ -1381,8 +1412,12 @@ export function TrajectoryTable({
}, [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 records = searchMatchIndexes === null
const records = collapseAssistantRecords(turnRecords, collapsedAssistants) ? collapseAssistantRecords(
collapseTurnRecords(allRecords, collapsedTurns),
collapsedAssistants,
)
: filterRecords(allRecords, searchMatchIndexes)
const selected = allRecords.find(record => record.cell.index === selectedIndex) const selected = allRecords.find(record => record.cell.index === selectedIndex)
const selectedPrompt = selected?.cell.kind === 'system' const selectedPrompt = selected?.cell.kind === 'system'
? selected.cell.promptDetail ? selected.cell.promptDetail
@@ -1484,6 +1519,7 @@ export function TrajectoryTable({
const selectRecord = (index: number) => { const selectRecord = (index: number) => {
const record = allRecords.find(candidate => candidate.cell.index === index) const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index)
setSelectedRequest(null) setSelectedRequest(null)
setSelectedIndex(index) setSelectedIndex(index)
if (record === undefined) return if (record === undefined) return

View File

@@ -23,7 +23,7 @@
.labels span { .labels span {
position: absolute; position: absolute;
right: 6px; right: 3px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
@@ -130,6 +130,11 @@
); );
} }
.span[data-equal-duration='true'] {
width: 8px;
min-width: 8px;
}
.span[data-selected='false'] { .span[data-selected='false'] {
opacity: 0.2; opacity: 0.2;
} }
@@ -142,6 +147,10 @@
0 0 0 2px var(--dsw-alias-state-business-primary); 0 0 0 2px var(--dsw-alias-state-business-primary);
} }
.span[data-search-match='false'] {
opacity: 0.14;
}
.selection { .selection {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
@@ -150,8 +159,6 @@
left: var(--trajectory-selection-left); left: var(--trajectory-selection-left);
width: var(--trajectory-selection-width); width: var(--trajectory-selection-width);
min-width: 1px; 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( background: color-mix(
in srgb, in srgb,
var(--dsw-alias-state-business-primary) 12%, var(--dsw-alias-state-business-primary) 12%,
@@ -163,8 +170,34 @@
pointer-events: none; pointer-events: none;
} }
.selection::before, .selectionEdges {
.selection::after { 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; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
@@ -173,12 +206,17 @@
content: ''; content: '';
} }
.selection::before { .selectionEdges::before {
left: -2px; left: 0;
} }
.selection::after { .selectionEdges::after {
right: -2px; right: 0;
}
.selectionEdges[data-dragging='true']::before,
.selectionEdges[data-dragging='true']::after {
width: 2px;
} }
.selection[data-dragging='true'] { .selection[data-dragging='true'] {

View File

@@ -26,6 +26,8 @@ export interface TrajectoryTimelineProps {
mode: TrajectoryTimelineMode mode: TrajectoryTimelineMode
range: TrajectoryTimeRange | null range: TrajectoryTimeRange | null
selectedIndex?: number | 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 onRangeChange: (range: TrajectoryTimeRange | null) => void
onRecordFocus?: (index: number) => void onRecordFocus?: (index: number) => void
} }
@@ -38,6 +40,15 @@ function clampFraction(value: number): number {
return Math.min(1, Math.max(0, value)) return Math.min(1, Math.max(0, value))
} }
function centeredRange(center: number, width: number): FractionRange {
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( function rangeFraction(
range: TrajectoryTimeRange, range: TrajectoryTimeRange,
start: number, 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({ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns, turns,
mode, mode,
range, range,
selectedIndex = null, selectedIndex = null,
searchMatchIndexes = null,
onRangeChange, onRangeChange,
onRecordFocus, onRecordFocus,
}: TrajectoryTimelineProps) { }: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, 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 [hover, setHover] = useState<number | null>(null)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null) const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
useEffect(() => { useEffect(() => {
if ( 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 fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
const rect = event.currentTarget.getBoundingClientRect() const rect = event.currentTarget.getBoundingClientRect()
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) 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 if (event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect() const rect = event.currentTarget.getBoundingClientRect()
const anchor = fractionAt(event) const anchor = fractionAt(event)
setHover(anchor)
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) } dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
if (typeof event.currentTarget.setPointerCapture === 'function') { if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
@@ -150,31 +169,40 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current const drag = dragRef.current
const fraction = fractionAt(event)
setHover(fraction)
if (drag === null || drag.pointerId !== event.pointerId) return 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 onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return if (drag === null || drag.pointerId !== event.pointerId) return
const selected = orderedRange(drag.anchor, fractionAt(event)) const point = fractionAt(event)
const selected = orderedRange(drag.anchor, point)
setHover(point)
dragRef.current = null dragRef.current = null
setDraft(null) setDraft(null)
if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) { const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
onRangeChange(null) const committedRange = selected.end - selected.start < minimumSelectionFraction
const point = domainStart + selected.start * domainDuration ? 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 nearest = model.spans.reduce((candidate, span) => {
const candidateDistance = point < candidate.start const candidateDistance = timelinePoint < candidate.start
? candidate.start - point ? candidate.start - timelinePoint
: point > candidate.end ? point - candidate.end : 0 : timelinePoint > candidate.end ? timelinePoint - candidate.end : 0
const spanDistance = point < span.start const spanDistance = timelinePoint < span.start
? span.start - point ? span.start - timelinePoint
: point > span.end ? point - span.end : 0 : timelinePoint > span.end ? timelinePoint - span.end : 0
return spanDistance < candidateDistance ? span : candidate return spanDistance < candidateDistance ? span : candidate
}) })
onRecordFocus?.(nearest.index) onRecordFocus?.(nearest.index)
} else {
commit(selected)
} }
} }
@@ -187,6 +215,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const onPointerCancel = () => { const onPointerCancel = () => {
dragRef.current = null dragRef.current = null
setDraft(null) setDraft(null)
setHover(null)
} }
const onWheel = (event: WheelEvent<HTMLDivElement>) => { const onWheel = (event: WheelEvent<HTMLDivElement>) => {
@@ -197,7 +226,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const nextDuration = Math.min( const nextDuration = Math.min(
fullDuration, fullDuration,
Math.max( 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), domainDuration * Math.exp(event.deltaY * 0.0015),
), ),
) )
@@ -226,6 +255,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerEnd} onPointerUp={onPointerEnd}
onPointerCancel={onPointerCancel} onPointerCancel={onPointerCancel}
onPointerLeave={() => {
if (dragRef.current === null) setHover(null)
}}
onDoubleClick={(event) => {
event.preventDefault()
onRangeChange(null)
}}
onWheel={onWheel} onWheel={onWheel}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault() event.preventDefault()
@@ -233,17 +269,37 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
setViewport(null) setViewport(null)
}} }}
> >
{visibleRange !== null && ( {hover !== null && draft === null && (
<div <div
className={css.selection} className={css.hoverLine}
data-dragging={draft === null ? undefined : 'true'}
aria-hidden="true" aria-hidden="true"
style={{ style={{
'--trajectory-selection-left': `${visibleRange.start * 100}%`, '--trajectory-hover-left': `${hover * 100}%`,
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
} as CSSProperties} } 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"> <div className={css.turnBoundaries} aria-hidden="true">
{model.turnBoundaries {model.turnBoundaries
.slice(1) .slice(1)
@@ -272,7 +328,11 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
<span <span
className={css.span} className={css.span}
data-timeline-span={span.kind} data-timeline-span={span.kind}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined} data-current={span.index === selectedIndex || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
data-selected={activeRange === null data-selected={activeRange === null
? undefined ? undefined
: span.start <= activeRange.end && span.end >= activeRange.start : span.start <= activeRange.end && span.end >= activeRange.start

View File

@@ -12,24 +12,11 @@
.inner { .inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0 14px 0 16px; padding: 0 10px 0 12px;
} gap: 8px;
.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);
} }
.actions { .actions {
@@ -39,78 +26,127 @@
gap: 2px; gap: 2px;
} }
.modeSwitch { .toggle {
display: inline-flex; display: inline-flex;
flex: none; flex: none;
align-items: center; align-items: center;
height: 24px; height: 20px;
margin-right: 5px;
padding: 0 7px; padding: 0 7px;
gap: 6px; gap: 4px;
border: 0; border: 0;
border-radius: 4px; border-radius: 3px;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font: var(--dsw-font-xs-13); font: var(--dsw-font-xxs-12);
} }
.modeSwitch:hover { .toggle:hover {
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
.modeSwitch:focus-visible { .toggle[aria-pressed='true'] {
outline: 1px solid var(--dsw-alias-state-business-primary); color: var(--dsw-alias-label-primary);
outline-offset: 2px; 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; position: relative;
display: inline-block; display: inline-block;
width: 26px; flex: none;
height: 14px; width: 20px;
border-radius: 7px; height: 10px;
border-radius: 5px;
background: var(--dsw-alias-border-l2); background: var(--dsw-alias-border-l2);
transition: background-color 120ms var(--ds-ease-in-out); transition: background-color 120ms var(--ds-ease-in-out);
} }
.modeThumb { .controlThumb {
position: absolute; position: absolute;
top: 2px; top: 2px;
left: 2px; left: 2px;
width: 10px; width: 6px;
height: 10px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-bg-layer-1);
transition: transform 120ms var(--ds-ease-in-out); transition: transform 120ms var(--ds-ease-in-out);
} }
.modeSwitch[aria-checked='true'] .modeTrack { .controlTrack[data-on='true'] {
background: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-state-business-primary);
} }
.modeSwitch[aria-checked='true'] .modeThumb { .controlTrack[data-on='true'] .controlThumb {
transform: translateX(12px); transform: translateX(10px);
} }
.action { .action {
display: inline-flex; display: inline-flex;
flex: none; flex: none;
align-items: center; align-items: center;
box-sizing: border-box; height: 20px;
height: 24px; padding: 0 5px;
padding: 0 7px; gap: 4px;
gap: 6px;
border: 0; border: 0;
border-radius: 4px; border-radius: 3px;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font: var(--dsw-font-xs-13); font: var(--dsw-font-xxs-12);
transition:
color 120ms var(--ds-ease-in-out),
background-color 120ms var(--ds-ease-in-out);
} }
.action:hover:not(:disabled) { .action:hover:not(:disabled) {
@@ -120,7 +156,7 @@
.action:focus-visible { .action:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary); outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px; outline-offset: 1px;
} }
.action:disabled { .action:disabled {
@@ -130,11 +166,54 @@
.actionIcon { .actionIcon {
color: var(--dsw-alias-label-tertiary); 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) { .search {
.summary { display: flex;
gap: 7px; 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' import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps { 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 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 onActualTimeChange: (actualTime: boolean) => void
/** Number of turns containing more than one row. */ /** Number of turns containing more than one row. */
collapsibleTurns: number collapsibleTurns: number
@@ -19,6 +24,10 @@ export interface TrajectoryToolbarProps {
allAssistantsCollapsed: boolean allAssistantsCollapsed: boolean
/** Fold or expand tool calls under every collapsible assistant. */ /** Fold or expand tool calls under every collapsible assistant. */
onToggleAllAssistants: () => void 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. * @returns the toolbar element.
*/ */
export function TrajectoryToolbar({ export function TrajectoryToolbar({
actualDuration,
onActualDurationChange,
actualTime, actualTime,
onActualTimeChange, onActualTimeChange,
collapsibleTurns, collapsibleTurns,
@@ -35,48 +46,84 @@ export function TrajectoryToolbar({
collapsibleAssistants, collapsibleAssistants,
allAssistantsCollapsed, allAssistantsCollapsed,
onToggleAllAssistants, onToggleAllAssistants,
searchQuery,
onSearchQueryChange,
}: TrajectoryToolbarProps) { }: TrajectoryToolbarProps) {
return ( return (
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar"> <div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
<div className={css.inner}> <div className={css.inner}>
<div className={css.summary}>
<span className={css.title}>Trajectory</span>
</div>
<div className={css.actions}> <div className={css.actions}>
<button <button
type="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" role="switch"
aria-checked={actualTime} aria-checked={actualTime}
hidden
onClick={() => { onActualTimeChange(!actualTime) }} onClick={() => { onActualTimeChange(!actualTime) }}
> >
<span>Actual time</span> <span>Actual time</span>
<span className={css.modeTrack} aria-hidden="true"> <span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
<span className={css.modeThumb} /> <span className={css.controlThumb} />
</span> </span>
</button> </button>
<button <button
type="button" type="button"
className={css.action} className={css.action}
disabled={collapsibleAssistants === 0} aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
onClick={onToggleAllAssistants} aria-pressed={allTurnsCollapsed}
> title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
<span className={css.actionIcon} aria-hidden="true">
{allAssistantsCollapsed ? '⊞' : '⊟'}
</span>
{allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
</button>
<button
type="button"
className={css.action}
disabled={collapsibleTurns === 0} disabled={collapsibleTurns === 0}
onClick={onToggleAllTurns} onClick={onToggleAllTurns}
> >
<span className={css.actionIcon} aria-hidden="true"> <span className={css.actionIcon} aria-hidden="true">
{allTurnsCollapsed ? '⊞' : '⊟'} {allTurnsCollapsed ? '⊞' : '⊟'}
</span> </span>
{allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'} Turns
</button> </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> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
/** Trajectory view: compact summary over a turn-aware event ledger. */ /** 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 { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { import type {
AssistantMessageNode, ConversationContext, RequestView, 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) { export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & TrajectoryViewInjected) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS) const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] = const [collapsedAssistants, setCollapsedAssistants] =
@@ -83,7 +139,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 [actualDuration, setActualDuration] = useState(false)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null) const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null) const ledgerRef = useRef<HTMLDivElement>(null)
const nodes = useSession(s => s.nodes) const nodes = useSession(s => s.nodes)
@@ -262,6 +320,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches, 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 const timelineRange = timelineSelection?.branchId === currentBranch.id
? timelineSelection.range ? timelineSelection.range
: null : null
@@ -271,6 +336,14 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode), : trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
[timelineMode, timelineRange, turns], [timelineMode, timelineRange, turns],
) )
const handleRecordSelect = useCallback((index: number) => {
if (
timelineFocusIndexes !== null
&& !timelineFocusIndexes.has(index)
) {
setTimelineSelection(null)
}
}, [timelineFocusIndexes])
useEffect(() => { useEffect(() => {
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
const ledger = ledgerRef.current const ledger = ledgerRef.current
@@ -284,11 +357,15 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
const focusHeight = const focusHeight =
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
if (focusHeight > ledger.clientHeight) { if (focusHeight > ledger.clientHeight) {
first.scrollIntoView({ behavior: 'smooth', block: 'start' }) if (typeof first.scrollIntoView === 'function') {
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return return
} }
focusedRows[Math.floor((focusedRows.length - 1) / 2)] const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)]
?.scrollIntoView({ behavior: 'smooth', block: 'center' }) if (middle !== undefined && typeof middle.scrollIntoView === 'function') {
middle.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [timelineFocusIndexes]) }, [timelineFocusIndexes])
const collapsibleTurnIds = useMemo( const collapsibleTurnIds = useMemo(
() => turns () => turns
@@ -365,9 +442,14 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
return ( return (
<div className={css.root}> <div className={css.root}>
<TrajectoryToolbar <TrajectoryToolbar
actualTime={timelineMode === 'actual'} actualDuration={actualDuration}
onActualTimeChange={(actualTime) => { onActualDurationChange={(nextActualDuration) => {
setTimelineMode(actualTime ? 'actual' : 'sequence') setActualDuration(nextActualDuration)
setTimelineSelection(null)
}}
actualTime={actualTime}
onActualTimeChange={(nextActualTime) => {
setActualTime(nextActualTime)
setTimelineSelection(null) setTimelineSelection(null)
}} }}
collapsibleTurns={collapsibleTurnIds.length} collapsibleTurns={collapsibleTurnIds.length}
@@ -376,19 +458,24 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
collapsibleAssistants={collapsibleAssistantIds.length} collapsibleAssistants={collapsibleAssistantIds.length}
allAssistantsCollapsed={allAssistantsCollapsed} allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants} onToggleAllAssistants={toggleAllAssistants}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/> />
<TrajectoryTimeline <TrajectoryTimeline
turns={turns} turns={turns}
mode={timelineMode} mode={timelineMode}
range={timelineRange} range={timelineRange}
selectedIndex={selectedTimelineIndex} selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => { onRangeChange={(range) => {
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range }) setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
}} }}
onRecordFocus={(index) => { onRecordFocus={(index) => {
ledgerRef.current const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`) ?.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}> <div ref={ledgerRef} className={css.ledger}>
@@ -397,7 +484,9 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
requestNumbers={requestNumbers} requestNumbers={requestNumbers}
turns={turns} turns={turns}
timelineFocusIndexes={timelineFocusIndexes} timelineFocusIndexes={timelineFocusIndexes}
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex} onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
collapsedTurns={collapsedTurns} collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn} onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants} collapsedAssistants={collapsedAssistants}

View File

@@ -4,7 +4,7 @@ import type { TrajectoryTurnModel } from './layout.ts'
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
/** Horizontal projection used by the trajectory timeline. */ /** 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. */ /** Inclusive selection in the active timeline projection's domain. */
export interface TrajectoryTimeRange { export interface TrajectoryTimeRange {
@@ -53,14 +53,20 @@ function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
/** /**
* Project every visible record into a stable three-lane timeline. * Project every visible record into a stable three-lane timeline.
* @param turns - Unfiltered trajectory layout. * @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. * @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', mode: TrajectoryTimelineMode = 'sequence',
): TrajectoryTimelineModel | null { ): TrajectoryTimelineModel | null {
if (mode === 'actual') return deriveActualTimeline(turns) if (mode !== 'sequence') {
return deriveTimedTimeline(
turns,
mode === 'duration' || mode === 'actual',
mode === 'duration',
)
}
const spans: TrajectoryTimelineSpan[] = [] const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = [] const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
@@ -92,8 +98,10 @@ export function deriveTrajectoryTimeline(
} }
} }
function deriveActualTimeline( function deriveTimedTimeline(
turns: readonly TrajectoryTurnModel[], turns: readonly TrajectoryTurnModel[],
actualDuration: boolean,
removeUserIdle: boolean,
): TrajectoryTimelineModel | null { ): TrajectoryTimelineModel | null {
const spans: TrajectoryTimelineSpan[] = [] const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = [] const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
@@ -120,13 +128,13 @@ function deriveActualTimeline(
const turnStart = Math.min(...rawSpans.map(span => span.start)) const turnStart = Math.min(...rawSpans.map(span => span.start))
const turnEnd = Math.max(...rawSpans.map(span => span.end)) const turnEnd = Math.max(...rawSpans.map(span => span.end))
if (previousTurnEnd !== null) { if (removeUserIdle && previousTurnEnd !== null) {
removedUserIdle += Math.max(0, turnStart - previousTurnEnd) removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
} }
spans.push(...rawSpans.map(span => ({ spans.push(...rawSpans.map(span => ({
...span, ...span,
start: span.start - removedUserIdle, start: span.start - removedUserIdle,
end: span.end - removedUserIdle, end: (actualDuration ? span.end : span.start) - removedUserIdle,
}))) })))
turnBoundaries.push({ turnBoundaries.push({
turn: turn.turn, turn: turn.turn,
@@ -150,7 +158,7 @@ function deriveActualTimeline(
* Identify 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 - Selected interval in the active projection. * @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. * @returns Record indexes inside the focus interval.
*/ */
export function trajectoryTimelineFocusIndexes( export function trajectoryTimelineFocusIndexes(

View File

@@ -1,6 +1,6 @@
/* Full-bleed, fixed-height host for the trajectory ledger. */ /* Full-bleed, fixed-height host for the trajectory ledger. */
.root { .root {
--dsh-trajectory-toolbar-height: 40px; --dsh-trajectory-toolbar-height: 32px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;