Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-trajectory
|
||||
|
||||
Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (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 turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Both views are placeholders by charter** — coarse span derivation with no visual acceptance bar; the real implementations, anchor deep-linking, and span-click selection handoff are the P-III project.
|
||||
- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Trajectory step cell — 38px row: index · kind tag · text · optional message
|
||||
* metrics · elapsed time. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 38px;
|
||||
padding: 0 8px 0 20px;
|
||||
gap: 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: transparent;
|
||||
box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.index {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tagSlot {
|
||||
flex: none;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 22px;
|
||||
max-width: 100%;
|
||||
padding: 0 4px;
|
||||
border-radius: 6px;
|
||||
font: var(--dsw-font-xs-strong-13);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tagUser {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
}
|
||||
|
||||
.tagMessage {
|
||||
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
background: var(--dsw-specific-bubble);
|
||||
}
|
||||
|
||||
.tagTool {
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Same column geometry as TrajectoryTurnHeader: 4×71 + 3×12 = 320. */
|
||||
.trailing {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 320px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.time {
|
||||
flex: none;
|
||||
width: 71px;
|
||||
text-align: left;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
99
packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
Normal file
99
packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// TrajectoryCell: one step row in the trajectory list — index, kind tag,
|
||||
// ellipsis text, optional Message token metrics, and own-duration time.
|
||||
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import css from './TrajectoryCell.module.css'
|
||||
|
||||
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */
|
||||
export type TrajectoryCellKind = 'user' | 'message' | 'tool'
|
||||
|
||||
/** Display label per kind (matches the design tags). */
|
||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
user: 'User',
|
||||
message: 'Message',
|
||||
tool: 'Tool',
|
||||
}
|
||||
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
||||
user: css.tagUser!,
|
||||
message: css.tagMessage!,
|
||||
tool: css.tagTool!,
|
||||
}
|
||||
|
||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 1-based step index shown as `#N`. */
|
||||
index: number
|
||||
kind: TrajectoryCellKind
|
||||
/** Single-line summary; CSS ellipsis when it overflows. */
|
||||
text: string
|
||||
/**
|
||||
* Own duration in seconds. `null` means no duration to show (em dash) —
|
||||
* used for in-flight tools and tools missing callTime.
|
||||
*/
|
||||
timeSeconds: number | null
|
||||
/** Message-only: prompt token count. */
|
||||
input?: number
|
||||
/** Message-only: completion token count. */
|
||||
output?: number
|
||||
/** Message-only: reasoning token count (usage column, not a Think cell). */
|
||||
think?: number
|
||||
/** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format own-duration for the trailing time column: `—` when unknown, `+Ns`
|
||||
* or `+N.1s` otherwise.
|
||||
* @param seconds - duration seconds, or null when absent.
|
||||
* @returns display string.
|
||||
*/
|
||||
export function formatElapsedSeconds(seconds: number | null): string {
|
||||
if (seconds === null || !Number.isFinite(seconds)) return '—'
|
||||
const rounded = Math.round(seconds * 10) / 10
|
||||
if (Number.isInteger(rounded)) return `+${rounded}s`
|
||||
return `+${rounded.toFixed(1)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one trajectory step cell.
|
||||
* @param props - index, kind, text, time, and optional Message metrics.
|
||||
* @returns the cell element.
|
||||
*/
|
||||
export function TrajectoryCell({
|
||||
index,
|
||||
kind,
|
||||
text,
|
||||
timeSeconds,
|
||||
input,
|
||||
output,
|
||||
think,
|
||||
selected = false,
|
||||
className,
|
||||
...rest
|
||||
}: TrajectoryCellProps) {
|
||||
const rootClass = [
|
||||
css.root,
|
||||
selected ? css.selected : undefined,
|
||||
className,
|
||||
].filter((c): c is string => c !== undefined).join(' ')
|
||||
const showMetrics = kind === 'message'
|
||||
return (
|
||||
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
|
||||
<span className={css.index}>#{index}</span>
|
||||
<span className={css.tagSlot}>
|
||||
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
|
||||
</span>
|
||||
<span className={css.text}>{text}</span>
|
||||
<span className={css.trailing}>
|
||||
{showMetrics ? (
|
||||
<>
|
||||
<span className={css.metric}>{input ?? ''}</span>
|
||||
<span className={css.metric}>{output ?? ''}</span>
|
||||
<span className={css.metric}>{think ?? ''}</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className={css.time}>{formatElapsedSeconds(timeSeconds)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Message / Step group title row inside a turn body. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// TrajectoryGroupHeader: "Message" or "Step N" row with optional description.
|
||||
|
||||
import css from './TrajectoryGroupHeader.module.css'
|
||||
|
||||
export interface TrajectoryGroupHeaderProps {
|
||||
/** Group title (`Message`, `Step 1`, …). */
|
||||
title: string
|
||||
/** Secondary summary (`49s`, `2.2s skill`, …). */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Message/Step group header inside a turn body.
|
||||
* @param props - title and optional description.
|
||||
* @returns the group header element.
|
||||
*/
|
||||
export function TrajectoryGroupHeader({ title, description }: TrajectoryGroupHeaderProps) {
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{description !== undefined && description !== ''
|
||||
? <span className={css.description}>{description}</span>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* One turn block: sticky header + padded body with 10px item gap. */
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 16px 22px;
|
||||
}
|
||||
26
packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx
Normal file
26
packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// TrajectoryTurn: sticky Turn header plus the padded Message/Step body.
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx'
|
||||
import css from './TrajectoryTurn.module.css'
|
||||
|
||||
export interface TrajectoryTurnProps {
|
||||
/** 1-based turn index for the sticky header. */
|
||||
turn: number
|
||||
/** Message / Step headers and TrajectoryCell rows. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one turn section (sticky header + body).
|
||||
* @param props - turn index and body children.
|
||||
* @returns the turn section element.
|
||||
*/
|
||||
export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) {
|
||||
return (
|
||||
<section className={css.root} data-turn={turn}>
|
||||
<TrajectoryTurnHeader turn={turn} />
|
||||
<div className={css.body}>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Sticky turn bar: full-bleed ghost-active fill across the panel; title +
|
||||
* metric labels sit in a centered 880 content lane (4×71 + 3×12 = 320). */
|
||||
|
||||
.root {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: var(--dsw-alias-button-ghost-active-fill);
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xs-strong-13);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.columns {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 320px;
|
||||
gap: 12px;
|
||||
/* Match cell padding-right: 8 so Time lines up with the trailing lane. */
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: none;
|
||||
width: 71px;
|
||||
text-align: left;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels.
|
||||
|
||||
import css from './TrajectoryTurnHeader.module.css'
|
||||
|
||||
const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const
|
||||
|
||||
export interface TrajectoryTurnHeaderProps {
|
||||
/** 1-based turn index shown as `Turn N`. */
|
||||
turn: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sticky turn header row.
|
||||
* @param props.turn - turn index.
|
||||
* @returns the sticky header element.
|
||||
*/
|
||||
export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) {
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.inner}>
|
||||
<span className={css.title}>Turn {turn}</span>
|
||||
<div className={css.columns} aria-hidden="true">
|
||||
{COLUMN_LABELS.map((label) => (
|
||||
<span key={label} className={css.column}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,40 @@
|
||||
// TrajectoryView: P-I placeholder body for the trajectory tab — span stats
|
||||
// header over a per-turn span list with node-count weights (no timing data
|
||||
// exists yet; deviation ledger #3 defers real rendering to P-III).
|
||||
// TrajectoryView: sticky Turn sections with Message/Step groups and step cells.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { deriveSpans } from './spans.ts'
|
||||
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
|
||||
import { TrajectoryCell } from './TrajectoryCell.tsx'
|
||||
import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from './TrajectoryTurn.tsx'
|
||||
import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
|
||||
const partial = useSession((s) => s.partial)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls }),
|
||||
[nodes, partial, runningCalls],
|
||||
)
|
||||
if (turns.length === 0) {
|
||||
return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TrajectoryStatsHeader useSession={useSession} />
|
||||
<div className={css.root}>
|
||||
{spans.map((span) => (
|
||||
<div key={span.turn} className={css.row}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span className={css.meta}>
|
||||
{span.steps} steps · {span.calls} calls · {span.nodes} nodes
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<div className={css.root}>
|
||||
{turns.map((turn) => (
|
||||
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
|
||||
{turn.groups.flatMap((group) => [
|
||||
<TrajectoryGroupHeader
|
||||
key={`${group.title}-h`}
|
||||
title={group.title}
|
||||
{...(group.description !== undefined ? { description: group.description } : {})}
|
||||
/>,
|
||||
...group.cells.map((cell) => (
|
||||
<TrajectoryCell key={cell.index} {...cell} />
|
||||
)),
|
||||
])}
|
||||
</TrajectoryTurn>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export const inject = ['slots', 'conversation']
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
* registrations ride the slot service's effect wrapper (plugin unload
|
||||
* removes both tabs); the span stats header renders inside each view body
|
||||
* (the chrome attachment mechanism retired with the view ring).
|
||||
* removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps
|
||||
* the span stats header inside its body (chrome attachment retired).
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
|
||||
418
packages/client/ui-trajectory/src/client/layout.ts
Normal file
418
packages/client/ui-trajectory/src/client/layout.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Trajectory list fold: expand assistant blocks, attach usage to Message,
|
||||
* own-duration times, in-flight partial/runningCalls, and group descriptions.
|
||||
*/
|
||||
import type {
|
||||
AssistantMessageNode,
|
||||
ConversationSnapshot,
|
||||
ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TrajectoryCellProps } from './TrajectoryCell.tsx'
|
||||
|
||||
/** One Message or Step group inside a turn. */
|
||||
export interface TrajectoryGroupModel {
|
||||
title: string
|
||||
description?: string
|
||||
cells: readonly TrajectoryCellProps[]
|
||||
}
|
||||
|
||||
/** One sticky-turn section. */
|
||||
export interface TrajectoryTurnModel {
|
||||
turn: number
|
||||
groups: readonly TrajectoryGroupModel[]
|
||||
}
|
||||
|
||||
/** Snapshot slice the trajectory view folds. */
|
||||
export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
|
||||
/** Cell plus absolute ms for group wall-span descriptions. */
|
||||
interface LaidCell {
|
||||
cell: TrajectoryCellProps
|
||||
absTime: number | null
|
||||
toolName?: string
|
||||
callId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a snapshot into turn → Message/Step groups with expanded cells.
|
||||
* @param input - nodes plus in-flight partial/runningCalls.
|
||||
* @returns turns ordered by first appearance.
|
||||
*/
|
||||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||||
const { nodes, partial, runningCalls } = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
|
||||
let index = 0
|
||||
let prevAbsTime: number | null = null
|
||||
let lastAssistantTurn: number | null = null
|
||||
|
||||
const bucket = (turn: number) => {
|
||||
let entry = turns.get(turn)
|
||||
if (entry === undefined) {
|
||||
entry = { message: [], steps: new Map() }
|
||||
turns.set(turn, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
const pushMessage = (turn: number, laid: LaidCell) => {
|
||||
bucket(turn).message.push(laid)
|
||||
}
|
||||
const pushStep = (turn: number, step: number, laid: LaidCell) => {
|
||||
const steps = bucket(turn).steps
|
||||
const list = steps.get(step) ?? []
|
||||
list.push(laid)
|
||||
steps.set(step, list)
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (node === undefined) continue
|
||||
if (node.kind === 'user' || node.kind === 'steering') {
|
||||
// user/message has no turn on the wire; enclose it in the next assistant
|
||||
// (or partial) turn, else open the turn after the last assistant.
|
||||
const turn = node.kind === 'steering'
|
||||
? node.turn
|
||||
: enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index, kind: 'user', text: summarizeContent(node.content),
|
||||
timeSeconds: 0,
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'assistant') {
|
||||
const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall)
|
||||
for (const laid of laidList) {
|
||||
if (node.step > 0) pushStep(node.turn, node.step, laid)
|
||||
else pushMessage(node.turn, laid)
|
||||
}
|
||||
const last = laidList[laidList.length - 1]
|
||||
if (last !== undefined) index = last.cell.index
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
lastAssistantTurn = node.turn
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'context') {
|
||||
// No trajectory cell, but the surface still advances the duration cursor.
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'tool-result') {
|
||||
if (!callEmittedInAssistant(nodes, node.callId)) {
|
||||
const toolName = node.call?.name
|
||||
pushStep(0, 1, {
|
||||
absTime: finiteTime(node.callTime ?? node.time),
|
||||
...(toolName !== undefined ? { toolName } : {}),
|
||||
callId: node.callId,
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
text: node.call !== null
|
||||
? summarizeCall(node.call.name, node.call.argsRaw)
|
||||
: summarizeResult(node),
|
||||
timeSeconds: durationSeconds(node.time, node.callTime),
|
||||
},
|
||||
})
|
||||
}
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
}
|
||||
}
|
||||
|
||||
if (partial !== null) {
|
||||
const fake: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
|
||||
turn: partial.turn, step: partial.step, blocks: partial.blocks,
|
||||
}
|
||||
const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true })
|
||||
for (const laid of laidList) {
|
||||
if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
|
||||
else pushMessage(partial.turn, laid)
|
||||
}
|
||||
const last = laidList[laidList.length - 1]
|
||||
if (last !== undefined) index = last.cell.index
|
||||
}
|
||||
|
||||
const seenCalls = collectCallIds(turns)
|
||||
for (const call of runningCalls) {
|
||||
if (seenCalls.has(call.callId)) continue
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, {
|
||||
absTime: null,
|
||||
toolName: call.name,
|
||||
callId: call.callId,
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
text: summarizeCall(call.name, call.argsRaw),
|
||||
timeSeconds: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
|
||||
const prologue = turns.get(0)
|
||||
if (prologue !== undefined) {
|
||||
turns.delete(0)
|
||||
const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({
|
||||
message: [],
|
||||
steps: new Map(),
|
||||
})
|
||||
const first = turns.get(1) ?? emptyTurn()
|
||||
first.message = [...prologue.message, ...first.message]
|
||||
for (const [step, cells] of prologue.steps) {
|
||||
const existing = first.steps.get(step) ?? []
|
||||
first.steps.set(step, [...cells, ...existing])
|
||||
}
|
||||
turns.set(1, first)
|
||||
}
|
||||
|
||||
return [...turns.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([turn, entry]) => toTurnModel(turn, entry))
|
||||
}
|
||||
|
||||
function toTurnModel(
|
||||
turn: number,
|
||||
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
|
||||
): TrajectoryTurnModel {
|
||||
const groups: TrajectoryGroupModel[] = []
|
||||
if (entry.message.length > 0) {
|
||||
const description = groupDescription(entry.message)
|
||||
groups.push({
|
||||
title: 'Message',
|
||||
...(description !== undefined ? { description } : {}),
|
||||
cells: entry.message.map(l => l.cell),
|
||||
})
|
||||
}
|
||||
for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) {
|
||||
const laid = entry.steps.get(step) ?? []
|
||||
const description = groupDescription(laid)
|
||||
groups.push({
|
||||
title: `Step ${step}`,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
cells: laid.map(l => l.cell),
|
||||
})
|
||||
}
|
||||
return { turn, groups }
|
||||
}
|
||||
|
||||
/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */
|
||||
function groupDescription(laid: readonly LaidCell[]): string | undefined {
|
||||
const parts: string[] = []
|
||||
// Tool rows contribute start (absTime) and end (start + own duration) so a
|
||||
// single Tool cell still spans call→result for the group wall clock.
|
||||
const times: number[] = []
|
||||
for (const l of laid) {
|
||||
if (l.absTime === null || !Number.isFinite(l.absTime)) continue
|
||||
times.push(l.absTime)
|
||||
if (l.cell.kind === 'tool' && l.cell.timeSeconds !== null && Number.isFinite(l.cell.timeSeconds)) {
|
||||
times.push(l.absTime + l.cell.timeSeconds * 1000)
|
||||
}
|
||||
}
|
||||
if (times.length >= 2) {
|
||||
const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000)
|
||||
if (span !== undefined) parts.push(span)
|
||||
} else if (times.length === 1) {
|
||||
const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds
|
||||
const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined
|
||||
if (span !== undefined) parts.push(span)
|
||||
}
|
||||
const tools = new Map<string, number>()
|
||||
for (const l of laid) {
|
||||
if (l.toolName === undefined || l.cell.kind !== 'tool') continue
|
||||
tools.set(l.toolName, (tools.get(l.toolName) ?? 0) + 1)
|
||||
}
|
||||
for (const [name, count] of tools) {
|
||||
parts.push(count > 1 ? `${name}×${count}` : name)
|
||||
}
|
||||
return parts.length === 0 ? undefined : parts.join(' ')
|
||||
}
|
||||
|
||||
function formatGroupDuration(seconds: number): string | undefined {
|
||||
if (!Number.isFinite(seconds)) return undefined
|
||||
const rounded = Math.round(seconds * 10) / 10
|
||||
if (Number.isInteger(rounded)) return `${rounded}s`
|
||||
return `${rounded.toFixed(1)}s`
|
||||
}
|
||||
|
||||
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
|
||||
function durationSeconds(later: number, earlier: number | null): number | null {
|
||||
if (earlier === null || !Number.isFinite(later) || !Number.isFinite(earlier)) return null
|
||||
return Math.max(0, (later - earlier) / 1000)
|
||||
}
|
||||
|
||||
/** Epoch-ms usable as an absolute time, else null. */
|
||||
function finiteTime(time: number): number | null {
|
||||
return Number.isFinite(time) ? time : null
|
||||
}
|
||||
|
||||
function expandAssistant(
|
||||
node: AssistantMessageNode,
|
||||
startIndex: number,
|
||||
prevAbsTime: number | null,
|
||||
results: Map<string, ToolResultNode>,
|
||||
opts?: { streaming?: boolean },
|
||||
): LaidCell[] {
|
||||
const out: LaidCell[] = []
|
||||
let index = startIndex - 1
|
||||
const usage = node.usage as UsageLike | undefined
|
||||
const streaming = opts?.streaming === true
|
||||
const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
|
||||
const nodeAbs = streaming ? null : finiteTime(node.time)
|
||||
let usageAttached = false
|
||||
|
||||
for (const block of node.blocks) {
|
||||
// Reasoning blocks are skipped: no block-level clock, so no Think cell.
|
||||
if (block.kind === 'reasoning') continue
|
||||
if (block.kind === 'text') {
|
||||
if (block.text === '' && streaming) continue
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index, kind: 'message', text: summarizeText(block.text),
|
||||
timeSeconds: messageDuration,
|
||||
}
|
||||
if (!usageAttached) {
|
||||
attachUsage(cell, usage)
|
||||
usageAttached = usage !== undefined
|
||||
}
|
||||
out.push({ absTime: nodeAbs, cell })
|
||||
continue
|
||||
}
|
||||
if (block.kind === 'tool-call') {
|
||||
const result = results.get(block.callId)
|
||||
const toolDuration = streaming || result === undefined
|
||||
? null
|
||||
: durationSeconds(result.time, result.callTime)
|
||||
const callAbs = streaming
|
||||
? null
|
||||
: (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime)
|
||||
? result.callTime
|
||||
: nodeAbs)
|
||||
out.push({
|
||||
absTime: callAbs,
|
||||
toolName: block.name,
|
||||
callId: block.callId,
|
||||
cell: {
|
||||
index: ++index, kind: 'tool',
|
||||
text: summarizeCall(block.name, block.argsRaw),
|
||||
timeSeconds: toolDuration,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (out.length === 0 && !streaming) {
|
||||
// Reasoning-only / empty success still owns provider usage on the Message row.
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index, kind: 'message', text: '', timeSeconds: messageDuration,
|
||||
}
|
||||
attachUsage(cell, usage)
|
||||
out.push({ absTime: nodeAbs, cell })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn that encloses a user/message: next assistant/steering turn, else the
|
||||
* in-flight partial, else the turn after the last finalized assistant (or 1).
|
||||
*/
|
||||
function enclosingUserTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
userIndex: number,
|
||||
partial: ConversationSnapshot['partial'],
|
||||
lastAssistantTurn: number | null,
|
||||
): number {
|
||||
for (let i = userIndex + 1; i < nodes.length; i++) {
|
||||
const n = nodes[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (n === undefined) continue
|
||||
if (n.kind === 'assistant' || n.kind === 'steering') return n.turn
|
||||
}
|
||||
if (partial !== null) return partial.turn
|
||||
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
|
||||
return 1
|
||||
}
|
||||
|
||||
/** Copy provider usage onto a Message cell when present. */
|
||||
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
|
||||
if (usage === undefined) return
|
||||
if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
|
||||
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
|
||||
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
|
||||
}
|
||||
|
||||
function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolResultNode> {
|
||||
const map = new Map<string, ToolResultNode>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'tool-result') map.set(node.callId, node)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant') continue
|
||||
if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function collectCallIds(
|
||||
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const entry of turns.values()) {
|
||||
for (const laid of entry.message) {
|
||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||
}
|
||||
for (const list of entry.steps.values()) {
|
||||
for (const laid of list) {
|
||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function summarizeCall(name: string, argsRaw: string): string {
|
||||
const args = argsRaw.replace(/\s+/g, ' ').trim()
|
||||
if (args === '') return name
|
||||
const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args
|
||||
return `${name} · ${clipped}`
|
||||
}
|
||||
|
||||
function summarizeResult(node: ToolResultNode): string {
|
||||
if (node.isError) {
|
||||
return node.error?.code ?? 'error'
|
||||
}
|
||||
for (const block of node.content) {
|
||||
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
|
||||
return summarizeText(block.text)
|
||||
}
|
||||
}
|
||||
return node.call?.name ?? node.callId
|
||||
}
|
||||
|
||||
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function summarizeText(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
@@ -1,25 +1,34 @@
|
||||
/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
|
||||
* cell content width is capped on the turn body (max 880). */
|
||||
.root {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* Waterfall placeholder rows (shared module). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
.turnTag {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.bar {
|
||||
@@ -29,9 +38,10 @@
|
||||
}
|
||||
|
||||
.barCalls {
|
||||
background: var(--dsw-alias-brand-primary);
|
||||
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
87
packages/client/ui-trajectory/tests/cell.spec.tsx
Normal file
87
packages/client/ui-trajectory/tests/cell.spec.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* TrajectoryCell presentation: kind tags, ellipsis-hosting text, Message
|
||||
* metric columns, own-duration formatting, and selected ring.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
formatElapsedSeconds,
|
||||
TrajectoryCell,
|
||||
type TrajectoryCellKind,
|
||||
} from '../src/client/TrajectoryCell.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('formatElapsedSeconds', () => {
|
||||
it('formats known durations and uses an em dash when absent', () => {
|
||||
expect(formatElapsedSeconds(null)).toBe('—')
|
||||
expect(formatElapsedSeconds(235)).toBe('+235s')
|
||||
expect(formatElapsedSeconds(235.0)).toBe('+235s')
|
||||
expect(formatElapsedSeconds(235.2)).toBe('+235.2s')
|
||||
expect(formatElapsedSeconds(235.25)).toBe('+235.3s')
|
||||
expect(formatElapsedSeconds(0)).toBe('+0s')
|
||||
expect(formatElapsedSeconds(Number.NaN)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryCell', () => {
|
||||
it('renders index, kind tag, text, and time for a Tool row', () => {
|
||||
render(
|
||||
<TrajectoryCell
|
||||
index={6}
|
||||
kind="tool"
|
||||
text="bash · Read src/index.ts"
|
||||
timeSeconds={5}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('#6')).toBeTruthy()
|
||||
expect(screen.getByText('Tool')).toBeTruthy()
|
||||
expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
|
||||
expect(screen.getByText('+5s')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Message rows expose Input / Output / Think metric columns before time', () => {
|
||||
const { container } = render(
|
||||
<TrajectoryCell
|
||||
index={3}
|
||||
kind="message"
|
||||
text="Let me now read the actual source files to understa..."
|
||||
timeSeconds={235.2}
|
||||
input={136}
|
||||
output={381}
|
||||
think={155}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Message')).toBeTruthy()
|
||||
expect(screen.getByText('136')).toBeTruthy()
|
||||
expect(screen.getByText('381')).toBeTruthy()
|
||||
expect(screen.getByText('155')).toBeTruthy()
|
||||
expect(screen.getByText('+235.2s')).toBeTruthy()
|
||||
const texts = [...container.querySelectorAll('span')].map((el) => el.textContent)
|
||||
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
|
||||
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
|
||||
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
|
||||
})
|
||||
|
||||
it('selected marks the row for the brand-primary inset ring', () => {
|
||||
const { container } = render(
|
||||
<TrajectoryCell index={15} kind="message" text="pictur..." timeSeconds={123.6} selected />,
|
||||
)
|
||||
expect(container.firstElementChild?.getAttribute('data-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['user', 'User'],
|
||||
['tool', 'Tool'],
|
||||
] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => {
|
||||
const { container } = render(
|
||||
<TrajectoryCell index={1} kind={kind} text="summary" timeSeconds={kind === 'user' ? 0 : null} input={1} output={2} think={3} />,
|
||||
)
|
||||
expect(screen.getByText(label)).toBeTruthy()
|
||||
expect(container.querySelector('[data-kind]')?.getAttribute('data-kind')).toBe(kind)
|
||||
expect(screen.queryByText('1')).toBeNull()
|
||||
expect(screen.queryByText('2')).toBeNull()
|
||||
expect(screen.queryByText('3')).toBeNull()
|
||||
})
|
||||
})
|
||||
206
packages/client/ui-trajectory/tests/layout.spec.tsx
Normal file
206
packages/client/ui-trajectory/tests/layout.spec.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Trajectory turn chrome and layout fold: expand blocks, usage on Message,
|
||||
* tool own-duration, group wall-span descriptions, in-flight rows.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
|
||||
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
|
||||
import { deriveTrajectoryLayout } from '../src/client/layout.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('TrajectoryTurnHeader', () => {
|
||||
it('renders Turn N and the four metric column labels', () => {
|
||||
render(<TrajectoryTurnHeader turn={1} />)
|
||||
expect(screen.getByText('Turn 1')).toBeTruthy()
|
||||
expect(screen.getByText('Input')).toBeTruthy()
|
||||
expect(screen.getByText('Output')).toBeTruthy()
|
||||
expect(screen.getByText('Think')).toBeTruthy()
|
||||
expect(screen.getByText('Time')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryGroupHeader', () => {
|
||||
it('renders title and optional description', () => {
|
||||
render(<TrajectoryGroupHeader title="Step 1" description="2.2s skill" />)
|
||||
expect(screen.getByText('Step 1')).toBeTruthy()
|
||||
expect(screen.getByText('2.2s skill')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the description node when absent', () => {
|
||||
const { container } = render(<TrajectoryGroupHeader title="Message" />)
|
||||
expect(screen.getByText('Message')).toBeTruthy()
|
||||
expect(container.querySelectorAll('span')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryTurn', () => {
|
||||
it('wraps a sticky header and body children', () => {
|
||||
render(
|
||||
<TrajectoryTurn turn={3}>
|
||||
<TrajectoryGroupHeader title="Message" description="49s" />
|
||||
</TrajectoryTurn>,
|
||||
)
|
||||
expect(screen.getByText('Turn 3')).toBeTruthy()
|
||||
expect(screen.getByText('Message')).toBeTruthy()
|
||||
expect(screen.getByText('49s')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveTrajectoryLayout', () => {
|
||||
it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'reasoning', text: 'thinking…' },
|
||||
{ kind: 'text', text: 'I will run bash' },
|
||||
{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' },
|
||||
],
|
||||
usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 },
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
|
||||
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
expect(turns).toHaveLength(1)
|
||||
expect(turns[0]?.turn).toBe(1)
|
||||
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
|
||||
expect(kinds).toEqual(['user', 'message', 'tool'])
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
input: 10, output: 20, think: 5, timeSeconds: 5,
|
||||
})
|
||||
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
|
||||
expect(tool?.text).toBe('bash · {"command":"ls"}')
|
||||
expect(tool?.timeSeconds).toBe(1.3)
|
||||
})
|
||||
|
||||
it('adds runningCalls not already present and leaves their time blank', () => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
nodes: [] as unknown as ConversationSnapshot['nodes'],
|
||||
partial: null,
|
||||
runningCalls: [{
|
||||
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
||||
turn: 1, step: 2, time: 9_000, callView: null,
|
||||
}],
|
||||
})
|
||||
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
|
||||
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
|
||||
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits duration when node times are missing instead of rendering NaN', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'reasoning', text: '…' },
|
||||
{ kind: 'text', text: 'ok' },
|
||||
],
|
||||
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
|
||||
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds a wall-span step description with a tool histogram', () => {
|
||||
const nodes = [
|
||||
{
|
||||
kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' },
|
||||
{ kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
|
||||
})
|
||||
|
||||
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'ok1' }],
|
||||
},
|
||||
{ kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'ok2' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
expect(turns.map((t) => t.turn)).toEqual([1, 2])
|
||||
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
|
||||
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
|
||||
})
|
||||
|
||||
it('keeps usage on the fallback Message row when assistant has no text block', () => {
|
||||
const nodes = [
|
||||
{
|
||||
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'reasoning', text: '…' }],
|
||||
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
text: '', input: 11, output: 22, think: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('advances the duration cursor over context nodes', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }],
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'context', seq: 4, time: 9_000,
|
||||
content: [{ type: 'text', text: 'extra' }], source: null,
|
||||
},
|
||||
{
|
||||
kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'done' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups
|
||||
.flatMap((g) => g.cells)
|
||||
.find((c) => c.kind === 'message' && c.text === 'done')
|
||||
// From context at 9s, not from the earlier user/tool surfaces.
|
||||
expect(message?.timeSeconds).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -3,9 +3,9 @@
|
||||
* View registration acceptance on the real framework stack: the plugin fiber
|
||||
* registers trajectory/waterfall into a real SlotsService view ring, tabs
|
||||
* switch inside ConversationRoot (renderSlot share driven by the same tab
|
||||
* projection apply uses) without collapsing chat, the span stats header
|
||||
* renders inside both view bodies, and fiber disposal removes both tabs.
|
||||
* Span derivation edge cases ride along.
|
||||
* projection apply uses) without collapsing chat, trajectory renders the
|
||||
* turn-list chrome (no span stats bar), waterfall keeps in-body stats, and
|
||||
* fiber disposal removes both tabs. Span derivation edge cases ride along.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -36,19 +36,26 @@ afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear so one case's active
|
||||
// view cannot rehydrate into the next.
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// Node 22+ exposes an experimental localStorage global that is undefined
|
||||
// without --localstorage-file; only clear when a real Storage is present.
|
||||
if (typeof localStorage !== 'undefined') localStorage.clear()
|
||||
})
|
||||
|
||||
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
|
||||
const NODES = [
|
||||
{ kind: 'user', seq: 1, content: [], source: null },
|
||||
{ kind: 'assistant', seq: 2, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'tool-result', seq: 3, callId: 'c1', call: null, content: [], isError: false, callView: null, resultView: null },
|
||||
{ kind: 'assistant', seq: 4, turn: 2, step: 1, blocks: [] },
|
||||
{ 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,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{ kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
|
||||
const store = createSnapshotStore({
|
||||
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
@@ -99,8 +106,9 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
|
||||
/** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
const chat = createChatStore().create()
|
||||
@@ -162,17 +170,20 @@ describe('plugin registration', () => {
|
||||
})
|
||||
|
||||
describe('tab switching in ConversationRoot', () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
// In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call.
|
||||
expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy()
|
||||
expect(screen.getByText('turn 0')).toBeTruthy()
|
||||
expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy()
|
||||
// Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body.
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
expect(screen.getByText('Turn 1')).toBeTruthy()
|
||||
expect(screen.getByText('Turn 2')).toBeTruthy()
|
||||
expect(screen.getAllByText('Message').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Input').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByTestId('chat-body')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user