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
|
/* 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). */
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'] {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user