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