Merge PR #500 into CI optimization

This commit is contained in:
Tianyi Cui
2026-07-22 18:31:28 +08:00
382 changed files with 33688 additions and 419 deletions

View File

@@ -0,0 +1,15 @@
# @deepseek-ai/dsh-client-ui-trajectory
Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (registers two views, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
## Model Experience
None, as the trajectory views render session data in the browser; nothing here reaches a model request.
#### KV Cache effect
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.

View File

@@ -0,0 +1,59 @@
{
"name": "@deepseek-ai/dsh-client-ui-trajectory",
"description": "Trajectory/Waterfall placeholder views: pure-consumer plugin registering into the conversation ViewMap (no service)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,7 @@
.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);
}

View File

@@ -0,0 +1,28 @@
// TrajectoryStatsHeader: span totals row mounted as chrome.header on both
// placeholder views — the second chrome-attachment consumer (chat's
// StatsLine footer is the first), proving both mount points render.
// 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-web-react'
import type { ChromeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans, deriveSpanStats } from './spans.ts'
import css from './TrajectoryStatsHeader.module.css'
/** Per-view chrome extension (the view map entry's chromeProps slot). */
export interface TrajectoryChromeProps {
/** Render the tool-calls segment; defaults to true (waterfall lanes already
* visualize calls, so that view may drop the redundant count). */
showCalls?: boolean
}
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession, showCalls }: ChromeProps & TrajectoryChromeProps) {
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
if (stats.turns === 0) return null
const parts = [`${stats.turns} turns`, `${stats.steps} steps`]
if (showCalls !== false) parts.push(`${stats.calls} tool calls`)
return <div className={css.root}>{parts.join(' · ')}</div>
})

View File

@@ -0,0 +1,28 @@
// TrajectoryView: P-I placeholder body for the trajectory tab — per-turn
// span list with node-count weights (no timing data exists yet; deviation
// ledger #3 defers real rendering to P-III).
import { useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
import css from './views.module.css'
export function TrajectoryView({ useSession }: ConvViewProps) {
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}></p></div>
return (
<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>
)
}

View File

@@ -0,0 +1,49 @@
// WaterfallView: P-I placeholder body for the waterfall tab — node-count
// bars per turn stand in for duration lanes (no timing data yet; deviation
// ledger #3 defers real rendering to P-III).
import { useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
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
/** Per-view extension merged into the waterfall body's props through the
* conversation view map ({ extraProps? } entry slot). */
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 as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}></p></div>
return (
<div className={css.root}>
{spans.map((span, i) => (
<div key={span.turn} 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>
))}
</div>
)
}

View File

@@ -0,0 +1,46 @@
/**
* Trajectory/Waterfall plugin, browser half: merges ConversationViewMap and
* registers the two placeholder views. Pure consumer — no ctx service, no
* Context declaration merge; the minimal-plugin exemplar. Contract:
* api-contracts v3 section 8.
*/
import type { Context } from 'cordis'
import { TrajectoryStatsHeader, type TrajectoryChromeProps } from './TrajectoryStatsHeader.tsx'
import { TrajectoryView } from './TrajectoryView.tsx'
import { WaterfallView, type WaterfallExtraProps } from './WaterfallView.tsx'
export { deriveSpans, deriveSpanStats, type SpanStats, type TurnSpan } from './spans.ts'
export { TrajectoryStatsHeader, type TrajectoryChromeProps } from './TrajectoryStatsHeader.tsx'
export { TrajectoryView } from './TrajectoryView.tsx'
export { WaterfallView, type WaterfallExtraProps } from './WaterfallView.tsx'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ConversationViewMap {
// Per-view extension shapes merged through the map (view-ring design):
// the stats header's chrome props ride both entries; the waterfall body
// additionally takes its lane-density extra. P-III widens these.
trajectory: { chromeProps: TrajectoryChromeProps }
waterfall: { chromeProps: TrajectoryChromeProps; extraProps: WaterfallExtraProps }
}
}
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['conversation']
/**
* Client plugin body: register the trajectory and waterfall views. The
* registrations are effects on this fiber (plugin unload removes both tabs).
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
// chrome.header on both views: the second chrome-attachment consumer
// (chat's footer StatsLine is the first) — proves both mount points live.
ctx.conversation.registerView({
id: 'trajectory', label: 'Trajectory', order: 10,
component: TrajectoryView, chrome: { header: TrajectoryStatsHeader },
})
ctx.conversation.registerView({
id: 'waterfall', label: 'Waterfall', order: 20,
component: WaterfallView, chrome: { header: TrajectoryStatsHeader },
})
}

View File

@@ -0,0 +1,71 @@
/**
* 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 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'
}

View File

@@ -0,0 +1,37 @@
.root {
padding: 16px;
overflow-y: auto;
color: var(--dsw-alias-label-primary);
font-size: 13px;
}
.empty {
color: var(--dsw-alias-label-tertiary);
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.turnTag {
flex: none;
width: 64px;
color: var(--dsw-alias-label-secondary);
}
.bar {
height: 12px;
border-radius: 4px;
background: var(--dsw-alias-bg-skeleton);
}
.barCalls {
background: var(--dsw-alias-brand-primary);
}
.meta {
color: var(--dsw-alias-label-caption);
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,10 @@
/**
* Trajectory plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 8.
*/
/** Host plugin body — no host-side behavior for the trajectory plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-trajectory`.
* @module @deepseek-ai/dsh-client-ui-trajectory/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-trajectory'
/** Cordis companion plugin name. */
export const name = 'client-ui-trajectory-invariant'
/** Service required before the companion can reserve package ownership. */
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 registrations are plain
* effects whose disposal the conversation registry's own specs and this
* package's behavior specs observe directly.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,81 @@
// @vitest-environment jsdom
/**
* Real tsdown artifact shape: lib/client.js hands off through
* window.DSHClientProxy.loadPlugin, resolves externals through the injected
* require, returns the export surface (apply + inject), and a mounted apply
* registers both views into a real ConversationService. Skips when dist/ is
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
interface Handoff { id: string; factory: (require: (spec: string) => unknown) => Record<string, unknown> }
type Win = { DSHClientProxy?: { loadPlugin(h: Handoff): void } }
function readBundle(): string | undefined {
try {
// import.meta.url is http-scheme in the jsdom pool; vitest runs from the
// repo root, so resolve the artifact repo-relatively instead.
return readFileSync(resolve('packages/client/ui-trajectory/lib/client.js'), 'utf8')
} catch {
return undefined
}
}
afterEach(() => {
delete (window as Win).DSHClientProxy
for (const el of document.querySelectorAll('style')) el.remove()
})
describe('tsdown client artifact', () => {
const code = readBundle()
async function loadArtifact() {
let handoff: Handoff | undefined
;(window as Win).DSHClientProxy = { loadPlugin: (h) => { handoff = h } }
// Same execution form the loader uses (inline script eval, window scope) —
// the implied-eval ban targets accidental string execution, not this
// deliberate bundle-execution fixture.
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
new Function(code!)()
expect(handoff).toBeDefined()
const modules = new Map<string, unknown>([
['react', await import('react')],
['react/jsx-runtime', await import('react/jsx-runtime')],
])
const surface = handoff!.factory((spec) => {
if (!modules.has(spec)) throw new Error(`unexpected require: ${spec}`)
return modules.get(spec)
})
return { handoff: handoff!, surface }
}
it.skipIf(code === undefined)('hands off with the manifest id and a DI-require factory', async () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
expect(surface.inject).toEqual(['conversation'])
})
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both views on the real service', async () => {
const { surface } = await loadArtifact()
const ctx = new Context()
const svc = new ConversationService(ctx)
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()
expect(svc.views().map(v => v.id)).toEqual(['trajectory', 'waterfall'])
await fiber.dispose()
expect(svc.views()).toHaveLength(0)
})
it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => {
await loadArtifact()
const tags = document.querySelectorAll(`style[data-plugin=${JSON.stringify(PLUGIN_ID)}]`)
expect(tags.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,197 @@
// @vitest-environment jsdom
/**
* View registration acceptance on the real framework stack: the plugin fiber
* registers trajectory/waterfall into a real ConversationService, tabs switch
* inside ConversationRoot without collapsing chat, chrome.header renders the
* span stats bar, and fiber disposal removes both tabs. Span derivation edge
* cases ride along.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, Fragment, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationRoot, ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import {
apply, deriveSpans, deriveSpanStats, inject, TrajectoryStatsHeader, TrajectoryView, WaterfallView,
} from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
const SID = 's1' as SessionId
afterEach(cleanup)
/** 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: [] },
] as unknown as ConversationSnapshot['nodes']
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
}
/** Real-stack bench: root Context + real ConversationService + the plugin fiber. */
async function bench() {
const ctx = new Context()
const svc = new ConversationService(ctx)
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
svc.registerView({ id: 'chat' as ViewId, label: 'Chat', order: 0, component: chatBody as unknown as FC<ConvViewProps> })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, svc, fiber }
}
/** Mount ConversationRoot over the service's registry face, rendering chrome like the conversation apply does. */
function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = NODES) {
const { useSession } = fakeSession(nodes)
const activeStore = createSnapshotStore<string | undefined>(undefined)
const ancestry: SessionSummary[] = [{ id: SID, title: 'self', running: false, updatedAt: 1 }]
const viewProps = {
sessionId: SID, useSession,
useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
slots: undefined,
} as unknown as ConvViewProps
const renderView = (entry: ViewEntry): ReactNode => {
const children: ReactNode[] = []
if (entry.chrome?.header !== undefined) {
children.push(createElement(entry.chrome.header, { key: 'h', sessionId: SID, useSession }))
}
children.push(createElement(entry.component, { key: 'b', ...viewProps }))
if (entry.chrome?.footer !== undefined) {
children.push(createElement(entry.chrome.footer, { key: 'f', sessionId: SID, useSession }))
}
return createElement(Fragment, null, children)
}
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
running: false, removed: false, promptError: null, nodes,
})
return render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession}
useAncestry={() => ancestry}
views={{
list: () => svc.views(),
subscribe: (fn) => svc.subscribeViews(fn),
version: () => svc.viewsVersion(),
}}
useActiveView={() => activeStore.useSelector((s) => s) as ViewId | undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: ((v: string) => { activeStore.set(v) }) as (v: never) => void, open: vi.fn() }}
renderView={renderView}
/>,
)
}
describe('plugin registration', () => {
it('registers trajectory and waterfall after chat, both with header chrome', async () => {
const b = await bench()
const views = b.svc.views()
expect(views.map((v) => v.id)).toEqual(['chat', 'trajectory', 'waterfall'])
expect(views[1]?.chrome?.header).toBeDefined()
expect(views[2]?.chrome?.header).toBeDefined()
expect(views[1]?.chrome?.footer).toBeUndefined()
})
it('fiber disposal removes both tabs and leaves chat standing', async () => {
const b = await bench()
await b.fiber.dispose()
expect(b.svc.views().map((v) => v.id)).toEqual(['chat'])
})
})
describe('tab switching in ConversationRoot', () => {
it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => {
const b = await bench()
mount(b.svc)
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' }))
// chrome.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()
expect(screen.queryByTestId('chat-body')).toBeNull()
})
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
const b = await bench()
mount(b.svc)
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: 'Chat' }))
expect(screen.getByTestId('chat-body')).toBeTruthy()
})
it('empty window: placeholder copy in the body, header chrome renders nothing', async () => {
const b = await bench()
mount(b.svc, [] as unknown as ConversationSnapshot['nodes'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
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 })
})
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
const { container } = render(createElement(TrajectoryStatsHeader, { sessionId: SID, useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>, {
sessionId: SID, useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
})
})
describe('WaterfallView standalone branches', () => {
const props = (nodes: ConversationSnapshot['nodes']) => ({
sessionId: SID, useSession: fakeSession(nodes).useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps)
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>, props([] as unknown as ConversationSnapshot['nodes'])))
expect(screen.getByText('暂无瀑布数据')).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>, props(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()).toBeUndefined()
})
})

View File

@@ -0,0 +1,34 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-conversation"
},
{
"path": "../web-react"
},
{
"path": "../runtime"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-trajectory', ['lib/types/index.js', 'lib/types/invariant.js'])