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