Merge branch 'master' into agent/web-bash-error-expand

This commit is contained in:
Ziya
2026-08-03 02:30:36 -04:00
committed by GitHub
32 changed files with 1373 additions and 188 deletions

View File

@@ -208,6 +208,26 @@
);
}
/* Views may opt into a composer overlay while ConversationRoot retains
ownership of the seat geometry and its active-phase precedence. */
.scrollBody:has([data-conversation-composer-overlay]) {
position: relative;
overflow: hidden;
}
.scrollBody:has([data-conversation-composer-overlay]) > .viewArea {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat {
position: absolute;
right: 0;
bottom: 0;
left: 0;
}
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
flex-centered in the column; composer phase docks it at the bottom. Flex,
NOT absolute+transform: a transform would make this box the containing

View File

@@ -14,7 +14,7 @@
color: var(--dsw-static-neutral-bluish-00);
font-size: 14px;
line-height: 22px;
white-space: nowrap;
white-space: pre-line;
pointer-events: none;
animation: tooltip-in 150ms var(--ds-ease-in-out);
}

View File

@@ -1,6 +1,6 @@
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
// TODO: interaction is a placeholder (no show delay, no flip on viewport
// collision, no arrow) — visuals and behavior get a proper pass later.
// TODO: interaction is a placeholder (no flip on viewport collision or
// arrow) — visuals and behavior get a proper pass later.
// The anchor is the child element itself (cloneElement, no wrapper node), so
// attaching a tooltip never changes the anchor's layout context. The bubble is
// position:fixed and coordinates come from the anchor's rect at show time, so
@@ -27,12 +27,13 @@ interface AnchorProps {
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
* toggling never remounts it (which would cut its CSS transitions).
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
*/
export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
@@ -43,15 +44,26 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
}, [childRef])
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
const triggers = useRef({ hover: false, focus: false })
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
// must drop an already-visible bubble: no mouseleave fires.
const cancelShow = useCallback(() => {
if (showTimer.current === null) return
clearTimeout(showTimer.current)
showTimer.current = null
}, [])
useEffect(() => {
if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) }
}, [disabled])
if (disabled) {
cancelShow()
triggers.current = { hover: false, focus: false }
setPos(null)
}
return cancelShow
}, [cancelShow, disabled])
const show = () => {
if (disabled) return
@@ -63,7 +75,19 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
? { x: r.right + 10, y: r.top + r.height / 2 }
: { x: r.left + r.width / 2, y: r.bottom + 8 })
}
const showAfterHoverDelay = () => {
cancelShow()
if (delayMs <= 0) {
show()
return
}
showTimer.current = setTimeout(() => {
showTimer.current = null
show()
}, delayMs)
}
const hide = () => {
cancelShow()
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
}
@@ -71,9 +95,9 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
<>
{cloneElement(children, {
ref: mergedRef,
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() },
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}
{pos !== null && (

View File

@@ -1,11 +1,37 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
describe('Tooltip', () => {
it('can delay pointer hover without delaying keyboard focus', () => {
vi.useFakeTimers()
try {
render(
<Tooltip label="Timing details" delayMs={500}>
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
fireEvent.mouseEnter(anchor)
act(() => { vi.advanceTimersByTime(499) })
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseLeave(anchor)
act(() => { vi.advanceTimersByTime(1) })
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseEnter(anchor)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
fireEvent.mouseLeave(anchor)
fireEvent.focus(anchor)
expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
} finally {
vi.useRealTimers()
}
})
it('shows the bubble to the right on hover and hides it on leave', () => {
render(
<Tooltip label="Open sidebar">

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: a65c11aed9dd74f9b0b60795441f876c1d64b3ad
README.zh.md: 6e25d24c6b65673b3d003e624b6e0727be60c0e1
README.md: 5d0ea3bbbbfca2b8c0ee02ed07ca956fbd377e11
README.zh.md: 1bfff4c18ea2e834781e2c6cb76773595eeed5ad

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包package保持为纯消费方插件向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图不会改变该区间。仅当记录表在流式更新前已经位于底部时更新才会保持贴底向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包package保持为纯消费方插件向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
## 模型体验

View File

@@ -15,7 +15,9 @@
.tablePane {
flex: 1;
min-width: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px);
container: trajectory-table / inline-size;
}
@@ -27,7 +29,7 @@
);
width: 100%;
min-width: 480px;
min-width: 0;
border-spacing: 0;
table-layout: fixed;
color: var(--dsw-alias-label-primary);
@@ -104,6 +106,11 @@
border-bottom: 0;
}
.table tbody tr[data-request-only='true']:has(+ tr[data-request-only='true']) td {
/* Keep consecutive boundary markers from painting their halos over one another. */
height: 9px;
}
.table tbody tr[data-request-only='true']:last-child td {
/* Retain the lower half of the 16px boundary marker at the table's end. */
height: 9px;
@@ -277,7 +284,7 @@
z-index: 3;
top: 0;
left: 0;
display: inline-flex;
display: inline-grid;
flex: none;
align-items: center;
box-sizing: border-box;
@@ -292,8 +299,18 @@
white-space: nowrap;
}
.turnLabelFull,
.turnLabelCompact {
display: none;
grid-area: 1 / 1;
max-width: 64px;
overflow: hidden;
opacity: 1;
white-space: nowrap;
}
.turnLabelCompact {
max-width: 0;
opacity: 0;
}
.turnLabelActive {
@@ -350,15 +367,23 @@
}
.kindTagIcon {
display: none;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 13px;
width: 0;
height: 13px;
overflow: hidden;
opacity: 0;
transform: scale(0.8);
}
.kindTagLabel {
display: inline;
display: inline-block;
max-width: 72px;
overflow: hidden;
opacity: 1;
white-space: nowrap;
}
.table .kindSlot .message {
@@ -393,19 +418,66 @@
}
.kindTagIcon {
display: inline-flex;
width: 13px;
opacity: 1;
transform: scale(1);
}
.kindTagLabel {
display: none;
max-width: 0;
opacity: 0;
}
.turnLabelFull {
display: none;
max-width: 0;
opacity: 0;
}
.turnLabelCompact {
display: inline;
max-width: 64px;
opacity: 1;
}
}
@media (prefers-reduced-motion: no-preference) {
.eventColumn,
.event,
.requestBoundaryControl,
.kindSlot,
.kindTag,
.kindTagIcon,
.kindTagLabel,
.turnLabelFull,
.turnLabelCompact {
transition-duration: 180ms;
transition-timing-function: var(--ds-ease-in-out);
}
.eventColumn,
.kindSlot {
transition-property: width;
}
.event {
transition-property: padding-right, padding-left;
}
.requestBoundaryControl {
transition-property: left;
}
.kindTag {
transition-property: padding-right, padding-left;
}
.kindTagIcon {
transition-property: width, opacity, transform;
}
.kindTagLabel,
.turnLabelFull,
.turnLabelCompact {
transition-property: max-width, opacity;
}
}
@@ -834,7 +906,9 @@
.detailBody {
flex: 1;
min-height: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px);
scrollbar-gutter: stable;
}
@@ -842,7 +916,7 @@
display: flex;
box-sizing: border-box;
flex-direction: column;
padding-bottom: 12px;
padding-bottom: calc(12px + var(--dsh-trajectory-bottom-clearance, 0px));
overflow: hidden;
}

View File

@@ -1,6 +1,6 @@
/** Turn-aware trajectory event ledger with a local record inspector. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import {
IconChevronRightOutline14,
@@ -22,6 +22,8 @@ import { formatElapsedSeconds } from './trajectory-record.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'SYSTEM',
user: 'USER',
@@ -1711,6 +1713,9 @@ export function TrajectoryTable({
// ledger has rendered. Not-found leaves the request pending (`turns` in the
// deps retries as history pages in); the ack clears the store field.
const rootRef = useRef<HTMLDivElement>(null)
const tablePaneRef = useRef<HTMLDivElement>(null)
const followsTableTail = useRef(false)
const tableScrollInitialized = useRef(false)
const pendingScrollIndex = useRef<number | null>(null)
const openRecordSummaryRef = useRef(openRecordSummary)
openRecordSummaryRef.current = openRecordSummary
@@ -1734,11 +1739,30 @@ export function TrajectoryTable({
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
useLayoutEffect(() => {
const pane = tablePaneRef.current
if (pane === null) return
if (!tableScrollInitialized.current) {
tableScrollInitialized.current = true
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
return
}
if (followsTableTail.current) pane.scrollTop = pane.scrollHeight
}, [turns])
return (
<div ref={rootRef} className={css.split} style={splitStyle}>
<div
ref={tablePaneRef}
className={css.tablePane}
onScroll={(event) => {
const pane = event.currentTarget
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
}}
onClick={(event) => {
if (event.target === event.currentTarget) clearAllSelections()
}}

View File

@@ -7,6 +7,10 @@
user-select: none;
}
.root :global([role='tooltip']) {
font: var(--dsw-font-xxxs-11);
}
.plot {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
@@ -53,6 +57,10 @@
touch-action: none;
}
.track[data-panning='true'] {
cursor: grabbing;
}
.empty {
position: absolute;
top: 50%;
@@ -105,8 +113,15 @@
.span {
position: absolute;
top: calc(var(--trajectory-span-lane) * 14px);
left: calc(var(--trajectory-span-left) + 1px);
width: max(2px, calc(var(--trajectory-span-width) - 2px));
left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap));
width: max(
2px,
calc(
var(--trajectory-span-width)
- var(--trajectory-span-gap)
- var(--trajectory-span-gap)
)
);
height: 8px;
min-width: 2px;
border-radius: 1px;
@@ -127,23 +142,35 @@
}
.span[data-timeline-span='message'] {
background: color-mix(
--trajectory-assistant-decoding-color: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
var(--dsw-alias-state-error-secondary)
);
}
.span[data-timeline-span='tool'] {
background: var(--dsw-alias-state-warn-label);
}
.span[data-timeline-span='subtool'] {
background: color-mix(
--trajectory-assistant-ttft-color: color-mix(
in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
var(--trajectory-assistant-decoding-color) 54%,
var(--dsw-alias-bg-layer-2)
);
background: var(--trajectory-assistant-decoding-color);
opacity: 1;
}
.span[data-timeline-span='message'][data-assistant-timing='true'] {
background: linear-gradient(
to right,
var(--trajectory-assistant-ttft-color) 0,
var(--trajectory-assistant-ttft-color) var(--trajectory-assistant-ttft),
var(--trajectory-assistant-decoding-color) var(--trajectory-assistant-ttft),
var(--trajectory-assistant-decoding-color) 100%
);
}
.span[data-timeline-span='tool'],
.span[data-timeline-span='subtool'] {
background: var(--dsw-alias-state-warn-label);
opacity: 1;
}
.span[data-error='true'] {
@@ -161,7 +188,7 @@
.span[data-hovered='true']:not([data-current='true']) {
z-index: 1;
opacity: 0.78;
opacity: 1;
box-shadow:
0 0 0 1px var(--dsw-alias-bg-layer-2),
0 0 0 2px color-mix(

View File

@@ -4,7 +4,9 @@ import {
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
type PointerEvent,
} from 'react'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TrajectoryTurnModel } from './layout.ts'
import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
import {
deriveTrajectoryTimeline,
formatTimelineOffset,
@@ -18,6 +20,14 @@ const MINIMUM_ZOOM_OPERATIONS = 4
const EDGE_PAN_ZONE_FRACTION = 0.08
const EDGE_PAN_STEP_FRACTION = 0.025
const MAXIMUM_EDGE_PAN_PX = 32
const TIMELINE_TOOLTIP_DELAY_MS = 500
interface TimelineRecordDetail {
decodingMs?: number
durationMs?: number
startedAt?: number
ttftMs?: number
}
interface FractionRange {
start: number
@@ -29,6 +39,94 @@ interface HoverPoint {
recordIndex: number | null
}
interface PanGesture {
anchorClientX: number
anchorStart: number
moved: boolean
pannable: boolean
pointerId: number
}
function assistantTimingDetail(
metrics: AssistantMetricDetail | undefined,
): Pick<TimelineRecordDetail, 'ttftMs' | 'decodingMs'> {
const start = metrics?.stepStartTime
const first = metrics?.firstTokenTime
const completed = metrics?.completedTime
if (
metrics?.timingRecorded !== true
|| typeof start !== 'number'
|| typeof first !== 'number'
|| typeof completed !== 'number'
|| !Number.isFinite(start)
|| !Number.isFinite(first)
|| !Number.isFinite(completed)
|| first < start
|| completed < first
) return {}
return { ttftMs: first - start, decodingMs: completed - first }
}
function timelineRecordDetail(cell: TrajectoryCellProps): TimelineRecordDetail {
const durationMs = cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
? undefined
: Math.max(0, cell.timeSeconds * 1_000)
const startedAt = cell.startedAt === null || !Number.isFinite(cell.startedAt)
? undefined
: cell.startedAt
return {
...(durationMs === undefined ? {} : { durationMs }),
...(startedAt === undefined ? {} : { startedAt }),
...assistantTimingDetail(cell.assistantMetrics),
}
}
function timelineKindLabel(kind: TrajectoryCellKind): string {
switch (kind) {
case 'system': return 'SYSTEM'
case 'user': return 'USER'
case 'context': return 'CONTEXT'
case 'compacted': return 'COMPACTED'
case 'message': return 'ASSISTANT'
case 'tool': return 'TOOL'
case 'subtool': return 'SUBTOOL'
}
}
function formatRecordedTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
})
}
function timelineTooltipLabel(
kind: TrajectoryCellKind,
detail: TimelineRecordDetail | undefined,
): string {
const heading = timelineKindLabel(kind)
if (detail === undefined) return heading
const duration = detail.durationMs === undefined
? null
: `Total ${formatTimelineOffset(detail.durationMs)}`
const range = detail.startedAt === undefined
? null
: detail.durationMs === undefined
? `Started ${formatRecordedTime(detail.startedAt)}`
: `${formatRecordedTime(detail.startedAt)}${formatRecordedTime(
detail.startedAt + detail.durationMs,
)}`
const segments = detail.ttftMs === undefined || detail.decodingMs === undefined
? null
: `TTFT ${formatTimelineOffset(detail.ttftMs)} · Decoding ${formatTimelineOffset(
detail.decodingMs,
)}`
const timing = [duration, segments].filter(value => value !== null).join(' · ')
return [heading, range, timing].filter(value => value !== null && value !== '').join('\n')
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
@@ -105,14 +203,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onRecordFocus,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
const durationByIndex = useMemo(
const detailByIndex = useMemo(
() => new Map(turns.flatMap(turn =>
turn.groups.flatMap(group =>
group.cells.flatMap(cell =>
cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
? []
: [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const],
),
group.cells.map(cell => [cell.index, timelineRecordDetail(cell)] as const),
),
)),
[turns],
@@ -123,10 +217,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
anchorClientX: number
recordIndex: number | null
} | null>(null)
const panRef = useRef<PanGesture | null>(null)
const rootRef = useRef<HTMLElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [panning, setPanning] = useState(false)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
useEffect(() => {
@@ -267,6 +363,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button === 2) {
panRef.current = {
anchorClientX: event.clientX,
anchorStart: domainStart,
moved: false,
pannable: viewport !== null,
pointerId: event.pointerId,
}
if (viewport !== null) setAnimateViewport(false)
setPanning(true)
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
return
}
if (event.button !== 0) return
const anchor = fractionAt(event)
const anchorTime = domainStart + anchor * domainDuration
@@ -285,10 +396,24 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
const rect = event.currentTarget.getBoundingClientRect()
const fraction = fractionAt(event)
setHover({ fraction, recordIndex: recordIndexAt(event) })
const pan = panRef.current
if (pan !== null && pan.pointerId === event.pointerId) {
if (Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX) {
pan.moved = true
}
if (!pan.pannable) return
const delta = (event.clientX - pan.anchorClientX) / Math.max(1, rect.width)
const nextStart = Math.min(
Math.max(pan.anchorStart - delta * domainDuration, model.start),
model.end - domainDuration,
)
setViewport({ start: nextStart, end: nextStart + domainDuration })
return
}
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
let nextDomainStart = domainStart
if (viewport !== null) {
@@ -326,6 +451,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const pan = panRef.current
if (pan !== null && pan.pointerId === event.pointerId) {
const moved = pan.moved
|| Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX
panRef.current = null
setPanning(false)
if (!moved) onRangeChange(null)
return
}
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
const pointFraction = fractionAt(event)
@@ -375,8 +509,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const onPointerCancel = () => {
dragRef.current = null
panRef.current = null
setDraft(null)
setHover(null)
setPanning(false)
}
return (
@@ -386,6 +522,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
<div
ref={trackRef}
className={css.track}
data-panning={panning || undefined}
aria-label="Timeline overview; drag horizontally to focus events"
tabIndex={0}
onKeyDown={onKeyDown}
@@ -394,7 +531,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onPointerUp={onPointerEnd}
onPointerCancel={onPointerCancel}
onPointerLeave={() => {
if (dragRef.current === null) setHover(null)
if (dragRef.current === null && panRef.current === null) setHover(null)
}}
onDoubleClick={(event) => {
event.preventDefault()
@@ -402,9 +539,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}}
onContextMenu={(event) => {
event.preventDefault()
setAnimateViewport(false)
onRangeChange(null)
setViewport(null)
}}
>
{hover !== null && hover.recordIndex === null && draft === null && (
@@ -466,7 +600,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
className={css.lanes}
data-animate-viewport={animateViewport || undefined}
data-timeline-domain
aria-hidden="true"
style={projectedDomainStyle}
>
{model.spans
@@ -476,34 +609,51 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
.map((span) => {
const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / fullDuration
const durationMs = durationByIndex.get(span.index)
const widthPercent = Math.max(width * 100, 0.35)
const detail = detailByIndex.get(span.index)
const ttftMs = detail?.ttftMs
const decodingMs = detail?.decodingMs
const ttftFraction = ttftMs === undefined
|| decodingMs === undefined
|| ttftMs + decodingMs <= 0
? null
: ttftMs / (ttftMs + decodingMs)
return (
<span
className={css.span}
data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
data-selected={activeRange === null
? undefined
: span.start <= activeRange.end && span.end >= activeRange.start
? 'true'
: 'false'}
<Tooltip
key={span.index}
title={durationMs === undefined
? span.label
: `${span.label} · ${formatTimelineOffset(durationMs)}`}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
'--trajectory-span-lane': span.lane,
} as CSSProperties}
/>
label={timelineTooltipLabel(span.kind, detail)}
side="bottom"
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
>
<span
aria-hidden="true"
className={css.span}
data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-assistant-timing={ttftFraction === null ? undefined : 'true'}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
data-selected={activeRange === null
? undefined
: span.start <= activeRange.end && span.end >= activeRange.start
? 'true'
: 'false'}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${widthPercent}%`,
'--trajectory-span-gap': `clamp(0.25px, ${widthPercent * 0.08}%, 1px)`,
'--trajectory-span-lane': span.lane,
...(ttftFraction === null
? {}
: { '--trajectory-assistant-ttft': `${ttftFraction * 100}%` }),
} as CSSProperties}
/>
</Tooltip>
)
})}
</div>

View File

@@ -459,7 +459,7 @@ export function TrajectoryView({
}
return (
<div className={css.root}>
<div className={css.root} data-conversation-composer-overlay="">
<TrajectoryToolbar
actualDuration={actualDuration}
onActualDurationChange={(nextActualDuration) => {

View File

@@ -13,19 +13,6 @@
background: var(--dsw-alias-bg-layer-1);
}
/* Trajectory keeps the ledger and details panel inside the remaining
* conversation height. Only the ledger pane scrolls; the composer remains
* the fixed flex sibling below this view. */
:global([data-conversation-scroll]):has(.root) {
overflow: hidden;
}
:global([data-conversation-scroll]):has(.root) > :first-child {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.ledger {
position: relative;
z-index: 0;
@@ -35,4 +22,7 @@
min-height: 0;
min-width: 0;
overflow: hidden;
/* ConversationRoot publishes the floating composer's live height. */
--dsh-trajectory-bottom-clearance: calc(var(--dsh-composer-height, 152px) + 16px);
}

View File

@@ -160,6 +160,53 @@ describe('TrajectoryTable', () => {
expect(onClearSelection).toHaveBeenCalledOnce()
})
it('follows appended records only while the ledger is already at the bottom', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
let scrollHeight = 200
Object.defineProperties(tablePane, {
clientHeight: { configurable: true, get: () => 100 },
scrollHeight: { configurable: true, get: () => scrollHeight },
})
tablePane.scrollTop = 100
fireEvent.scroll(tablePane)
scrollHeight = 260
view.rerender(
<TrajectoryTable
turns={[...TURNS, {
turn: 2,
groups: [{
title: 'Step 1',
cells: [{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 }],
}],
}]}
{...FOLD_PROPS}
/>,
)
expect(tablePane.scrollTop).toBe(260)
tablePane.scrollTop = 20
fireEvent.scroll(tablePane)
scrollHeight = 320
view.rerender(
<TrajectoryTable
turns={[...TURNS, {
turn: 2,
groups: [{
title: 'Step 1',
cells: [
{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 },
{ index: 5, kind: 'tool', text: 'new tool', timeSeconds: 0.1 },
],
}],
}]}
{...FOLD_PROPS}
/>,
)
expect(tablePane.scrollTop).toBe(20)
})
it('keeps running and failure semantics distinct from record roles', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()

View File

@@ -9,7 +9,7 @@
*/
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -288,6 +288,7 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.queryByRole('columnheader')).toBeNull()
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
@@ -445,7 +446,7 @@ describe('tab switching in ConversationRoot', () => {
.toBe('outside')
fireEvent.contextMenu(plot)
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBeNull()
.toBe('outside')
})
it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
@@ -528,6 +529,57 @@ describe('timeline projection', () => {
}],
}] satisfies readonly TrajectoryTurnModel[]
it('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => {
vi.useFakeTimers()
try {
const view = render(
<TrajectoryTimeline
turns={[{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: 'assistant',
startedAt: 1_000,
timeSeconds: 2,
assistantMetrics: {
timingRecorded: true,
stepStartTime: 1_000,
firstTokenTime: 1_500,
completedTime: 3_000,
usageProvided: false,
outputTokens: null,
},
}],
}],
}]}
mode="duration"
range={null}
onRangeChange={vi.fn()}
/>,
)
const span = view.container.querySelector<HTMLElement>(
'[data-timeline-span="message"]',
)
expect(span?.getAttribute('title')).toBeNull()
expect(span?.getAttribute('data-assistant-timing')).toBe('true')
expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%')
fireEvent.mouseEnter(span as HTMLElement)
act(() => { vi.advanceTimersByTime(499) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]')
expect(tooltip?.textContent).toContain('Total 2.0 s')
expect(tooltip?.textContent).toContain('TTFT 500 ms')
expect(tooltip?.textContent).toContain('Decoding 1.5 s')
} finally {
vi.useRealTimers()
}
})
it('cancels native scrolling across the timeline while zooming', () => {
render(
<TrajectoryTimeline
@@ -550,6 +602,99 @@ describe('timeline projection', () => {
})).toBe(false)
})
it('scales sequence gutters with narrow operation spans', () => {
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
onRangeChange={vi.fn()}
/>,
)
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%')
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
.toBe('clamp(0.25px, 0.8%, 1px)')
})
it('clears the selection without changing zoom on a zoomed right click', () => {
const onRangeChange = vi.fn()
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={{ start: 2, end: 4 }}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width')
expect(domainWidth).not.toBe('100%')
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(fireEvent.contextMenu(plot)).toBe(false)
fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(onRangeChange).toHaveBeenCalledOnce()
expect(onRangeChange).toHaveBeenCalledWith(null)
expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth)
})
it('clears the selection and suppresses the context menu at full zoom', () => {
const onRangeChange = vi.fn()
render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={{ start: 2, end: 4 }}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(fireEvent.contextMenu(plot)).toBe(false)
fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(onRangeChange).toHaveBeenCalledOnce()
expect(onRangeChange).toHaveBeenCalledWith(null)
})
it('pans the zoomed viewport with a right-button drag without changing the selection', () => {
const onRangeChange = vi.fn()
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={{ start: 2, end: 4 }}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
const before = domain?.style.getPropertyValue('--trajectory-domain-left')
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(plot.getAttribute('data-panning')).toBe('true')
expect(fireEvent.contextMenu(plot)).toBe(false)
fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 })
fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 })
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before)
expect(onRangeChange).not.toHaveBeenCalled()
expect(plot.getAttribute('data-panning')).toBeNull()
})
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
const onRangeChange = vi.fn()
const view = render(