feat(client): add trajectory timing overview
This commit is contained in:
@@ -150,8 +150,11 @@ function deriveCallSchemas(
|
||||
}
|
||||
for (const event of events) {
|
||||
if (event.type === 'request/header') {
|
||||
const tools: unknown = event.data.header.tools
|
||||
active = new Map(
|
||||
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
|
||||
Array.isArray(tools)
|
||||
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
|
||||
: [],
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -105,4 +105,25 @@ describe('inspectRequests', () => {
|
||||
|
||||
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: '{{tools}}',
|
||||
},
|
||||
}),
|
||||
at(1, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas).toEqual(new Map())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
|
||||
/**
|
||||
* The conversation view ring: one list entry per view tab (chat here;
|
||||
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
|
||||
* Trajectory from ui-trajectory), rendered one-at-a-time by
|
||||
* ConversationRoot via `only: <active id>`. Declared by this package's
|
||||
* 'conversation' entry (declaring is claiming). Session scope: views read
|
||||
* the conversation snapshot through the standard kit.
|
||||
@@ -133,7 +133,7 @@ export interface ConvViewOwnerProps {}
|
||||
|
||||
/**
|
||||
* Owner share of a per-view toolview slot: the call material the rendering
|
||||
* view supplies per row. Uniform across views — the trajectory/waterfall
|
||||
* view supplies per row. Uniform across views — the Trajectory
|
||||
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
|
||||
* discipline) land with their own row render sites; today only the chat slot
|
||||
* is declared (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
@@ -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: 884db29b746124ecc1d7a8d2312101b2930c113f
|
||||
README.zh.md: a4d7d4513317692798bb6ea8b453293fa05b6cbe
|
||||
README.md: ee6fb97894ec7503ccb9c0cb6a74c122fbcd852c
|
||||
README.zh.md: 238a499f646a4bf637c037f8906be6ae684bb013
|
||||
|
||||
@@ -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. The package also provides the Waterfall view and remains a pure-consumer plugin (registers two view tabs 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 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 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
|
||||
|
||||
@@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands; record selection is intentionally local to Trajectory; anchor deep-linking remains deferred.
|
||||
- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands, so the Overview renders a start marker rather than inventing a live span; record and timeline selection are intentionally local to Trajectory; anchor deep-linking remains deferred.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。该包(package)还提供 waterfall(瀑布式事件)视图,并保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -14,4 +14,4 @@ Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时;记录选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。
|
||||
- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-trajectory",
|
||||
"description": "Trajectory event-ledger and Waterfall views: pure-consumer plugin registering into the conversation ViewMap (no service)",
|
||||
"description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.root {
|
||||
padding: 4px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// TrajectoryStatsHeader: span totals row rendered at the top of both
|
||||
// placeholder view bodies (chrome dissolved into the views — the header is
|
||||
// part of what these views ARE, not registration metadata). Subscribes to
|
||||
// `nodes` only: chunk batches never swap that reference, so the row is quiet
|
||||
// during streaming.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { deriveSpans, deriveSpanStats } from './spans.ts'
|
||||
import css from './TrajectoryStatsHeader.module.css'
|
||||
|
||||
/** Props: the conversation-snapshot selector hook (handed down by the view body). */
|
||||
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
|
||||
if (stats.turns === 0) return null
|
||||
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
.root {
|
||||
flex: none;
|
||||
padding: 8px 16px 12px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.clear {
|
||||
flex: none;
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
background: transparent;
|
||||
font: var(--dsw-font-xs-13);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.clear:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.plot {
|
||||
position: relative;
|
||||
height: 72px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.plot:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: absolute;
|
||||
inset: 0 8px auto;
|
||||
height: 20px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
left: var(--trajectory-tick-left);
|
||||
padding: 2px 4px;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.tick:first-child {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tick:last-child {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.tick::after {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
bottom: -52px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: var(--dsw-alias-border-l1);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.lanes {
|
||||
position: absolute;
|
||||
inset: 24px 8px 6px;
|
||||
}
|
||||
|
||||
.span {
|
||||
position: absolute;
|
||||
top: calc(var(--trajectory-span-lane) * 14px);
|
||||
left: var(--trajectory-span-left);
|
||||
width: var(--trajectory-span-width);
|
||||
height: 8px;
|
||||
min-width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.span[data-timeline-span='message'],
|
||||
.span[data-timeline-span='compacted'] {
|
||||
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='tool'],
|
||||
.span[data-timeline-span='subtool'] {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.selection {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--trajectory-selection-left);
|
||||
width: var(--trajectory-selection-width);
|
||||
min-width: 1px;
|
||||
border-right: 1px solid var(--dsw-alias-state-business-primary);
|
||||
border-left: 1px solid var(--dsw-alias-state-business-primary);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-business-primary) 12%,
|
||||
transparent
|
||||
);
|
||||
box-shadow:
|
||||
-100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent),
|
||||
100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selection::before,
|
||||
.selection::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.selection::before {
|
||||
left: -2px;
|
||||
}
|
||||
|
||||
.selection::after {
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
.selection[data-dragging='true'] {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-business-primary) 18%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.root {
|
||||
padding-right: 12px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.plot {
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
216
packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx
Normal file
216
packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
|
||||
|
||||
import {
|
||||
memo, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent,
|
||||
} from 'react'
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import {
|
||||
deriveTrajectoryTimeline,
|
||||
filterTrajectoryTimelineRange,
|
||||
formatTimelineOffset,
|
||||
type TrajectoryTimeRange,
|
||||
} from './timeline.ts'
|
||||
import css from './TrajectoryTimeline.module.css'
|
||||
|
||||
const TICK_COUNT = 5
|
||||
const MINIMUM_DRAG_PX = 3
|
||||
|
||||
interface FractionRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Props for the fixed full-domain overview above the trajectory ledger. */
|
||||
export interface TrajectoryTimelineProps {
|
||||
turns: readonly TrajectoryTurnModel[]
|
||||
range: TrajectoryTimeRange | null
|
||||
onRangeChange: (range: TrajectoryTimeRange | null) => void
|
||||
}
|
||||
|
||||
function orderedRange(left: number, right: number): FractionRange {
|
||||
return left <= right ? { start: left, end: right } : { start: right, end: left }
|
||||
}
|
||||
|
||||
function clampFraction(value: number): number {
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
function rangeFraction(
|
||||
range: TrajectoryTimeRange,
|
||||
start: number,
|
||||
duration: number,
|
||||
): FractionRange {
|
||||
return orderedRange(
|
||||
clampFraction((range.start - start) / duration),
|
||||
clampFraction((range.end - start) / duration),
|
||||
)
|
||||
}
|
||||
|
||||
/** Overview renderer with drag-to-filter and Escape/clear reset. */
|
||||
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
turns,
|
||||
range,
|
||||
onRangeChange,
|
||||
}: TrajectoryTimelineProps) {
|
||||
const model = useMemo(() => deriveTrajectoryTimeline(turns), [turns])
|
||||
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
|
||||
const [draft, setDraft] = useState<FractionRange | null>(null)
|
||||
const domainDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
|
||||
const committed = model === null || range === null
|
||||
? null
|
||||
: rangeFraction(range, model.start, domainDuration)
|
||||
const visibleRange = draft ?? committed
|
||||
const focusedCount = useMemo(
|
||||
() => range === null
|
||||
? model?.spans.length ?? 0
|
||||
: deriveTrajectoryTimeline(filterTrajectoryTimelineRange(turns, range))?.spans.length ?? 0,
|
||||
[model?.spans.length, range, turns],
|
||||
)
|
||||
|
||||
if (model === null) {
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.header}>
|
||||
<span className={css.title}>Overview</span>
|
||||
<span className={css.summary}>No timing data</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||
}
|
||||
|
||||
const commit = (fraction: FractionRange) => {
|
||||
onRangeChange({
|
||||
start: model.start + fraction.start * domainDuration,
|
||||
end: model.start + fraction.end * domainDuration,
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const anchor = fractionAt(event)
|
||||
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
|
||||
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
setDraft({ start: anchor, end: anchor })
|
||||
}
|
||||
|
||||
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
setDraft(orderedRange(drag.anchor, fractionAt(event)))
|
||||
}
|
||||
|
||||
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
const selected = orderedRange(drag.anchor, fractionAt(event))
|
||||
dragRef.current = null
|
||||
setDraft(null)
|
||||
if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) {
|
||||
onRangeChange(null)
|
||||
} else {
|
||||
commit(selected)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Escape' || range === null) return
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
}
|
||||
|
||||
const onPointerCancel = () => {
|
||||
dragRef.current = null
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const ticks = Array.from({ length: TICK_COUNT }, (_, index) => {
|
||||
const fraction = index / (TICK_COUNT - 1)
|
||||
return {
|
||||
fraction,
|
||||
label: formatTimelineOffset(fraction * domainDuration),
|
||||
}
|
||||
})
|
||||
const summary = range === null
|
||||
? `${model.spans.length} timed events`
|
||||
: `${focusedCount} of ${model.spans.length} events · ${formatTimelineOffset(range.start - model.start)}–${formatTimelineOffset(range.end - model.start)}`
|
||||
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.header}>
|
||||
<span className={css.title}>Overview</span>
|
||||
<span className={css.summary} aria-live="polite">{summary}</span>
|
||||
{range !== null && (
|
||||
<button
|
||||
className={css.clear}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onRangeChange(null)
|
||||
}}
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css.plot}
|
||||
aria-label="Timeline overview; drag horizontally to filter events"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerEnd}
|
||||
onPointerCancel={onPointerCancel}
|
||||
>
|
||||
<div className={css.ticks} aria-hidden="true">
|
||||
{ticks.map(tick => (
|
||||
<span
|
||||
className={css.tick}
|
||||
key={tick.fraction}
|
||||
style={{ '--trajectory-tick-left': `${tick.fraction * 100}%` } as CSSProperties}
|
||||
>
|
||||
{tick.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.lanes} aria-hidden="true">
|
||||
{model.spans.map((span) => {
|
||||
const left = (span.start - model.start) / domainDuration
|
||||
const width = (span.end - span.start) / domainDuration
|
||||
return (
|
||||
<span
|
||||
className={css.span}
|
||||
data-timeline-span={span.kind}
|
||||
key={span.index}
|
||||
title={`${span.label} · ${formatTimelineOffset(span.end - span.start)}`}
|
||||
style={{
|
||||
'--trajectory-span-left': `${left * 100}%`,
|
||||
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
|
||||
'--trajectory-span-lane': span.lane,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{visibleRange !== null && (
|
||||
<div
|
||||
className={css.selection}
|
||||
data-dragging={draft === null ? undefined : 'true'}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
|
||||
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
type TrajectoryUsage,
|
||||
} from './TrajectoryTable.tsx'
|
||||
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
|
||||
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
|
||||
import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import {
|
||||
filterTrajectoryTimelineRange, type TrajectoryTimeRange,
|
||||
} from './timeline.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set()
|
||||
@@ -73,6 +77,10 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [timelineSelection, setTimelineSelection] = useState<{
|
||||
branchId: number
|
||||
range: TrajectoryTimeRange
|
||||
} | null>(null)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const inspection = useSession(s => s.inspection)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
@@ -249,8 +257,15 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
|
||||
],
|
||||
)
|
||||
const timelineRange = timelineSelection?.branchId === currentBranch.id
|
||||
? timelineSelection.range
|
||||
: null
|
||||
const focusedTurns = useMemo(
|
||||
() => filterTrajectoryTimelineRange(turns, timelineRange),
|
||||
[timelineRange, turns],
|
||||
)
|
||||
const collapsibleTurnIds = useMemo(
|
||||
() => turns
|
||||
() => focusedTurns
|
||||
.filter(turn =>
|
||||
turn.groups.reduce(
|
||||
(count, group) =>
|
||||
@@ -259,13 +274,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
0,
|
||||
) > 1)
|
||||
.map(turn => turn.turn),
|
||||
[turns],
|
||||
[focusedTurns],
|
||||
)
|
||||
const allTurnsCollapsed = collapsibleTurnIds.length > 0
|
||||
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
|
||||
const collapsibleAssistantIds = useMemo(() => {
|
||||
const ids: number[] = []
|
||||
for (const turn of turns) {
|
||||
for (const turn of focusedTurns) {
|
||||
const cells = turn.groups.flatMap(group => group.cells)
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i]
|
||||
@@ -275,7 +290,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [turns])
|
||||
}, [focusedTurns])
|
||||
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
|
||||
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
|
||||
|
||||
@@ -331,11 +346,18 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
allAssistantsCollapsed={allAssistantsCollapsed}
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
/>
|
||||
<TrajectoryTimeline
|
||||
turns={turns}
|
||||
range={timelineRange}
|
||||
onRangeChange={(range) => {
|
||||
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
|
||||
}}
|
||||
/>
|
||||
<div className={css.ledger}>
|
||||
<TrajectoryTable
|
||||
key={currentBranch.id}
|
||||
key={`${currentBranch.id}:${timelineRange?.start ?? 'all'}:${timelineRange?.end ?? 'all'}`}
|
||||
requestNumbers={requestNumbers}
|
||||
turns={turns}
|
||||
turns={focusedTurns}
|
||||
collapsedTurns={collapsedTurns}
|
||||
onToggleTurn={toggleTurn}
|
||||
collapsedAssistants={collapsedAssistants}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// WaterfallView: span stats header over per-turn node-count lanes (P-I
|
||||
// stand-in for duration lanes; deviation ledger #3). run_code turns
|
||||
// additionally draw TRUTHFUL sub-call lanes: the dispatch start/settle pair
|
||||
// carries per-sub-call wall time, so each sub-span's width is its real
|
||||
// duration against the parent turn's dispatch window.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { deriveSpans, deriveSubSpans } from './spans.ts'
|
||||
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
|
||||
import css from './views.module.css'
|
||||
|
||||
/** Bar width scale: px per node, clamped so tiny windows still show a bar. */
|
||||
const PX_PER_NODE = 14
|
||||
const MIN_BAR_PX = 8
|
||||
/** Sub-span lane width budget (the parent window scales into this). */
|
||||
const SUB_LANE_PX = 220
|
||||
|
||||
/** Optional density override (test/standalone knob; the register site passes nothing). */
|
||||
export interface WaterfallExtraProps {
|
||||
/** Bar-lane density in px per node; defaults to 14. */
|
||||
pxPerNode?: number
|
||||
}
|
||||
|
||||
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
|
||||
const scale = pxPerNode ?? PX_PER_NODE
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
|
||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>No timing data</p></div>
|
||||
return (
|
||||
<>
|
||||
<TrajectoryStatsHeader useSession={useSession} />
|
||||
<div className={css.root}>
|
||||
{spans.map((span, i) => (
|
||||
<div key={span.turn}>
|
||||
<div className={css.row} style={{ paddingLeft: i * 12 }}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span
|
||||
className={css.bar}
|
||||
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
|
||||
title={`${span.nodes} nodes`}
|
||||
/>
|
||||
{span.calls > 0 && (
|
||||
<span
|
||||
className={`${css.bar} ${css.barCalls}`}
|
||||
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
|
||||
title={`${span.calls} tool calls`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(subSpans.get(span.turn) ?? []).map(lane => (
|
||||
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
|
||||
<span className={css.subTag}>{lane.name}</span>
|
||||
<span
|
||||
className={`${css.bar} ${css.barSub}`}
|
||||
data-timing={lane.timing}
|
||||
style={{
|
||||
marginLeft: Math.round(lane.offsetFraction * SUB_LANE_PX),
|
||||
width: Math.max(Math.round(lane.widthFraction * SUB_LANE_PX), 4),
|
||||
}}
|
||||
title={lane.timing === 'measured'
|
||||
/* durationMs is non-null exactly when timing is measured. */
|
||||
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)} s`
|
||||
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Browser trajectory plugin contributing two entries to the conversation
|
||||
* view slot without defining a service.
|
||||
* Browser trajectory plugin contributing one entry to the conversation view
|
||||
* slot without defining a service.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -8,7 +8,6 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
import { WaterfallView } from './WaterfallView.tsx'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
@@ -20,9 +19,8 @@ import { WaterfallView } from './WaterfallView.tsx'
|
||||
export const inject = ['slots', 'conversation', 'sessions']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
* registrations ride the slot service's effect wrapper (plugin unload
|
||||
* removes both tabs).
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
* rides the slot service's effect wrapper, so plugin unload removes the tab.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
@@ -39,6 +37,4 @@ export function apply(ctx: Context): void {
|
||||
return { loadAllHistory: () => session.loadAllHistory() }
|
||||
},
|
||||
}, TrajectoryView)
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Rough per-turn span derivation shared by the two placeholder views and the
|
||||
* header stats bar. P-I ships no timing data, so a span's weight is its node
|
||||
* count, not wall time (deviation ledger #3 — real spans land in P-III).
|
||||
*/
|
||||
import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One run_code sub-dispatch lane in the waterfall: real timing off the start/settle pair. */
|
||||
export interface SubSpanLane {
|
||||
callId: string
|
||||
name: string
|
||||
/** Wall duration in ms; null unless both endpoints were observed (`timing: 'measured'`). */
|
||||
durationMs: number | null
|
||||
/**
|
||||
* Timing provenance: `measured` = start/settle pair observed; `running` =
|
||||
* start seen, settle pending; `unknown` = settle-only replay window (the
|
||||
* start fell outside), so no duration claim is possible.
|
||||
*/
|
||||
timing: 'measured' | 'running' | 'unknown'
|
||||
/** Start offset as a fraction of the parent turn's dispatch window [0, 1). */
|
||||
offsetFraction: number
|
||||
/** Width as a fraction of the window (running lanes extend to the window end). */
|
||||
widthFraction: number
|
||||
}
|
||||
|
||||
/** One turn's worth of activity, folded from the snapshot node window. */
|
||||
export interface TurnSpan {
|
||||
turn: number
|
||||
/** Assistant step messages inside the turn. */
|
||||
steps: number
|
||||
/** Tool results inside the turn (running calls are not folded in P-I). */
|
||||
calls: number
|
||||
/** Total nodes attributed to the turn (span weight stand-in). */
|
||||
nodes: number
|
||||
}
|
||||
|
||||
/** Aggregate totals for the header stats bar. */
|
||||
export interface SpanStats {
|
||||
turns: number
|
||||
steps: number
|
||||
calls: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold snapshot nodes into per-turn spans. Only assistant nodes carry a turn
|
||||
* number; user/steering/context/tool nodes attach to the turn last seen in
|
||||
* sequence order (turn 0 collects the pre-assistant prologue).
|
||||
* @param nodes - snapshot nodes in surface order.
|
||||
* @returns spans ordered by first appearance.
|
||||
*/
|
||||
export function deriveSpans(nodes: ConversationSnapshot['nodes']): readonly TurnSpan[] {
|
||||
const spans = new Map<number, TurnSpan>()
|
||||
let currentTurn = 0
|
||||
const spanFor = (turn: number): TurnSpan => {
|
||||
let span = spans.get(turn)
|
||||
if (span === undefined) {
|
||||
span = { turn, steps: 0, calls: 0, nodes: 0 }
|
||||
spans.set(turn, span)
|
||||
}
|
||||
return span
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (hasTurn(node)) currentTurn = node.turn
|
||||
const span = spanFor(currentTurn)
|
||||
span.nodes += 1
|
||||
if (node.kind === 'assistant') span.steps += 1
|
||||
if (node.kind === 'tool-result') span.calls += 1
|
||||
}
|
||||
return [...spans.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate spans into the header totals.
|
||||
* @param spans - deriveSpans product.
|
||||
* @returns turn/step/call totals.
|
||||
*/
|
||||
export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats {
|
||||
let steps = 0
|
||||
let calls = 0
|
||||
for (const span of spans) {
|
||||
steps += span.steps
|
||||
calls += span.calls
|
||||
}
|
||||
return { turns: spans.length, steps, calls }
|
||||
}
|
||||
|
||||
function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } {
|
||||
return node.kind === 'assistant' || node.kind === 'steering'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the dispatch index into per-turn sub-span lanes with REAL timing: each
|
||||
* lane's offset/width scale against its parent turn's dispatch window (first
|
||||
* start → last settle). Running (unsettled) lanes extend to the window end
|
||||
* with a null duration.
|
||||
* @param nodes - snapshot nodes (locates each parent run_code call's turn).
|
||||
* @param codeDispatches - the snapshot's dispatch index.
|
||||
* @returns lanes keyed by turn, in start order.
|
||||
*/
|
||||
export function deriveSubSpans(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
codeDispatches: ConversationSnapshot['codeDispatches'],
|
||||
): ReadonlyMap<number, readonly SubSpanLane[]> {
|
||||
const out = new Map<number, SubSpanLane[]>()
|
||||
if (codeDispatches.size === 0) return out
|
||||
const turnByCall = new Map<string, number>()
|
||||
let currentTurn = 0
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'assistant' || node.kind === 'steering') currentTurn = node.turn
|
||||
if (node.kind === 'tool-result') turnByCall.set(node.callId, currentTurn)
|
||||
}
|
||||
for (const [parent, subs] of codeDispatches) {
|
||||
if (subs.length === 0) continue
|
||||
const turn = turnByCall.get(parent) ?? currentTurn
|
||||
// A settle-only entry (callTime null: its start fell outside the replay
|
||||
// window) anchors the window by its settle time — a real observation —
|
||||
// but must never masquerade as a measured zero-duration span.
|
||||
const starts: number[] = []
|
||||
const ends: number[] = []
|
||||
for (const sub of subs) {
|
||||
const settled = 'kind' in sub
|
||||
const start = settled ? sub.callTime ?? sub.time : sub.time
|
||||
starts.push(start)
|
||||
ends.push(settled ? sub.time : start)
|
||||
}
|
||||
const windowStart = Math.min(...starts)
|
||||
const windowEnd = Math.max(...ends, windowStart + 1)
|
||||
const windowSpan = windowEnd - windowStart
|
||||
const lanes: SubSpanLane[] = subs.map((sub, i) => {
|
||||
const settled = 'kind' in sub
|
||||
const timing = settled ? (sub.callTime === null ? 'unknown' as const : 'measured' as const) : 'running' as const
|
||||
const start = starts[i] ?? windowStart
|
||||
const end = settled ? sub.time : windowEnd
|
||||
return {
|
||||
callId: sub.callId,
|
||||
name: settled ? sub.call?.name ?? sub.callId : sub.name,
|
||||
durationMs: timing === 'measured' ? Math.max(0, end - start) : null,
|
||||
timing,
|
||||
offsetFraction: (start - windowStart) / windowSpan,
|
||||
widthFraction: Math.max((end - start) / windowSpan, 0.02),
|
||||
}
|
||||
})
|
||||
const existing = out.get(turn) ?? []
|
||||
out.set(turn, [...existing, ...lanes])
|
||||
}
|
||||
return out
|
||||
}
|
||||
110
packages/client/ui-trajectory/src/client/timeline.ts
Normal file
110
packages/client/ui-trajectory/src/client/timeline.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/** Time-domain projection and filtering for the trajectory overview. */
|
||||
|
||||
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
|
||||
/** Inclusive absolute-time selection in Unix epoch milliseconds. */
|
||||
export interface TrajectoryTimeRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** One timed ledger record projected into the overview. */
|
||||
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
|
||||
index: number
|
||||
kind: TrajectoryCellKind
|
||||
label: string
|
||||
lane: number
|
||||
}
|
||||
|
||||
/** Full-domain model used by the overview. */
|
||||
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
|
||||
spans: readonly TrajectoryTimelineSpan[]
|
||||
}
|
||||
|
||||
function finite(value: number | null | undefined): value is number {
|
||||
return value !== null && value !== undefined && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
|
||||
if (!finite(cell.startedAt)) return null
|
||||
const durationMs = finite(cell.timeSeconds)
|
||||
? Math.max(0, cell.timeSeconds * 1_000)
|
||||
: 0
|
||||
return { start: cell.startedAt, end: cell.startedAt + durationMs }
|
||||
}
|
||||
|
||||
function laneFor(kind: TrajectoryCellKind): number {
|
||||
if (kind === 'tool' || kind === 'subtool') return 2
|
||||
if (kind === 'message' || kind === 'compacted') return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Project every visible timed record into a stable three-lane overview.
|
||||
* @param turns - Unfiltered trajectory layout.
|
||||
* @returns Timeline model, or `null` when no record carries a start time.
|
||||
*/
|
||||
export function deriveTrajectoryTimeline(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
): TrajectoryTimelineModel | null {
|
||||
const spans = turns.flatMap(turn =>
|
||||
turn.groups.flatMap(group =>
|
||||
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
|
||||
if (cell.requestOnly === true) return []
|
||||
const range = cellRange(cell)
|
||||
return range === null
|
||||
? []
|
||||
: [{
|
||||
...range,
|
||||
index: cell.index,
|
||||
kind: cell.kind,
|
||||
label: cell.text,
|
||||
lane: laneFor(cell.kind),
|
||||
}]
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (spans.length === 0) return null
|
||||
return {
|
||||
start: Math.min(...spans.map(span => span.start)),
|
||||
end: Math.max(...spans.map(span => span.end)),
|
||||
spans,
|
||||
}
|
||||
}
|
||||
|
||||
function overlaps(cell: TrajectoryCellProps, range: TrajectoryTimeRange): boolean {
|
||||
const timed = cellRange(cell)
|
||||
return timed !== null && timed.start <= range.end && timed.end >= range.start
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep records active at any point inside an inclusive selected interval.
|
||||
* @param turns - Unfiltered trajectory layout.
|
||||
* @param range - Absolute selected interval, or `null` for the full ledger.
|
||||
* @returns A layout retaining original turn, group, and record identities.
|
||||
*/
|
||||
export function filterTrajectoryTimelineRange(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
range: TrajectoryTimeRange | null,
|
||||
): readonly TrajectoryTurnModel[] {
|
||||
if (range === null) return turns
|
||||
return turns.flatMap((turn): TrajectoryTurnModel[] => {
|
||||
const groups = turn.groups.flatMap((group) => {
|
||||
const cells = group.cells.filter(cell => overlaps(cell, range))
|
||||
return cells.length === 0 ? [] : [{ ...group, cells }]
|
||||
})
|
||||
return groups.length === 0 ? [] : [{ ...turn, groups }]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a relative timeline offset with a compact unit.
|
||||
* @param milliseconds - Non-negative relative offset.
|
||||
* @returns Millisecond or second label.
|
||||
*/
|
||||
export function formatTimelineOffset(milliseconds: number): string {
|
||||
if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
|
||||
const seconds = milliseconds / 1_000
|
||||
return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */
|
||||
/* Full-bleed, fixed-height host for the trajectory ledger. */
|
||||
.root {
|
||||
--dsh-trajectory-toolbar-height: 40px;
|
||||
|
||||
@@ -13,16 +13,6 @@
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
place-items: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.ledger {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -30,68 +20,3 @@
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Waterfall placeholder rows (shared module). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
.turnTag {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-bg-skeleton);
|
||||
}
|
||||
|
||||
.barCalls {
|
||||
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* run_code sub-span lanes: one row per sub-dispatch under its turn row,
|
||||
offset/width scaled to the dispatch window (real wall time). A running
|
||||
lane pulses via reduced opacity until its settle arrives. */
|
||||
.subRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.subTag {
|
||||
flex: none;
|
||||
width: 88px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.barSub {
|
||||
height: 8px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.barSub[data-timing='running'] {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Settle-only replay entries: no measured span — hollow, not a solid bar. */
|
||||
.barSub[data-timing='unknown'] {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a pure-consumer plugin — it emits no cordis events
|
||||
* and owns no mutable cross-plugin state; both view-slot registrations are
|
||||
* plain effects whose disposal the slot ledger's own specs and this
|
||||
* and owns no mutable cross-plugin state; its view-slot registration is a
|
||||
* plain effect whose disposal the slot ledger's own specs and this
|
||||
* package's behavior specs observe directly.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Real tsdown artifact shape: lib/client.js hands off through
|
||||
* window.__ModuleLoader__.load, resolves externals through the injected
|
||||
* require, returns the export surface (apply + inject), and a mounted apply
|
||||
* registers both view tabs into a real SlotsService ring. Skips when dist/ is
|
||||
* registers the view tab into a real SlotsService ring. Skips when dist/ is
|
||||
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -63,7 +63,7 @@ describe('tsdown client artifact', () => {
|
||||
expect(surface.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
|
||||
const { surface } = await loadArtifact()
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
@@ -78,7 +78,7 @@ describe('tsdown client artifact', () => {
|
||||
ctx.provide('sessions', {})
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.view')).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* View registration acceptance on the real framework stack: the plugin fiber
|
||||
* registers trajectory/waterfall into a real SlotsService view ring, tabs
|
||||
* registers Trajectory into a real SlotsService view ring, tabs
|
||||
* switch inside ConversationRoot (renderSlot share driven by the same tab
|
||||
* projection apply uses) without collapsing chat, trajectory renders the
|
||||
* event-ledger chrome (no span stats bar), waterfall keeps in-body stats, and
|
||||
* fiber disposal removes both tabs. Span derivation edge cases ride along.
|
||||
* event ledger with its timing overview, and fiber disposal removes the tab.
|
||||
* Timeline projection and inclusive filtering edge cases ride along.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -23,10 +23,13 @@ import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversa
|
||||
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
|
||||
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
|
||||
import type { TrajectoryTurnModel } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/layout.ts'
|
||||
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
|
||||
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
|
||||
import {
|
||||
deriveTrajectoryTimeline,
|
||||
filterTrajectoryTimelineRange,
|
||||
formatTimelineOffset,
|
||||
} from '@deepseek-ai/dsh-client-ui-trajectory/src/client/timeline.ts'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
@@ -42,12 +45,18 @@ beforeEach(() => {
|
||||
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
|
||||
const NODES = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
|
||||
{ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null,
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [],
|
||||
timing: { stepStartTime: 1_800, firstTokenTime: 1_900, completedTime: 2_000 },
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{ kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
|
||||
timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
@@ -167,16 +176,15 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
}
|
||||
|
||||
describe('plugin registration', () => {
|
||||
it('registers trajectory and waterfall after chat on the ring', async () => {
|
||||
it('registers trajectory after chat on the ring', async () => {
|
||||
const b = await bench()
|
||||
expect(tabsOf(b.slots)).toEqual([
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'trajectory', label: 'Trajectory' },
|
||||
{ id: 'waterfall', label: 'Waterfall' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fiber disposal removes both tabs and leaves chat standing', async () => {
|
||||
it('fiber disposal removes the tab and leaves chat standing', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.dispose()
|
||||
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
||||
@@ -184,17 +192,18 @@ describe('plugin registration', () => {
|
||||
})
|
||||
|
||||
describe('tab switching in ConversationRoot', () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => {
|
||||
it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => {
|
||||
const b = await bench()
|
||||
const view = mount(b.slots)
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
|
||||
expect(screen.queryByRole('columnheader')).toBeNull()
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).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' }))
|
||||
@@ -220,47 +229,72 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
|
||||
})
|
||||
|
||||
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
|
||||
it('dragging the overview focuses overlapping records and clear restores the ledger', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
|
||||
expect(screen.getByTitle('2 nodes')).toBeTruthy()
|
||||
expect(screen.getByTitle('1 tool calls')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to filter events')
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 1 })
|
||||
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
|
||||
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
expect(screen.queryByRole('row', { name: /USER/ })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }))
|
||||
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
|
||||
it('empty window keeps the toolbar and reports no timing data', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots, [])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByText('No timing data')).toBeTruthy()
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('span derivation', () => {
|
||||
it('attributes prologue to turn 0 and follows steering turn tags', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1 },
|
||||
{ kind: 'steering', seq: 2, turn: 5 },
|
||||
{ kind: 'user', seq: 3 },
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const spans = deriveSpans(nodes)
|
||||
expect(spans).toEqual([
|
||||
{ turn: 0, steps: 0, calls: 0, nodes: 1 },
|
||||
{ turn: 5, steps: 0, calls: 0, nodes: 2 },
|
||||
])
|
||||
expect(deriveSpanStats(spans)).toEqual({ turns: 2, steps: 0, calls: 0 })
|
||||
describe('timeline projection', () => {
|
||||
const turns = [{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 1, kind: 'message', text: 'assistant', startedAt: 1_000, timeSeconds: 1 },
|
||||
{ index: 2, kind: 'tool', text: 'bash', startedAt: 2_000, timeSeconds: 1 },
|
||||
{ index: 3, kind: 'user', text: 'unknown', timeSeconds: 0 },
|
||||
],
|
||||
}],
|
||||
}] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
it('uses real start/duration timing and stable semantic lanes', () => {
|
||||
expect(deriveTrajectoryTimeline(turns)).toEqual({
|
||||
start: 1_000,
|
||||
end: 3_000,
|
||||
spans: [
|
||||
{
|
||||
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 1_000, end: 2_000,
|
||||
},
|
||||
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 2_000, end: 3_000 },
|
||||
],
|
||||
})
|
||||
expect(formatTimelineOffset(999)).toBe('999 ms')
|
||||
expect(formatTimelineOffset(1_500)).toBe('1.5 s')
|
||||
})
|
||||
|
||||
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
|
||||
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||
const { useSession } = fakeSession([])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
|
||||
expect(container.firstChild).toBeNull()
|
||||
it('filters inclusively and drops records without known timing', () => {
|
||||
const focused = filterTrajectoryTimelineRange(turns, { start: 2_000, end: 2_000 })
|
||||
expect(focused[0]?.groups[0]?.cells.map(cell => cell.index)).toEqual([1, 2])
|
||||
expect(filterTrajectoryTimelineRange(turns, null)).toBe(turns)
|
||||
})
|
||||
|
||||
it('empty inputs produce no model and the standalone view reports its empty form', () => {
|
||||
expect(deriveTrajectoryTimeline([])).toBeNull()
|
||||
render(createElement(
|
||||
TrajectoryView,
|
||||
{ ...standaloneProps([]), loadAllHistory: () => Promise.resolve() },
|
||||
@@ -343,135 +377,8 @@ describe('TrajectoryView branches', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('WaterfallView standalone branches', () => {
|
||||
it('empty window renders the placeholder copy', () => {
|
||||
render(createElement(WaterfallView as FC<ConvViewProps>,
|
||||
standaloneProps([])))
|
||||
expect(screen.getByText('No timing data')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a turn without tool calls renders the node bar only', () => {
|
||||
const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
|
||||
render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
|
||||
expect(screen.getByTitle('1 nodes')).toBeTruthy()
|
||||
expect(screen.queryByTitle(/tool calls/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('node half', () => {
|
||||
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSubSpans (waterfall lanes)', () => {
|
||||
const dispatchNodes = [
|
||||
{ kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
|
||||
call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
it('scales settled lanes into the dispatch window with real durations', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
|
||||
call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||
const turn3 = lanes.get(3)
|
||||
expect(turn3).toHaveLength(2)
|
||||
// Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
|
||||
expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
|
||||
expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
|
||||
expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
|
||||
expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
|
||||
})
|
||||
|
||||
it('a running lane extends to the window end with a null duration', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
|
||||
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
|
||||
// Extends from its start to the window end.
|
||||
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
|
||||
expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
|
||||
})
|
||||
|
||||
it('waterfall renders sub-span lanes under the owning turn row', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const store = createSnapshotStore({
|
||||
nodes: dispatchNodes, partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
|
||||
})
|
||||
const props = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const lane = view.container.querySelector('[data-subspan]')
|
||||
expect(lane).not.toBeNull()
|
||||
expect(lane!.textContent).toContain('bash')
|
||||
expect(lane!.querySelector('[title*="1.80 s"]')).not.toBeNull()
|
||||
expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('waterfall labels a settle-only lane as duration unknown', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'read', argsRaw: '{}' }, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const store = createSnapshotStore({
|
||||
nodes: dispatchNodes, partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
|
||||
})
|
||||
const props = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const bar = view.container.querySelector('[data-timing="unknown"]')
|
||||
expect(bar).not.toBeNull()
|
||||
expect(bar!.getAttribute('title')).toContain('duration unknown')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user