mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #1172 from deepseek-harness/xtr/trajectory-timeline-interactions
feat(ui-trajectory): improve timeline timing, layout, and navigation
This commit is contained in:
@@ -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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
|
||||
2026-07-27-trajectory-inspection-ledger.md: 8c2a7c42b7898776de5d42459b09c0fb1737ec0b
|
||||
2026-07-27-trajectory-inspection-ledger.zh.md: 6c5733046dc2cfd3fd2bd005f4cf5c2d2bd110af
|
||||
2026-07-27-trajectory-inspection-ledger.md: cdeaa30ea64f47b0e0110baf566f747a4591a384
|
||||
2026-07-27-trajectory-inspection-ledger.zh.md: df2a3d266161a7c1c4444863971f3d177533af8c
|
||||
|
||||
@@ -21,7 +21,9 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
|
||||
- Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas.
|
||||
- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data.
|
||||
- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
|
||||
- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
|
||||
- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
|
||||
- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows.
|
||||
- Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay.
|
||||
- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer.
|
||||
|
||||
## Alternatives considered
|
||||
@@ -34,10 +36,12 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
|
||||
|
||||
**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state.
|
||||
|
||||
**Override the composer seat from Trajectory CSS.** Rejected: a cross-package selector would depend on generated class specificity and stylesheet order. An explicit view marker keeps seat geometry and active-phase precedence in `ConversationRoot`, while Trajectory owns only its internal clearance.
|
||||
|
||||
**Keep timing in a separate Waterfall tab.** Rejected: the placeholder summarized node counts rather than record timing and forced users to switch away from the rows they wanted to focus. A full-domain Overview keeps timing and filtered records in one visual context.
|
||||
|
||||
**Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces.
|
||||
|
||||
## Consequences
|
||||
|
||||
Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin projection, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview, and inspector through the real client composition.
|
||||
Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin tail following, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition.
|
||||
|
||||
@@ -21,7 +21,9 @@ Status: implemented
|
||||
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
|
||||
- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。
|
||||
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。
|
||||
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
|
||||
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
|
||||
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。
|
||||
- Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。
|
||||
- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
@@ -34,10 +36,12 @@ Status: implemented
|
||||
|
||||
**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。
|
||||
|
||||
**由 Trajectory CSS 覆盖 composer seat。** 不予采纳:跨包(package)选择器会依赖生成类选择器的优先级和样式表顺序。显式视图标记让 seat 几何形状和活跃阶段优先级留在 `ConversationRoot` 中,而 Trajectory 只负责自身内部的避让空间。
|
||||
|
||||
**将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。
|
||||
|
||||
**修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。
|
||||
|
||||
## 后果
|
||||
|
||||
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间与耗时数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定投影、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 区域与检查器。
|
||||
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定末尾跟随、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。
|
||||
|
||||
@@ -138,6 +138,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
const overlayLayout = await page.getByRole('table').evaluate((table) => {
|
||||
const host = table.closest('[data-conversation-scroll]')
|
||||
const seat = host?.querySelector('[data-composer-seat]') ?? null
|
||||
const pane = table.parentElement
|
||||
return {
|
||||
hostPosition: host === null ? null : getComputedStyle(host).position,
|
||||
paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX,
|
||||
paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth,
|
||||
seatPosition: seat === null ? null : getComputedStyle(seat).position,
|
||||
}
|
||||
})
|
||||
expect(overlayLayout).toEqual({
|
||||
hostPosition: 'relative',
|
||||
paneOverflowX: 'hidden',
|
||||
paneScrollableWidth: 0,
|
||||
seatPosition: 'absolute',
|
||||
})
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
slotErrors,
|
||||
@@ -153,6 +170,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await page.locator('tr[data-kind="tool"]').first().click()
|
||||
const details = page.getByRole('complementary', { name: 'Event details' })
|
||||
await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX))
|
||||
.toBe('hidden')
|
||||
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||
const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({
|
||||
heading: getComputedStyle(heading).backgroundColor,
|
||||
@@ -162,6 +181,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
|
||||
await page.getByRole('tab', { name: 'Result' }).click()
|
||||
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first()
|
||||
await assistantSpan.hover()
|
||||
const timingTooltip = page.getByRole('tooltip')
|
||||
await timingTooltip.waitFor({ timeout: 5_000 })
|
||||
await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/)
|
||||
const assistantTimingStyle = await assistantSpan.evaluate(node => ({
|
||||
background: getComputedStyle(node).backgroundImage,
|
||||
ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'),
|
||||
}))
|
||||
expect(assistantTimingStyle.background).toContain('linear-gradient')
|
||||
expect(assistantTimingStyle.ttft).toMatch(/%$/)
|
||||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
|
||||
|
||||
@@ -516,6 +516,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
// shape so goldens stay stable across midnight and year boundaries.
|
||||
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
.replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
.replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}')
|
||||
.replace(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
- button "Collapse calls": Calls
|
||||
- img
|
||||
- searchbox "Search trajectory"
|
||||
- region "Trajectory timeline"
|
||||
- region "Trajectory timeline":
|
||||
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s"
|
||||
- table:
|
||||
- rowgroup:
|
||||
- row "SYSTEM, Initial System Prompt":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user