mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into mergebot/pr908
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-trajectory/src/client/views.module.css
This commit is contained in:
@@ -46,14 +46,40 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tagSystem {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.tagUser {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
}
|
||||
|
||||
.tagContext {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-success-primary) 68%,
|
||||
var(--dsw-alias-label-secondary)
|
||||
);
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
}
|
||||
|
||||
.tagMessage {
|
||||
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
background: var(--dsw-specific-bubble);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
|
||||
var(--dsw-alias-state-error-secondary)
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 55%,
|
||||
var(--dsw-alias-state-error-secondary)
|
||||
) 15%,
|
||||
var(--dsw-alias-bg-layer-1)
|
||||
);
|
||||
}
|
||||
|
||||
.tagTool {
|
||||
@@ -64,8 +90,16 @@
|
||||
/* run_code sub-dispatch cells: the business tint plus an indent so the
|
||||
nesting under the parent Tool cell reads at a glance. */
|
||||
.tagSubtool {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
background: var(--dsw-alias-state-business-tertiary);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-label) 62%,
|
||||
var(--dsw-alias-label-tertiary)
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-tertiary) 58%,
|
||||
var(--dsw-alias-bg-layer-1)
|
||||
);
|
||||
}
|
||||
|
||||
.root[data-kind='subtool'] {
|
||||
|
||||
@@ -1,62 +1,40 @@
|
||||
// TrajectoryCell: one step row in the trajectory list — index, kind tag,
|
||||
// ellipsis text, optional Message token metrics, and own-duration time.
|
||||
// Legacy standalone trajectory cell retained for direct consumers and specs.
|
||||
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import {
|
||||
formatElapsedSeconds,
|
||||
type TrajectoryCellKind,
|
||||
type TrajectoryCellProps,
|
||||
} from './trajectory-record.ts'
|
||||
import css from './TrajectoryCell.module.css'
|
||||
|
||||
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think;
|
||||
* subtool = one run_code sub-dispatch nested under its Tool cell). */
|
||||
export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
|
||||
export { formatElapsedSeconds }
|
||||
export type {
|
||||
AssistantMetricDetail,
|
||||
TrajectoryCellKind,
|
||||
TrajectoryCellProps,
|
||||
} from './trajectory-record.ts'
|
||||
|
||||
/** Display label per kind (matches the design tags). */
|
||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
system: 'System',
|
||||
user: 'User',
|
||||
context: 'Context',
|
||||
compacted: 'Compacted',
|
||||
message: 'Message',
|
||||
tool: 'Tool',
|
||||
subtool: 'Sub',
|
||||
}
|
||||
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
|
||||
system: css.tagSystem,
|
||||
user: css.tagUser,
|
||||
context: css.tagContext,
|
||||
compacted: css.tagSystem,
|
||||
message: css.tagMessage,
|
||||
tool: css.tagTool,
|
||||
subtool: css.tagSubtool,
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -66,7 +44,20 @@ export function TrajectoryCell({
|
||||
index,
|
||||
kind,
|
||||
text,
|
||||
inputDetail: _inputDetail,
|
||||
promptDetail: _promptDetail,
|
||||
previousPromptDetail: _previousPromptDetail,
|
||||
outputDetail: _outputDetail,
|
||||
thinkingDetail: _thinkingDetail,
|
||||
sourceBlocks: _sourceBlocks,
|
||||
outputBlocks: _outputBlocks,
|
||||
schemaDetail: _schemaDetail,
|
||||
assistantMetrics: _assistantMetrics,
|
||||
result: _result,
|
||||
callId: _callId,
|
||||
isError: _isError,
|
||||
timeSeconds,
|
||||
startedAt: _startedAt,
|
||||
input,
|
||||
output,
|
||||
think,
|
||||
|
||||
@@ -5,7 +5,7 @@ import css from './TrajectoryGroupHeader.module.css'
|
||||
export interface TrajectoryGroupHeaderProps {
|
||||
/** Group title (`Message`, `Step 1`, …). */
|
||||
title: string
|
||||
/** Secondary summary (`49s`, `2.2s skill`, …). */
|
||||
/** Secondary summary (`49 s`, `2.2 s skill`, …). */
|
||||
description?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
})
|
||||
1487
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
Normal file
1487
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
Normal file
File diff suppressed because it is too large
Load Diff
2329
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
Normal file
2329
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
.root {
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.plot {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.labels {
|
||||
position: relative;
|
||||
border-right: 1px solid var(--dsw-alias-border-l1);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.labels span {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.labels span:nth-child(1) {
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.labels span:nth-child(2) {
|
||||
top: 21px;
|
||||
}
|
||||
|
||||
.labels span:nth-child(3) {
|
||||
top: 35px;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.track:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.lanes {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 7px 0;
|
||||
}
|
||||
|
||||
.turnBoundaries {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.turnBoundary {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--trajectory-turn-left);
|
||||
width: 1px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.span {
|
||||
position: absolute;
|
||||
top: calc(var(--trajectory-span-lane) * 14px);
|
||||
left: calc(var(--trajectory-span-left) + 1px);
|
||||
width: max(2px, calc(var(--trajectory-span-width) - 2px));
|
||||
height: 8px;
|
||||
min-width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-secondary);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.span[data-timeline-span='user'] {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='context'] {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-success-primary) 68%,
|
||||
var(--dsw-alias-label-secondary)
|
||||
);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='message'] {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
|
||||
var(--dsw-alias-state-error-secondary)
|
||||
);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='tool'] {
|
||||
background: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.span[data-timeline-span='subtool'] {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-label) 62%,
|
||||
var(--dsw-alias-label-tertiary)
|
||||
);
|
||||
}
|
||||
|
||||
.span[data-equal-duration='true'] {
|
||||
width: 8px;
|
||||
min-width: 8px;
|
||||
}
|
||||
|
||||
.span[data-selected='false'] {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.span[data-current='true'] {
|
||||
z-index: 1;
|
||||
opacity: 1;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--dsw-alias-bg-layer-2),
|
||||
0 0 0 2px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.span[data-search-match='false'] {
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.selection {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--trajectory-selection-left);
|
||||
width: var(--trajectory-selection-width);
|
||||
min-width: 1px;
|
||||
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;
|
||||
}
|
||||
|
||||
.selectionEdges {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--trajectory-selection-left);
|
||||
width: var(--trajectory-selection-width);
|
||||
min-width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hoverLine {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: clamp(
|
||||
0px,
|
||||
calc(var(--trajectory-hover-left) - 1px),
|
||||
calc(100% - 2px)
|
||||
);
|
||||
width: 2px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selectionEdges::before,
|
||||
.selectionEdges::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.selectionEdges::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.selectionEdges::after {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.selectionEdges[data-dragging='true']::before,
|
||||
.selectionEdges[data-dragging='true']::after {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.selection[data-dragging='true'] {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-business-primary) 18%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
372
packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx
Normal file
372
packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
|
||||
|
||||
import {
|
||||
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
|
||||
type PointerEvent, type WheelEvent,
|
||||
} from 'react'
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import {
|
||||
deriveTrajectoryTimeline,
|
||||
formatTimelineOffset,
|
||||
type TrajectoryTimelineMode,
|
||||
type TrajectoryTimeRange,
|
||||
} from './timeline.ts'
|
||||
import css from './TrajectoryTimeline.module.css'
|
||||
|
||||
const MINIMUM_DRAG_PX = 3
|
||||
const MINIMUM_ZOOM_OPERATIONS = 4
|
||||
|
||||
interface FractionRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Props for the fixed full-domain overview above the trajectory ledger. */
|
||||
export interface TrajectoryTimelineProps {
|
||||
turns: readonly TrajectoryTurnModel[]
|
||||
mode: TrajectoryTimelineMode
|
||||
range: TrajectoryTimeRange | null
|
||||
selectedIndex?: number | null
|
||||
/** Record indexes matching the active ledger search, or null without a query. */
|
||||
searchMatchIndexes?: ReadonlySet<number> | null
|
||||
onRangeChange: (range: TrajectoryTimeRange | null) => void
|
||||
onRecordFocus?: (index: number) => 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 centeredRange(center: number, width: number): FractionRange {
|
||||
const clampedWidth = Math.min(1, Math.max(0, width))
|
||||
const start = Math.min(
|
||||
Math.max(center - clampedWidth / 2, 0),
|
||||
1 - clampedWidth,
|
||||
)
|
||||
return { start, end: start + clampedWidth }
|
||||
}
|
||||
|
||||
function rangeFraction(
|
||||
range: TrajectoryTimeRange,
|
||||
start: number,
|
||||
duration: number,
|
||||
): FractionRange {
|
||||
return orderedRange(
|
||||
clampFraction((range.start - start) / duration),
|
||||
clampFraction((range.end - start) / duration),
|
||||
)
|
||||
}
|
||||
|
||||
function LaneLabels() {
|
||||
return (
|
||||
<div className={css.labels} aria-hidden="true">
|
||||
<span>Input</span>
|
||||
<span>Model</span>
|
||||
<span>Tools</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
|
||||
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
turns,
|
||||
mode,
|
||||
range,
|
||||
selectedIndex = null,
|
||||
searchMatchIndexes = null,
|
||||
onRangeChange,
|
||||
onRecordFocus,
|
||||
}: TrajectoryTimelineProps) {
|
||||
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
|
||||
const durationByIndex = useMemo(
|
||||
() => new Map(turns.flatMap(turn =>
|
||||
turn.groups.flatMap(group =>
|
||||
group.cells.flatMap(cell =>
|
||||
cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
|
||||
? []
|
||||
: [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const],
|
||||
),
|
||||
),
|
||||
)),
|
||||
[turns],
|
||||
)
|
||||
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
|
||||
const [draft, setDraft] = useState<FractionRange | null>(null)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
||||
useEffect(() => {
|
||||
if (
|
||||
model !== null
|
||||
&& range !== null
|
||||
&& (range.end < model.start || range.start > model.end)
|
||||
) {
|
||||
onRangeChange(null)
|
||||
}
|
||||
}, [model, onRangeChange, range])
|
||||
useEffect(() => {
|
||||
if (model === null) return
|
||||
setViewport(current =>
|
||||
current !== null && (current.end < model.start || current.start > model.end)
|
||||
? null
|
||||
: current)
|
||||
}, [model])
|
||||
const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
|
||||
const viewportDuration = Math.min(
|
||||
fullDuration,
|
||||
Math.max(1, (viewport?.end ?? 0) - (viewport?.start ?? 0)),
|
||||
)
|
||||
const viewportStart = model === null || viewport === null
|
||||
? model?.start ?? 0
|
||||
: Math.min(
|
||||
Math.max(viewport.start, model.start),
|
||||
model.end - viewportDuration,
|
||||
)
|
||||
const domainDuration = viewport === null ? fullDuration : viewportDuration
|
||||
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
|
||||
const committed = model === null || range === null
|
||||
? null
|
||||
: rangeFraction(range, domainStart, domainDuration)
|
||||
const visibleRange = draft ?? committed
|
||||
const activeRange = draft === null
|
||||
? range
|
||||
: {
|
||||
start: domainStart + draft.start * domainDuration,
|
||||
end: domainStart + draft.end * domainDuration,
|
||||
}
|
||||
|
||||
if (model === null) {
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.plot}>
|
||||
<LaneLabels />
|
||||
<div className={css.track}>
|
||||
<span className={css.empty}>No timing data</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const minimumSelectionFraction = Math.min(
|
||||
1,
|
||||
fullDuration / domainDuration / model.spans.length,
|
||||
)
|
||||
|
||||
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: domainStart + fraction.start * domainDuration,
|
||||
end: domainStart + fraction.end * domainDuration,
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const anchor = fractionAt(event)
|
||||
setHover(anchor)
|
||||
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
|
||||
const fraction = fractionAt(event)
|
||||
setHover(fraction)
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
setDraft(orderedRange(drag.anchor, fraction))
|
||||
}
|
||||
|
||||
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return
|
||||
const point = fractionAt(event)
|
||||
const selected = orderedRange(drag.anchor, point)
|
||||
setHover(point)
|
||||
dragRef.current = null
|
||||
setDraft(null)
|
||||
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
|
||||
const committedRange = selected.end - selected.start < minimumSelectionFraction
|
||||
? centeredRange(
|
||||
click ? selected.start : (selected.start + selected.end) / 2,
|
||||
minimumSelectionFraction,
|
||||
)
|
||||
: selected
|
||||
commit(committedRange)
|
||||
if (click) {
|
||||
const timelinePoint = domainStart + selected.start * domainDuration
|
||||
const nearest = model.spans.reduce((candidate, span) => {
|
||||
const candidateDistance = timelinePoint < candidate.start
|
||||
? candidate.start - timelinePoint
|
||||
: timelinePoint > candidate.end ? timelinePoint - candidate.end : 0
|
||||
const spanDistance = timelinePoint < span.start
|
||||
? span.start - timelinePoint
|
||||
: timelinePoint > span.end ? timelinePoint - span.end : 0
|
||||
return spanDistance < candidateDistance ? span : candidate
|
||||
})
|
||||
onRecordFocus?.(nearest.index)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Escape' || range === null) return
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
}
|
||||
|
||||
const onPointerCancel = () => {
|
||||
dragRef.current = null
|
||||
setDraft(null)
|
||||
setHover(null)
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const anchorFraction =
|
||||
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||
const nextDuration = Math.min(
|
||||
fullDuration,
|
||||
Math.max(
|
||||
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
|
||||
domainDuration * Math.exp(event.deltaY * 0.0015),
|
||||
),
|
||||
)
|
||||
if (nextDuration >= fullDuration * 0.999) {
|
||||
setViewport(null)
|
||||
return
|
||||
}
|
||||
const anchorTime = domainStart + anchorFraction * domainDuration
|
||||
const nextStart = Math.min(
|
||||
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
|
||||
model.end - nextDuration,
|
||||
)
|
||||
setViewport({ start: nextStart, end: nextStart + nextDuration })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.plot}>
|
||||
<LaneLabels />
|
||||
<div
|
||||
className={css.track}
|
||||
aria-label="Timeline overview; drag horizontally to focus events"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerEnd}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onPointerLeave={() => {
|
||||
if (dragRef.current === null) setHover(null)
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
setViewport(null)
|
||||
}}
|
||||
>
|
||||
{hover !== null && draft === null && (
|
||||
<div
|
||||
className={css.hoverLine}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
'--trajectory-hover-left': `${hover * 100}%`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
className={css.selectionEdges}
|
||||
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 className={css.turnBoundaries} aria-hidden="true">
|
||||
{model.turnBoundaries
|
||||
.slice(1)
|
||||
.filter(boundary =>
|
||||
boundary.time >= domainStart
|
||||
&& boundary.time <= domainStart + domainDuration)
|
||||
.map(boundary => (
|
||||
<span
|
||||
className={css.turnBoundary}
|
||||
data-turn={boundary.turn}
|
||||
key={boundary.turn}
|
||||
style={{
|
||||
'--trajectory-turn-left':
|
||||
`${(boundary.time - domainStart) / domainDuration * 100}%`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.lanes} aria-hidden="true">
|
||||
{model.spans
|
||||
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
|
||||
.map((span) => {
|
||||
const left = (span.start - domainStart) / domainDuration
|
||||
const width = (span.end - span.start) / domainDuration
|
||||
const durationMs = durationByIndex.get(span.index)
|
||||
return (
|
||||
<span
|
||||
className={css.span}
|
||||
data-timeline-span={span.kind}
|
||||
data-equal-duration={mode === 'time' || undefined}
|
||||
data-current={span.index === selectedIndex || undefined}
|
||||
data-search-match={searchMatchIndexes === null
|
||||
? undefined
|
||||
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
|
||||
data-selected={activeRange === null
|
||||
? undefined
|
||||
: span.start <= activeRange.end && span.end >= activeRange.start
|
||||
? 'true'
|
||||
: 'false'}
|
||||
key={span.index}
|
||||
title={durationMs === undefined
|
||||
? span.label
|
||||
: `${span.label} · ${formatTimelineOffset(durationMs)}`}
|
||||
style={{
|
||||
'--trajectory-span-left': `${left * 100}%`,
|
||||
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
|
||||
'--trajectory-span-lane': span.lane,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
.root {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: var(--dsh-trajectory-toolbar-height);
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.toggle[aria-pressed='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.toggleIcon {
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 88px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.control[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control:hover:not(:disabled),
|
||||
.control[aria-checked='true'],
|
||||
.control[aria-pressed='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.control:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.control:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.controlTrack {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
transition: background-color 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.controlThumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
transition: transform 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.controlTrack[data-on='true'] {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.controlTrack[data-on='true'] .controlThumb {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: 14px/14px var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex: 0 1 164px;
|
||||
align-items: center;
|
||||
min-width: 84px;
|
||||
height: 22px;
|
||||
margin-left: auto;
|
||||
padding: 0 6px;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 4px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.search:hover {
|
||||
border-color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: transparent;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.searchInput::-webkit-search-cancel-button {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
131
packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx
Normal file
131
packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
/** Trajectory toolbar: timeline and ledger fold controls. */
|
||||
|
||||
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './TrajectoryToolbar.module.css'
|
||||
|
||||
export interface TrajectoryToolbarProps {
|
||||
/** Whether timeline blocks use recorded durations instead of equal widths. */
|
||||
actualDuration: boolean
|
||||
/** Select recorded-duration or equal-width blocks. */
|
||||
onActualDurationChange: (actualDuration: boolean) => void
|
||||
/** Whether recorded timing retains idle gaps between user turns. */
|
||||
actualTime: boolean
|
||||
/** Select complete wall-clock timing or idle-compressed timing. */
|
||||
onActualTimeChange: (actualTime: boolean) => void
|
||||
/** Number of turns containing more than one row. */
|
||||
collapsibleTurns: number
|
||||
/** Whether every collapsible turn is currently folded. */
|
||||
allTurnsCollapsed: boolean
|
||||
/** Fold or expand every collapsible turn. */
|
||||
onToggleAllTurns: () => void
|
||||
/** Number of assistant messages followed by tool calls. */
|
||||
collapsibleAssistants: number
|
||||
/** Whether every collapsible assistant's tool calls are currently folded. */
|
||||
allAssistantsCollapsed: boolean
|
||||
/** Fold or expand tool calls under every collapsible assistant. */
|
||||
onToggleAllAssistants: () => void
|
||||
/** Current live ledger search query. */
|
||||
searchQuery: string
|
||||
/** Update the live ledger search query. */
|
||||
onSearchQueryChange: (query: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sticky trajectory toolbar.
|
||||
* @param props - rendered counts and whole-list fold state.
|
||||
* @returns the toolbar element.
|
||||
*/
|
||||
export function TrajectoryToolbar({
|
||||
actualDuration,
|
||||
onActualDurationChange,
|
||||
actualTime,
|
||||
onActualTimeChange,
|
||||
collapsibleTurns,
|
||||
allTurnsCollapsed,
|
||||
onToggleAllTurns,
|
||||
collapsibleAssistants,
|
||||
allAssistantsCollapsed,
|
||||
onToggleAllAssistants,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
}: TrajectoryToolbarProps) {
|
||||
return (
|
||||
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
|
||||
<div className={css.inner}>
|
||||
<div className={css.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggle}
|
||||
aria-label="Use actual duration"
|
||||
aria-pressed={actualDuration}
|
||||
title={actualDuration ? 'Use equal-width operations' : 'Use actual duration'}
|
||||
onClick={() => { onActualDurationChange(!actualDuration) }}
|
||||
>
|
||||
<svg
|
||||
className={css.toggleIcon}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="5.25" />
|
||||
<path d="M8 4.75V8l2.25 1.5" />
|
||||
</svg>
|
||||
Duration
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.control}
|
||||
role="switch"
|
||||
aria-checked={actualTime}
|
||||
hidden
|
||||
onClick={() => { onActualTimeChange(!actualTime) }}
|
||||
>
|
||||
<span>Actual time</span>
|
||||
<span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
|
||||
<span className={css.controlThumb} />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
aria-pressed={allTurnsCollapsed}
|
||||
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
disabled={collapsibleTurns === 0}
|
||||
onClick={onToggleAllTurns}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allTurnsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
Turns
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
aria-pressed={allAssistantsCollapsed}
|
||||
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
disabled={collapsibleAssistants === 0}
|
||||
onClick={onToggleAllAssistants}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allAssistantsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
Calls
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.search}>
|
||||
<IconSearchOutline16 size={11} className={css.searchIcon} />
|
||||
<input
|
||||
type="search"
|
||||
className={css.searchInput}
|
||||
aria-label="Search trajectory"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +1,509 @@
|
||||
// TrajectoryView: sticky Turn sections with Message/Step groups and step cells.
|
||||
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { TrajectoryCell } from './TrajectoryCell.tsx'
|
||||
import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from './TrajectoryTurn.tsx'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationContext,
|
||||
SessionHistoryFace,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||
} from './context-branches.ts'
|
||||
import {
|
||||
TrajectoryTable,
|
||||
type TrajectoryRequestNumber,
|
||||
type TrajectoryUsage,
|
||||
} from './TrajectoryTable.tsx'
|
||||
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
|
||||
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
|
||||
import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import {
|
||||
trajectoryTimelineFocusIndexes,
|
||||
type TrajectoryTimelineMode,
|
||||
type TrajectoryTimeRange,
|
||||
} from './timeline.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const partial = useSession(s => s.partial)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
|
||||
[nodes, partial, runningCalls, codeDispatches],
|
||||
)
|
||||
if (turns.length === 0) {
|
||||
return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set()
|
||||
|
||||
/** Session-history paging needed by the event-complete trajectory view. */
|
||||
export interface TrajectoryViewInjected {
|
||||
hooks: { history: SessionHistoryFace }
|
||||
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
|
||||
function requestUsage(value: unknown): TrajectoryUsage | undefined {
|
||||
const usage = value as UsageLike | undefined
|
||||
if (usage === undefined) return undefined
|
||||
return {
|
||||
...(usage.inputTokens === undefined ? {} : { input: usage.inputTokens }),
|
||||
...(usage.cacheReadTokens === undefined ? {} : { cacheRead: usage.cacheReadTokens }),
|
||||
...(usage.cacheWriteTokens === undefined ? {} : { cacheWrite: usage.cacheWriteTokens }),
|
||||
...(usage.outputTokens === undefined ? {} : { output: usage.outputTokens }),
|
||||
...(usage.reasoningTokens === undefined ? {} : { reasoning: usage.reasoningTokens }),
|
||||
}
|
||||
}
|
||||
|
||||
function addUsage(
|
||||
total: TrajectoryUsage | undefined,
|
||||
usage: TrajectoryUsage | undefined,
|
||||
): TrajectoryUsage | undefined {
|
||||
if (usage === undefined) return total
|
||||
return {
|
||||
...(total?.input === undefined && usage.input === undefined
|
||||
? {}
|
||||
: { input: (total?.input ?? 0) + (usage.input ?? 0) }),
|
||||
...(total?.cacheRead === undefined && usage.cacheRead === undefined
|
||||
? {}
|
||||
: { cacheRead: (total?.cacheRead ?? 0) + (usage.cacheRead ?? 0) }),
|
||||
...(total?.cacheWrite === undefined && usage.cacheWrite === undefined
|
||||
? {}
|
||||
: { cacheWrite: (total?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0) }),
|
||||
...(total?.output === undefined && usage.output === undefined
|
||||
? {}
|
||||
: { output: (total?.output ?? 0) + (usage.output ?? 0) }),
|
||||
...(total?.reasoning === undefined && usage.reasoning === undefined
|
||||
? {}
|
||||
: { reasoning: (total?.reasoning ?? 0) + (usage.reasoning ?? 0) }),
|
||||
}
|
||||
}
|
||||
|
||||
function searchableJson(value: unknown): string {
|
||||
if (value === undefined) return ''
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function searchMatches(
|
||||
turns: ReturnType<typeof deriveTrajectoryLayout>,
|
||||
query: string,
|
||||
): ReadonlySet<number> | null {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (terms.length === 0) return null
|
||||
const matches = new Set<number>()
|
||||
for (const turn of turns) {
|
||||
for (const group of turn.groups) {
|
||||
for (const cell of group.cells) {
|
||||
if (cell.requestOnly === true) continue
|
||||
const blocks = [
|
||||
...(cell.sourceBlocks ?? []),
|
||||
...(cell.outputBlocks ?? []),
|
||||
]
|
||||
const text = [
|
||||
`turn ${turn.turn}`,
|
||||
group.title,
|
||||
cell.kind,
|
||||
cell.kind === 'message' ? 'assistant' : undefined,
|
||||
cell.text,
|
||||
cell.inputDetail,
|
||||
cell.outputDetail,
|
||||
cell.thinkingDetail,
|
||||
cell.schemaDetail,
|
||||
cell.result,
|
||||
cell.callId,
|
||||
...blocks.flatMap(block => [
|
||||
block.type,
|
||||
block.content,
|
||||
block.callId,
|
||||
block.toolName,
|
||||
block.imageAlt,
|
||||
]),
|
||||
searchableJson(cell.messageSource),
|
||||
searchableJson(cell.promptDetail),
|
||||
searchableJson(cell.previousPromptDetail),
|
||||
].filter((value): value is string => typeof value === 'string')
|
||||
.join('\n')
|
||||
.toLocaleLowerCase()
|
||||
if (terms.every(term => text.includes(term))) matches.add(cell.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
export function TrajectoryView({
|
||||
useHistory, loadAllHistory,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
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 [actualDuration, setActualDuration] = useState(false)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||
const ledgerRef = useRef<HTMLDivElement>(null)
|
||||
const inspection = useHistory(snapshot => snapshot.inspection)
|
||||
const nodes = inspection.eventNodes
|
||||
const partial = inspection.partial
|
||||
const runningCalls = inspection.runningCalls
|
||||
const codeDispatches = inspection.codeDispatches
|
||||
const loadAllHistoryRef = useRef(loadAllHistory)
|
||||
loadAllHistoryRef.current = loadAllHistory
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadAllHistoryRef.current(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [])
|
||||
const requests = inspection.requests
|
||||
const callSchemas = inspection.callSchemas
|
||||
const contexts = useMemo<readonly ConversationContext[]>(
|
||||
() => inspection.contexts.length === 0
|
||||
? [{ id: 0, nodes }]
|
||||
: inspection.contexts,
|
||||
[inspection, nodes],
|
||||
)
|
||||
const branches = useMemo(
|
||||
() => deriveTrajectoryContextBranches(contexts),
|
||||
[contexts],
|
||||
)
|
||||
const currentBranch = branches.at(-1)
|
||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||
const selectedNodes = useMemo(() => {
|
||||
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
|
||||
for (const node of inspection.interruptedNodes) {
|
||||
selected.set(node.seq, node)
|
||||
}
|
||||
return [...selected.values()].sort((left, right) => left.seq - right.seq)
|
||||
}, [currentBranch, inspection])
|
||||
const selectedRequests = useMemo(
|
||||
() => requests.filter(request =>
|
||||
trajectoryBranchContainsRequest(currentBranch, request),
|
||||
),
|
||||
[currentBranch, requests],
|
||||
)
|
||||
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
||||
for (const context of contexts) {
|
||||
for (const node of context.nodes) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
}
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
}
|
||||
const requestsByStep = new Map(
|
||||
requests
|
||||
.filter(request => request.purpose === 'assistant')
|
||||
.map(request => [
|
||||
`${request.turn}\u0000${request.step}`,
|
||||
request,
|
||||
]),
|
||||
)
|
||||
const orderedRequests = [
|
||||
...requests.map(request => ({
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
node: request.purpose === 'assistant'
|
||||
? assistantsByStep.get(`${request.turn}\u0000${request.step}`)
|
||||
: undefined,
|
||||
})),
|
||||
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
|
||||
requestsByStep.has(key)
|
||||
? []
|
||||
: [{
|
||||
seq: node.seq,
|
||||
request: undefined,
|
||||
node,
|
||||
}],
|
||||
),
|
||||
].sort((left, right) => left.seq - right.seq)
|
||||
const numbered: TrajectoryRequestNumber[] = []
|
||||
let cumulativeUsage: TrajectoryUsage | undefined
|
||||
for (const [index, entry] of orderedRequests.entries()) {
|
||||
const usage = requestUsage(entry.request?.usage ?? entry.node?.usage)
|
||||
cumulativeUsage = addUsage(cumulativeUsage, usage)
|
||||
if (entry.request?.purpose !== 'compaction') {
|
||||
const request = entry.request
|
||||
const node = entry.node
|
||||
const turn = request?.turn ?? node?.turn
|
||||
const step = request?.step ?? node?.step
|
||||
if (turn === undefined || step === undefined) continue
|
||||
const provider = request?.provenance?.provider ?? node?.provenance?.provider
|
||||
const model = request?.provenance?.model ?? node?.provenance?.model
|
||||
const requestConfig = request?.requestConfig ?? node?.requestConfig
|
||||
numbered.push({
|
||||
seq: entry.seq,
|
||||
turn,
|
||||
step,
|
||||
group: `Step ${step}`,
|
||||
number: index + 1,
|
||||
...(request?.status === undefined ? {} : { status: request.status }),
|
||||
...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }),
|
||||
...(request?.completedAt === undefined ? {} : { completedAt: request.completedAt }),
|
||||
...(request?.error === undefined ? {} : { error: request.error }),
|
||||
...(request?.resultSeq === undefined ? {} : { resultSeq: request.resultSeq }),
|
||||
...(request?.retry === undefined ? {} : { retry: request.retry }),
|
||||
...(request?.maxRetries === undefined ? {} : { maxRetries: request.maxRetries }),
|
||||
...(request?.retryDelayMs === undefined
|
||||
? {}
|
||||
: { retryDelayMs: request.retryDelayMs }),
|
||||
...(provider === undefined ? {} : { provider }),
|
||||
...(model === undefined ? {} : { model }),
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const request = entry.request
|
||||
numbered.push({
|
||||
seq: request.startSeq,
|
||||
turn: request.turn,
|
||||
step: 0,
|
||||
group: `Compaction ${request.startSeq}`,
|
||||
number: index + 1,
|
||||
purpose: 'compaction',
|
||||
status: request.status,
|
||||
startedAt: request.startedAt,
|
||||
completedAt: request.completedAt,
|
||||
...(request.error === undefined ? {} : { error: request.error }),
|
||||
resultSeq: request.startSeq,
|
||||
...(request.provenance?.provider === undefined
|
||||
? {}
|
||||
: { provider: request.provenance.provider }),
|
||||
...(request.provenance?.model === undefined
|
||||
? {}
|
||||
: { model: request.provenance.model }),
|
||||
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
})
|
||||
}
|
||||
|
||||
if (partial !== null && partial.step > 0) {
|
||||
const key = `${partial.turn}\u0000${partial.step}`
|
||||
const recorded = numbered.some(request =>
|
||||
`${request.turn}\u0000${request.step}` === key,
|
||||
)
|
||||
if (!recorded) {
|
||||
numbered.push({
|
||||
turn: partial.turn,
|
||||
step: partial.step,
|
||||
group: `Step ${partial.step}`,
|
||||
number: orderedRequests.length + 1,
|
||||
...(currentBranch.latest.prompt?.config.provider === undefined
|
||||
? {}
|
||||
: { provider: currentBranch.latest.prompt.config.provider }),
|
||||
...(currentBranch.latest.prompt?.config.model === undefined
|
||||
? {}
|
||||
: { model: currentBranch.latest.prompt.config.model }),
|
||||
...(currentBranch.latest.prompt?.config === undefined
|
||||
? {}
|
||||
: { requestConfig: currentBranch.latest.prompt.config }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
})
|
||||
}
|
||||
}
|
||||
return numbered
|
||||
}, [
|
||||
contexts, currentBranch.latest.prompt, nodes, partial, requests,
|
||||
])
|
||||
const requestNumbers = globalRequestNumbers
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({
|
||||
nodes: selectedNodes,
|
||||
partial,
|
||||
runningCalls,
|
||||
requests: selectedRequests,
|
||||
callSchemas,
|
||||
codeDispatches,
|
||||
}),
|
||||
[
|
||||
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
|
||||
],
|
||||
)
|
||||
const timelineMode: TrajectoryTimelineMode = actualDuration
|
||||
? actualTime ? 'actual' : 'duration'
|
||||
: actualTime ? 'time' : 'sequence'
|
||||
const searchMatchIndexes = useMemo(
|
||||
() => searchMatches(turns, searchQuery),
|
||||
[searchQuery, turns],
|
||||
)
|
||||
const timelineRange = timelineSelection?.branchId === currentBranch.id
|
||||
? timelineSelection.range
|
||||
: null
|
||||
const timelineFocusIndexes = useMemo(
|
||||
() => timelineRange === null
|
||||
? null
|
||||
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
|
||||
[timelineMode, timelineRange, turns],
|
||||
)
|
||||
const handleRecordSelect = useCallback((index: number) => {
|
||||
if (
|
||||
timelineFocusIndexes !== null
|
||||
&& !timelineFocusIndexes.has(index)
|
||||
) {
|
||||
setTimelineSelection(null)
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
useEffect(() => {
|
||||
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
|
||||
const ledger = ledgerRef.current
|
||||
if (ledger === null) return
|
||||
const focusedRows = [
|
||||
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
|
||||
]
|
||||
const first = focusedRows.at(0)
|
||||
const last = focusedRows.at(-1)
|
||||
if (first === undefined || last === undefined) return
|
||||
const focusHeight =
|
||||
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
|
||||
if (focusHeight > ledger.clientHeight) {
|
||||
if (typeof first.scrollIntoView === 'function') {
|
||||
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
return
|
||||
}
|
||||
const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)]
|
||||
if (middle !== undefined && typeof middle.scrollIntoView === 'function') {
|
||||
middle.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
const collapsibleTurnIds = useMemo(
|
||||
() => turns
|
||||
.filter(turn =>
|
||||
turn.groups.reduce(
|
||||
(count, group) =>
|
||||
count + group.cells.filter(cell =>
|
||||
cell.requestOnly !== true && cell.kind !== 'system').length,
|
||||
0,
|
||||
) > 1)
|
||||
.map(turn => turn.turn),
|
||||
[turns],
|
||||
)
|
||||
const allTurnsCollapsed = collapsibleTurnIds.length > 0
|
||||
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
|
||||
const collapsibleAssistantIds = useMemo(() => {
|
||||
const ids: number[] = []
|
||||
for (const turn of turns) {
|
||||
const cells = turn.groups.flatMap(group => group.cells)
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i]
|
||||
if (cell?.kind !== 'message') continue
|
||||
const next = cells[i + 1]
|
||||
if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [turns])
|
||||
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
|
||||
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
|
||||
|
||||
const toggleTurn = (turn: number) => {
|
||||
setCollapsedTurns((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (collapsed.has(turn)) collapsed.delete(turn)
|
||||
else collapsed.add(turn)
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAllTurns = () => {
|
||||
setCollapsedTurns((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (allTurnsCollapsed) {
|
||||
for (const turn of collapsibleTurnIds) collapsed.delete(turn)
|
||||
} else {
|
||||
for (const turn of collapsibleTurnIds) collapsed.add(turn)
|
||||
}
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAssistant = (index: number) => {
|
||||
setCollapsedAssistants((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (collapsed.has(index)) collapsed.delete(index)
|
||||
else collapsed.add(index)
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAllAssistants = () => {
|
||||
setCollapsedAssistants((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (allAssistantsCollapsed) {
|
||||
for (const index of collapsibleAssistantIds) collapsed.delete(index)
|
||||
} else {
|
||||
for (const index of collapsibleAssistantIds) collapsed.add(index)
|
||||
}
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
))}
|
||||
<TrajectoryToolbar
|
||||
actualDuration={actualDuration}
|
||||
onActualDurationChange={(nextActualDuration) => {
|
||||
setActualDuration(nextActualDuration)
|
||||
setTimelineSelection(null)
|
||||
}}
|
||||
actualTime={actualTime}
|
||||
onActualTimeChange={(nextActualTime) => {
|
||||
setActualTime(nextActualTime)
|
||||
setTimelineSelection(null)
|
||||
}}
|
||||
collapsibleTurns={collapsibleTurnIds.length}
|
||||
allTurnsCollapsed={allTurnsCollapsed}
|
||||
onToggleAllTurns={toggleAllTurns}
|
||||
collapsibleAssistants={collapsibleAssistantIds.length}
|
||||
allAssistantsCollapsed={allAssistantsCollapsed}
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
/>
|
||||
<TrajectoryTimeline
|
||||
turns={turns}
|
||||
mode={timelineMode}
|
||||
range={timelineRange}
|
||||
selectedIndex={selectedTimelineIndex}
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onRangeChange={(range) => {
|
||||
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
|
||||
}}
|
||||
onRecordFocus={(index) => {
|
||||
const row = ledgerRef.current
|
||||
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
|
||||
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div ref={ledgerRef} className={css.ledger}>
|
||||
<TrajectoryTable
|
||||
key={currentBranch.id}
|
||||
requestNumbers={requestNumbers}
|
||||
turns={turns}
|
||||
timelineFocusIndexes={timelineFocusIndexes}
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onSelectedIndexChange={setSelectedTimelineIndex}
|
||||
onRecordSelect={handleRecordSelect}
|
||||
onClearSelection={() => { setTimelineSelection(null) }}
|
||||
collapsedTurns={collapsedTurns}
|
||||
onToggleTurn={toggleTurn}
|
||||
collapsedAssistants={collapsedAssistants}
|
||||
onToggleAssistant={toggleAssistant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>暂无瀑布数据</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
113
packages/client/ui-trajectory/src/client/context-branches.ts
Normal file
113
packages/client/ui-trajectory/src/client/context-branches.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
|
||||
|
||||
import type {
|
||||
ConversationContext, ConversationNode, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
|
||||
export interface TrajectoryContextBranch {
|
||||
id: number
|
||||
contexts: readonly ConversationContext[]
|
||||
latest: ConversationContext
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Seq that opened this branch; earlier requests require retained surface provenance. */
|
||||
startSeq: number
|
||||
/** Exact pre-rewind surface records inherited by this branch. */
|
||||
retainedSurfaceSeqs: ReadonlySet<number>
|
||||
}
|
||||
|
||||
interface MutableBranch {
|
||||
id: number
|
||||
contexts: ConversationContext[]
|
||||
latest: ConversationContext
|
||||
nodes: Map<number, ConversationNode>
|
||||
startSeq: number
|
||||
retainedSurfaceSeqs: Set<number>
|
||||
}
|
||||
|
||||
function isCompactionCheckpoint(node: ConversationNode): boolean {
|
||||
if (node.kind !== 'context') return false
|
||||
const source = node.source
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
|
||||
/**
|
||||
* Join context generations across compaction/rewrite operations and split only at rewind.
|
||||
* @param contexts - Append-only context generations from the runtime fold.
|
||||
* @returns Rewind-delimited branches in creation order.
|
||||
*/
|
||||
export function deriveTrajectoryContextBranches(
|
||||
contexts: readonly ConversationContext[],
|
||||
): readonly TrajectoryContextBranch[] {
|
||||
const mutable: MutableBranch[] = []
|
||||
for (const context of contexts) {
|
||||
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
|
||||
if (startsBranch) {
|
||||
const previous = mutable.at(-1)
|
||||
const retainedSurfaceSeqs = new Set(
|
||||
context.nodes
|
||||
.filter(node =>
|
||||
context.originSeq !== undefined && node.seq < context.originSeq,
|
||||
)
|
||||
.map(node => node.seq),
|
||||
)
|
||||
const inheritedNodes = previous === undefined
|
||||
? []
|
||||
: [...previous.nodes.values()].filter(node =>
|
||||
retainedSurfaceSeqs.has(node.seq),
|
||||
)
|
||||
mutable.push({
|
||||
id: context.id,
|
||||
contexts: [context],
|
||||
latest: context,
|
||||
nodes: new Map(
|
||||
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
|
||||
.map(node => [node.seq, node]),
|
||||
),
|
||||
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
|
||||
retainedSurfaceSeqs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
const branch = mutable.at(-1)
|
||||
if (branch === undefined) continue
|
||||
branch.contexts.push(context)
|
||||
branch.latest = context
|
||||
for (const node of context.nodes) {
|
||||
if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node)
|
||||
}
|
||||
}
|
||||
return mutable.map(branch => ({
|
||||
id: branch.id,
|
||||
contexts: branch.contexts,
|
||||
latest: branch.latest,
|
||||
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
|
||||
startSeq: branch.startSeq,
|
||||
retainedSurfaceSeqs: branch.retainedSurfaceSeqs,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a provider request belongs to one rewind branch.
|
||||
* @param branch - Branch carrying exact inherited surface provenance.
|
||||
* @param request - Provider request to classify.
|
||||
* @returns Whether the request began on this branch or produced a retained surface record.
|
||||
*/
|
||||
export function trajectoryBranchContainsRequest(
|
||||
branch: TrajectoryContextBranch,
|
||||
request: RequestView,
|
||||
): boolean {
|
||||
if (request.startSeq >= branch.startSeq) return true
|
||||
return (
|
||||
request.resultSeq !== undefined
|
||||
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
|
||||
) || (
|
||||
request.replacementSeq !== undefined
|
||||
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* 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'
|
||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
// 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 } from './TrajectoryView.tsx'
|
||||
import { WaterfallView } from './WaterfallView.tsx'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
@@ -16,17 +16,25 @@ import { WaterfallView } from './WaterfallView.tsx'
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation']
|
||||
export const inject = ['slots', 'conversation', 'sessionHistory']
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory' }, TrajectoryView)
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
|
||||
ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
order: 10,
|
||||
label: 'Trajectory',
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const history = ctx.sessionHistory.source(sessionId)
|
||||
return {
|
||||
hooks: { history },
|
||||
loadAllHistory: signal => history.loadAll(signal),
|
||||
}
|
||||
},
|
||||
}, TrajectoryView)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
* own-duration times, in-flight partial/runningCalls, and group descriptions.
|
||||
*/
|
||||
import type {
|
||||
AssistantBlock,
|
||||
AssistantMessageNode,
|
||||
CodeSubCall,
|
||||
ConversationSnapshot,
|
||||
RequestInspectionSnapshot,
|
||||
RequestPromptChange,
|
||||
RequestView,
|
||||
ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TrajectoryCellProps } from './TrajectoryCell.tsx'
|
||||
import type {
|
||||
TrajectoryCellProps,
|
||||
TrajectorySourceBlock,
|
||||
} from './trajectory-record.ts'
|
||||
|
||||
/** One Message or Step group inside a turn. */
|
||||
export interface TrajectoryGroupModel {
|
||||
@@ -28,12 +35,16 @@ export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
requests?: readonly RequestView[]
|
||||
callSchemas?: RequestInspectionSnapshot['callSchemas']
|
||||
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
|
||||
codeDispatches: ConversationSnapshot['codeDispatches']
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
@@ -46,15 +57,85 @@ interface LaidCell {
|
||||
callId?: string
|
||||
}
|
||||
|
||||
interface LaidGroup {
|
||||
title: string
|
||||
laid: LaidCell[]
|
||||
}
|
||||
|
||||
interface TurnBucket {
|
||||
groups: LaidGroup[]
|
||||
}
|
||||
|
||||
type InputNode = Extract<
|
||||
ConversationSnapshot['nodes'][number],
|
||||
{ kind: 'user' | 'steering' | 'context' }
|
||||
>
|
||||
|
||||
type OrderedLayoutEntry =
|
||||
| {
|
||||
kind: 'node'
|
||||
seq: number
|
||||
node: ConversationSnapshot['nodes'][number]
|
||||
nodeIndex: number
|
||||
}
|
||||
| {
|
||||
kind: 'compaction'
|
||||
seq: number
|
||||
request: RequestView
|
||||
}
|
||||
| {
|
||||
kind: 'system'
|
||||
seq: number
|
||||
request: RequestView
|
||||
change: RequestPromptChange
|
||||
}
|
||||
| {
|
||||
kind: 'request'
|
||||
seq: number
|
||||
request: RequestView
|
||||
}
|
||||
|
||||
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
|
||||
return entry.kind === 'system' && entry.change.kind === 'initial'
|
||||
? Number.NEGATIVE_INFINITY
|
||||
: entry.seq
|
||||
}
|
||||
|
||||
function inputCellDetail(node: InputNode): Pick<
|
||||
TrajectoryCellProps,
|
||||
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt'
|
||||
> {
|
||||
return {
|
||||
text: summarizeContent(node.content),
|
||||
sourceSeq: node.seq,
|
||||
messageSource: node.source,
|
||||
inputDetail: detailContent(node.content),
|
||||
sourceBlocks: node.content.map(block => sourceBlock(block)),
|
||||
timeSeconds: 0,
|
||||
startedAt: finiteTime(node.time),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, codeDispatches } = input
|
||||
const {
|
||||
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
|
||||
} = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
|
||||
const callStartById = new Map<string, number>()
|
||||
for (const result of resultByCall.values()) {
|
||||
const startedAt = finiteTime(result.callTime)
|
||||
if (startedAt !== null) callStartById.set(result.callId, startedAt)
|
||||
}
|
||||
for (const call of runningCalls) {
|
||||
const startedAt = finiteTime(call.time)
|
||||
if (startedAt !== null) callStartById.set(call.callId, startedAt)
|
||||
}
|
||||
const turns = new Map<number, TurnBucket>()
|
||||
let index = 0
|
||||
let prevAbsTime: number | null = null
|
||||
let lastAssistantTurn: number | null = null
|
||||
@@ -62,26 +143,171 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
const bucket = (turn: number) => {
|
||||
let entry = turns.get(turn)
|
||||
if (entry === undefined) {
|
||||
entry = { message: [], steps: new Map() }
|
||||
entry = { groups: [] }
|
||||
turns.set(turn, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
const pushMessage = (turn: number, laid: LaidCell) => {
|
||||
bucket(turn).message.push(laid)
|
||||
const groups = bucket(turn).groups
|
||||
const last = groups.at(-1)
|
||||
if (last?.title === 'Message') {
|
||||
last.laid.push(laid)
|
||||
return
|
||||
}
|
||||
groups.push({ title: 'Message', laid: [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)
|
||||
const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => {
|
||||
if (laid.length === 0) return
|
||||
const groups = bucket(turn).groups
|
||||
const title = `Step ${step}`
|
||||
const existing = groups.find(group => group.title === title)
|
||||
if (existing !== undefined) {
|
||||
existing.laid.push(...laid)
|
||||
return
|
||||
}
|
||||
groups.push({ title, laid: [...laid] })
|
||||
}
|
||||
|
||||
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
|
||||
const representedRequests = new Set<string>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'assistant' && node.step > 0) {
|
||||
representedRequests.add(`${node.turn}\u0000${node.step}`)
|
||||
}
|
||||
}
|
||||
if (partial !== null && partial.step > 0) {
|
||||
representedRequests.add(`${partial.turn}\u0000${partial.step}`)
|
||||
}
|
||||
for (const call of runningCalls) {
|
||||
if (call.step > 0) representedRequests.add(`${call.turn}\u0000${call.step}`)
|
||||
}
|
||||
|
||||
const entries: OrderedLayoutEntry[] = [
|
||||
...nodes.map((node, nodeIndex) => ({
|
||||
kind: 'node' as const,
|
||||
seq: node.seq,
|
||||
node,
|
||||
nodeIndex,
|
||||
})),
|
||||
...requests
|
||||
.filter(request => request.purpose === 'compaction')
|
||||
.map(request => ({
|
||||
kind: 'compaction' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
|
||||
? []
|
||||
: [{
|
||||
kind: 'system' as const,
|
||||
seq: request.promptChange.seq,
|
||||
request,
|
||||
change: request.promptChange,
|
||||
}]),
|
||||
...requests
|
||||
.filter(request => request.purpose === 'assistant')
|
||||
.filter(request =>
|
||||
!representedRequests.has(`${request.turn}\u0000${request.step}`),
|
||||
)
|
||||
.map(request => ({
|
||||
kind: 'request' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
].sort((left, right) => layoutEntryOrder(left) - layoutEntryOrder(right))
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'request') {
|
||||
const { request } = entry
|
||||
pushStep(request.turn, request.step, [{
|
||||
absTime: finiteTime(request.startedAt),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'message',
|
||||
text: '',
|
||||
sourceSeq: request.startSeq,
|
||||
requestOnly: true,
|
||||
timeSeconds: request.completedAt === null
|
||||
? null
|
||||
: durationSeconds(request.completedAt, request.startedAt),
|
||||
startedAt: finiteTime(request.startedAt),
|
||||
...(request.status === 'error' ? { isError: true } : {}),
|
||||
},
|
||||
}])
|
||||
prevAbsTime = finiteTime(request.completedAt)
|
||||
?? finiteTime(request.startedAt)
|
||||
?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'system') {
|
||||
const { change, request } = entry
|
||||
const turn = change.kind === 'initial'
|
||||
? firstVisibleTurn(nodes, partial)
|
||||
: enclosingPromptTurn(nodes, change.seq, partial)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(change.time),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'system',
|
||||
text: promptChangeLabel(change),
|
||||
sourceSeq: change.seq,
|
||||
...(request.prompt === undefined ? {} : { promptDetail: request.prompt }),
|
||||
...(change.previous === undefined
|
||||
? {}
|
||||
: { previousPromptDetail: change.previous }),
|
||||
timeSeconds: 0,
|
||||
startedAt: finiteTime(change.time),
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(change.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'compaction') {
|
||||
const request = entry.request
|
||||
const rawOutput = request.rawOutput ?? request.summary
|
||||
const thinkingDetail = rawOutput === undefined
|
||||
? ''
|
||||
: detailReasoning(rawOutput)
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index,
|
||||
kind: 'compacted',
|
||||
text: request.status === 'running'
|
||||
? 'Compacting context…'
|
||||
: request.status === 'error'
|
||||
? request.error ?? 'Compaction failed'
|
||||
: request.summary === undefined
|
||||
? 'Context compacted'
|
||||
: summarizeContent(request.summary),
|
||||
sourceSeq: request.startSeq,
|
||||
...(request.summary === undefined
|
||||
? {}
|
||||
: {
|
||||
outputDetail: detailContent(request.summary),
|
||||
outputBlocks: request.summary.map(block => sourceBlock(block)),
|
||||
}),
|
||||
...(thinkingDetail === '' ? {} : { thinkingDetail }),
|
||||
...(rawOutput === undefined
|
||||
? {}
|
||||
: { sourceBlocks: rawOutput.map(block => sourceBlock(block)) }),
|
||||
...(request.status === 'error' ? { isError: true } : {}),
|
||||
timeSeconds: request.completedAt === null
|
||||
? null
|
||||
: durationSeconds(request.completedAt, request.startedAt),
|
||||
startedAt: finiteTime(request.startedAt),
|
||||
}
|
||||
attachUsage(cell, request.usage as UsageLike | undefined)
|
||||
bucket(request.turn).groups.push({
|
||||
title: `Compaction ${request.startSeq}`,
|
||||
laid: [{
|
||||
absTime: finiteTime(request.startedAt),
|
||||
cell,
|
||||
}],
|
||||
})
|
||||
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
const { node, nodeIndex: i } = entry
|
||||
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.
|
||||
@@ -91,19 +317,22 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index, kind: 'user', text: summarizeContent(node.content),
|
||||
timeSeconds: 0,
|
||||
index: ++index,
|
||||
kind: 'user',
|
||||
...inputCellDetail(node),
|
||||
opensTurn: node.kind === 'user',
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'assistant') {
|
||||
const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches)
|
||||
for (const laid of laidList) {
|
||||
if (node.step > 0) pushStep(node.turn, node.step, laid)
|
||||
else pushMessage(node.turn, laid)
|
||||
}
|
||||
const laidList = withSubCalls(
|
||||
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
|
||||
codeDispatches,
|
||||
)
|
||||
if (node.step > 0) pushStep(node.turn, node.step, laidList)
|
||||
else for (const laid of laidList) pushMessage(node.turn, laid)
|
||||
const last = laidList[laidList.length - 1]
|
||||
if (last !== undefined) index = last.cell.index
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
@@ -111,30 +340,47 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'context') {
|
||||
// No trajectory cell, but the surface still advances the duration cursor.
|
||||
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'context',
|
||||
...inputCellDetail(node),
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'tool-result') {
|
||||
if (!callEmittedInAssistant(nodes, node.callId)) {
|
||||
const toolName = node.call?.name
|
||||
pushStep(0, 1, {
|
||||
const laidList: LaidCell[] = [{
|
||||
absTime: finiteTime(node.callTime ?? node.time),
|
||||
...(toolName !== undefined ? { toolName } : {}),
|
||||
callId: node.callId,
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
sourceSeq: node.seq,
|
||||
text: node.call !== null
|
||||
? summarizeCall(node.call.name, node.call.argsRaw)
|
||||
: summarizeResult(node),
|
||||
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
|
||||
outputDetail: detailResult(node),
|
||||
outputBlocks: node.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(node),
|
||||
callId: node.callId,
|
||||
isError: node.isError,
|
||||
timeSeconds: durationSeconds(node.time, node.callTime),
|
||||
startedAt: finiteTime(node.callTime),
|
||||
},
|
||||
})
|
||||
}]
|
||||
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
|
||||
pushStep(0, 1, laid)
|
||||
laidList.push(laid)
|
||||
index = laid.cell.index
|
||||
}
|
||||
pushStep(0, 1, laidList)
|
||||
}
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
}
|
||||
@@ -145,11 +391,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
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 laidList = expandAssistant(
|
||||
fake,
|
||||
index + 1,
|
||||
prevAbsTime,
|
||||
resultByCall,
|
||||
callStartById,
|
||||
{ streaming: true },
|
||||
)
|
||||
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
|
||||
else for (const laid of laidList) pushMessage(partial.turn, laid)
|
||||
const last = laidList[laidList.length - 1]
|
||||
if (last !== undefined) index = last.cell.index
|
||||
}
|
||||
@@ -157,7 +408,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
const seenCalls = collectCallIds(turns)
|
||||
for (const call of runningCalls) {
|
||||
if (seenCalls.has(call.callId)) continue
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, {
|
||||
const laidList: LaidCell[] = [{
|
||||
absTime: null,
|
||||
toolName: call.name,
|
||||
callId: call.callId,
|
||||
@@ -165,63 +416,67 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
text: summarizeCall(call.name, call.argsRaw),
|
||||
inputDetail: call.argsRaw,
|
||||
callId: call.callId,
|
||||
timeSeconds: null,
|
||||
startedAt: finiteTime(call.time),
|
||||
},
|
||||
})
|
||||
}]
|
||||
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, laid)
|
||||
laidList.push(laid)
|
||||
index = laid.cell.index
|
||||
}
|
||||
if (call.step > 0) pushStep(call.turn, call.step, laidList)
|
||||
else for (const laid of laidList) pushMessage(call.turn, laid)
|
||||
}
|
||||
|
||||
// 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 emptyTurn = (): TurnBucket => ({ groups: [] })
|
||||
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])
|
||||
}
|
||||
first.groups = [...prologue.groups, ...first.groups]
|
||||
turns.set(1, first)
|
||||
}
|
||||
|
||||
for (const entry of turns.values()) {
|
||||
for (const group of entry.groups) {
|
||||
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
|
||||
}
|
||||
}
|
||||
|
||||
return [...turns.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([turn, entry]) => toTurnModel(turn, entry))
|
||||
}
|
||||
|
||||
function attachToolSchema(
|
||||
laid: LaidCell,
|
||||
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
|
||||
): void {
|
||||
if (laid.callId === undefined || callSchemas === undefined) return
|
||||
const schema = callSchemas.get(laid.callId)
|
||||
if (schema === undefined) return
|
||||
laid.cell.schemaDetail = JSON.stringify(schema, null, 2)
|
||||
}
|
||||
|
||||
function toTurnModel(
|
||||
turn: number,
|
||||
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
|
||||
entry: TurnBucket,
|
||||
): 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 groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
|
||||
const description = groupDescription(laid)
|
||||
groups.push({
|
||||
title: `Step ${step}`,
|
||||
return {
|
||||
title,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
cells: laid.map(l => l.cell),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return { turn, groups }
|
||||
}
|
||||
|
||||
/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */
|
||||
/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */
|
||||
function groupDescription(laid: readonly LaidCell[]): string | undefined {
|
||||
const parts: string[] = []
|
||||
// Tool rows contribute start (absTime) and end (start + own duration) so a
|
||||
@@ -256,8 +511,8 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined {
|
||||
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`
|
||||
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. */
|
||||
@@ -267,8 +522,8 @@ function durationSeconds(later: number, earlier: number | null): number | null {
|
||||
}
|
||||
|
||||
/** Epoch-ms usable as an absolute time, else null. */
|
||||
function finiteTime(time: number): number | null {
|
||||
return Number.isFinite(time) ? time : null
|
||||
function finiteTime(time: number | null | undefined): number | null {
|
||||
return typeof time === 'number' && Number.isFinite(time) ? time : null
|
||||
}
|
||||
|
||||
function expandAssistant(
|
||||
@@ -276,66 +531,175 @@ function expandAssistant(
|
||||
startIndex: number,
|
||||
prevAbsTime: number | null,
|
||||
results: Map<string, ToolResultNode>,
|
||||
callStarts: ReadonlyMap<string, number>,
|
||||
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 recordedStart = finiteTime(node.timing?.stepStartTime)
|
||||
const messageDuration = streaming
|
||||
? null
|
||||
: durationSeconds(node.time, recordedStart ?? prevAbsTime)
|
||||
const nodeAbs = streaming ? null : finiteTime(node.time)
|
||||
let usageAttached = false
|
||||
const messageText = node.blocks
|
||||
.filter(block => block.kind === 'text' && (!streaming || block.text !== ''))
|
||||
.map(block => block.kind === 'text' ? block.text : '')
|
||||
.join('\n\n')
|
||||
const thinkingText = node.blocks
|
||||
.filter(block => block.kind === 'reasoning' && (!streaming || block.text !== ''))
|
||||
.map(block => block.kind === 'reasoning' ? block.text : '')
|
||||
.join('\n\n')
|
||||
const message: TrajectoryCellProps = {
|
||||
index: ++index,
|
||||
kind: 'message',
|
||||
sourceSeq: node.seq,
|
||||
text: messageText !== ''
|
||||
? summarizeText(messageText)
|
||||
: thinkingText !== ''
|
||||
? summarizeText(thinkingText)
|
||||
: summarizeAssistantActivity(node.blocks),
|
||||
...(messageText !== '' ? { outputDetail: messageText } : {}),
|
||||
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
|
||||
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
|
||||
timeSeconds: messageDuration,
|
||||
startedAt: recordedStart,
|
||||
}
|
||||
attachUsage(message, usage)
|
||||
message.assistantMetrics = {
|
||||
timingRecorded: node.timing !== undefined,
|
||||
stepStartTime: node.timing?.stepStartTime ?? null,
|
||||
firstTokenTime: node.timing?.firstTokenTime ?? null,
|
||||
completedTime: streaming ? null : finiteTime(node.time),
|
||||
usageProvided: usage !== undefined,
|
||||
outputTokens: Number.isFinite(usage?.outputTokens) ? usage?.outputTokens ?? null : null,
|
||||
}
|
||||
out.push({ absTime: nodeAbs, cell: message })
|
||||
|
||||
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,
|
||||
// Text and reasoning belong to the one Assistant record emitted above.
|
||||
if (block.kind !== 'tool-call') continue
|
||||
const result = results.get(block.callId)
|
||||
const toolDuration = streaming || result === undefined
|
||||
? null
|
||||
: durationSeconds(result.time, result.callTime)
|
||||
const callAbs = finiteTime(callStarts.get(block.callId))
|
||||
out.push({
|
||||
absTime: callAbs,
|
||||
toolName: block.name,
|
||||
callId: block.callId,
|
||||
cell: {
|
||||
index: ++index, kind: 'tool',
|
||||
text: summarizeCall(block.name, block.argsRaw),
|
||||
inputDetail: block.argsRaw,
|
||||
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 })
|
||||
...(result !== undefined
|
||||
? {
|
||||
outputDetail: detailResult(result),
|
||||
outputBlocks: result.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(result),
|
||||
isError: result.isError,
|
||||
}
|
||||
: {}),
|
||||
timeSeconds: toolDuration,
|
||||
startedAt: callAbs,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
|
||||
const tools = new Map<string, number>()
|
||||
for (const block of blocks) {
|
||||
if (block.kind !== 'tool-call') continue
|
||||
tools.set(block.name, (tools.get(block.name) ?? 0) + 1)
|
||||
}
|
||||
if (tools.size > 0) {
|
||||
return 'Tool call only'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function promptChangeLabel(change: RequestPromptChange): string {
|
||||
if (change.kind === 'initial') return 'Initial System Prompt'
|
||||
if (change.kind === 'system') return 'System Prompt Updated'
|
||||
if (change.kind === 'tools') return 'Tools Updated'
|
||||
return 'System Prompt and Tools Updated'
|
||||
}
|
||||
|
||||
function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
|
||||
switch (block.kind) {
|
||||
case 'text': return { type: 'text', content: block.text }
|
||||
case 'reasoning': return { type: 'thinking', content: block.text }
|
||||
case 'tool-call': return {
|
||||
type: 'tool-call',
|
||||
content: block.argsRaw,
|
||||
callId: block.callId,
|
||||
toolName: block.name,
|
||||
}
|
||||
case 'other': return sourceBlock(block.block)
|
||||
}
|
||||
}
|
||||
|
||||
function sourceBlock(value: unknown): TrajectorySourceBlock {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return { type: 'unknown', content: stringifySourceValue(value) }
|
||||
}
|
||||
const block = value as Record<string, unknown>
|
||||
const type = typeof block.type === 'string' ? block.type : 'unknown'
|
||||
if (typeof block.text === 'string') {
|
||||
return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
|
||||
}
|
||||
const imageSrc = sourceImage(block)
|
||||
const imageAlt = typeof block.alt === 'string' ? block.alt : undefined
|
||||
return {
|
||||
type,
|
||||
content: imageSrc === undefined ? stringifySourceValue(value) : '',
|
||||
...(imageSrc !== undefined ? { imageSrc } : {}),
|
||||
...(imageAlt !== undefined ? { imageAlt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function sourceImage(block: Record<string, unknown>): string | undefined {
|
||||
if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined
|
||||
for (const candidate of [block.url, block.image_url]) {
|
||||
if (typeof candidate === 'string') return safeImageSource(candidate)
|
||||
}
|
||||
if (typeof block.data === 'string') {
|
||||
const mediaType = [block.mimeType, block.mediaType, block.media_type]
|
||||
.find((candidate): candidate is string => typeof candidate === 'string')
|
||||
?? 'image/png'
|
||||
return safeImageSource(
|
||||
block.data.startsWith('data:')
|
||||
? block.data
|
||||
: `data:${mediaType};base64,${block.data}`,
|
||||
)
|
||||
}
|
||||
if (typeof block.source !== 'object' || block.source === null) return undefined
|
||||
const source = block.source as Record<string, unknown>
|
||||
if (typeof source.url === 'string') return safeImageSource(source.url)
|
||||
if (typeof source.data !== 'string') return undefined
|
||||
const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png'
|
||||
return safeImageSource(`data:${mediaType};base64,${source.data}`)
|
||||
}
|
||||
|
||||
function safeImageSource(value: string): string | undefined {
|
||||
if (value.startsWith('data:image/') || value.startsWith('blob:')) return value
|
||||
try {
|
||||
const protocol = new URL(value).protocol
|
||||
return protocol === 'http:' || protocol === 'https:' ? value : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function stringifySourceValue(value: unknown): string {
|
||||
const json = JSON.stringify(value, null, 2)
|
||||
return json || String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -357,10 +721,37 @@ function enclosingUserTurn(
|
||||
return 1
|
||||
}
|
||||
|
||||
function enclosingPromptTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
seq: number,
|
||||
partial: ConversationSnapshot['partial'],
|
||||
): number {
|
||||
const next = nodes.find(node =>
|
||||
node.seq > seq && node.kind === 'assistant' && node.step > 0)
|
||||
if (next?.kind === 'assistant') return next.turn
|
||||
return partial?.turn ?? 1
|
||||
}
|
||||
|
||||
/** Earliest raw turn represented by the selected trajectory branch. */
|
||||
function firstVisibleTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
partial: ConversationSnapshot['partial'],
|
||||
): number {
|
||||
const turns = nodes.flatMap(node =>
|
||||
(node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0
|
||||
? [node.turn]
|
||||
: [],
|
||||
)
|
||||
if (partial !== null && partial.turn > 0) turns.push(partial.turn)
|
||||
return turns.length === 0 ? 1 : Math.min(...turns)
|
||||
}
|
||||
|
||||
/** 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.cacheReadTokens !== undefined) cell.cacheRead = usage.cacheReadTokens
|
||||
if (usage.cacheWriteTokens !== undefined) cell.cacheWrite = usage.cacheWriteTokens
|
||||
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
|
||||
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
|
||||
}
|
||||
@@ -382,15 +773,12 @@ function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: st
|
||||
}
|
||||
|
||||
function collectCallIds(
|
||||
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
|
||||
turns: Map<number, TurnBucket>,
|
||||
): 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) {
|
||||
for (const group of entry.groups) {
|
||||
for (const laid of group.laid) {
|
||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||
}
|
||||
}
|
||||
@@ -433,12 +821,27 @@ function expandSubCalls(
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'subtool',
|
||||
callId: sub.callId,
|
||||
text: settled
|
||||
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
|
||||
: summarizeCall(sub.name, sub.argsRaw),
|
||||
...(settled
|
||||
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
|
||||
: { inputDetail: sub.argsRaw }),
|
||||
...(settled
|
||||
? {
|
||||
outputDetail: detailResult(sub),
|
||||
outputBlocks: sub.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(sub),
|
||||
isError: sub.isError,
|
||||
}
|
||||
: {}),
|
||||
// PR3's start/settle pair carries per-sub-call wall time; a running
|
||||
// (unsettled) or pre-pair log entry shows the em dash.
|
||||
timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null,
|
||||
startedAt: settled
|
||||
? finiteTime(sub.callTime)
|
||||
: finiteTime(sub.time),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -448,8 +851,7 @@ function expandSubCalls(
|
||||
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}`
|
||||
return `${name} · ${args}`
|
||||
}
|
||||
|
||||
function summarizeResult(node: ToolResultNode): string {
|
||||
@@ -461,7 +863,40 @@ function summarizeResult(node: ToolResultNode): string {
|
||||
return summarizeText(block.text)
|
||||
}
|
||||
}
|
||||
return node.call?.name ?? node.callId
|
||||
return 'No output'
|
||||
}
|
||||
|
||||
function detailResult(node: ToolResultNode): string {
|
||||
if (node.isError) {
|
||||
return node.error === undefined
|
||||
? 'error'
|
||||
: `${node.error.name}: ${node.error.code}`
|
||||
}
|
||||
const text = node.content
|
||||
.filter(block => block.type === 'text' && typeof block.text === 'string')
|
||||
.map(block => block.type === 'text' ? block.text : '')
|
||||
.join('\n')
|
||||
if (text !== '') return text
|
||||
if (
|
||||
node.content.length === 0
|
||||
|| node.content.every(block =>
|
||||
block.type === 'text' && (typeof block.text !== 'string' || block.text === ''))
|
||||
) return 'No output'
|
||||
return JSON.stringify(node.content, null, 2)
|
||||
}
|
||||
|
||||
function detailContent(content: readonly { type: string; text?: string }[]): string {
|
||||
return content
|
||||
.filter(block => block.type === 'text' && typeof block.text === 'string')
|
||||
.map(block => block.text ?? '')
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function detailReasoning(content: readonly { type: string; text?: string }[]): string {
|
||||
return content
|
||||
.filter(block => block.type === 'reasoning' && typeof block.text === 'string')
|
||||
.map(block => block.text ?? '')
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
186
packages/client/ui-trajectory/src/client/timeline.ts
Normal file
186
packages/client/ui-trajectory/src/client/timeline.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/** Operation-sequence and recorded-time projections for the trajectory overview. */
|
||||
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
|
||||
|
||||
/** Horizontal projection used by the trajectory timeline. */
|
||||
export type TrajectoryTimelineMode = 'sequence' | 'duration' | 'time' | 'actual'
|
||||
|
||||
/** Inclusive selection in the active timeline projection's domain. */
|
||||
export interface TrajectoryTimeRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** One ledger record projected into the active timeline domain. */
|
||||
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
|
||||
index: number
|
||||
kind: TrajectoryCellKind
|
||||
label: string
|
||||
lane: number
|
||||
}
|
||||
|
||||
/** One turn boundary in the active timeline domain. */
|
||||
export interface TrajectoryTimelineTurnBoundary {
|
||||
turn: number
|
||||
time: number
|
||||
}
|
||||
|
||||
/** Full-domain model used by the overview. */
|
||||
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
|
||||
spans: readonly TrajectoryTimelineSpan[]
|
||||
turnBoundaries: readonly TrajectoryTimelineTurnBoundary[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timeline duration with a compact unit.
|
||||
* @param milliseconds - Non-negative duration in milliseconds.
|
||||
* @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`
|
||||
}
|
||||
|
||||
function laneFor(kind: TrajectoryCellKind): number {
|
||||
if (kind === 'tool' || kind === 'subtool') return 2
|
||||
if (kind === 'message' || kind === 'compacted') return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Project every visible record into a stable three-lane timeline.
|
||||
* @param turns - Unfiltered trajectory layout.
|
||||
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
|
||||
* @returns Timeline model, or `null` when no record is visible.
|
||||
*/
|
||||
export function deriveTrajectoryTimeline(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
mode: TrajectoryTimelineMode = 'sequence',
|
||||
): TrajectoryTimelineModel | null {
|
||||
if (mode !== 'sequence') {
|
||||
return deriveTimedTimeline(
|
||||
turns,
|
||||
mode === 'duration' || mode === 'actual',
|
||||
mode === 'duration',
|
||||
)
|
||||
}
|
||||
const spans: TrajectoryTimelineSpan[] = []
|
||||
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||
|
||||
for (const turn of turns) {
|
||||
const cells = turn.groups.flatMap(group =>
|
||||
group.cells.filter(cell => cell.requestOnly !== true),
|
||||
)
|
||||
if (cells.length === 0) continue
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
time: spans.length,
|
||||
})
|
||||
spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
|
||||
start: spans.length + offset,
|
||||
end: spans.length + offset + 1,
|
||||
index: cell.index,
|
||||
kind: cell.kind,
|
||||
label: cell.text,
|
||||
lane: laneFor(cell.kind),
|
||||
})))
|
||||
}
|
||||
|
||||
if (spans.length === 0) return null
|
||||
return {
|
||||
start: 0,
|
||||
end: spans.length,
|
||||
spans,
|
||||
turnBoundaries,
|
||||
}
|
||||
}
|
||||
|
||||
function deriveTimedTimeline(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
actualDuration: boolean,
|
||||
removeUserIdle: boolean,
|
||||
): TrajectoryTimelineModel | null {
|
||||
const spans: TrajectoryTimelineSpan[] = []
|
||||
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||
let removedUserIdle = 0
|
||||
let previousTurnEnd: number | null = null
|
||||
|
||||
for (const turn of turns) {
|
||||
const rawSpans = 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 (rawSpans.length === 0) continue
|
||||
|
||||
const turnStart = Math.min(...rawSpans.map(span => span.start))
|
||||
const turnEnd = Math.max(...rawSpans.map(span => span.end))
|
||||
if (removeUserIdle && previousTurnEnd !== null) {
|
||||
removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
|
||||
}
|
||||
spans.push(...rawSpans.map(span => ({
|
||||
...span,
|
||||
start: span.start - removedUserIdle,
|
||||
end: (actualDuration ? span.end : span.start) - removedUserIdle,
|
||||
})))
|
||||
turnBoundaries.push({
|
||||
turn: turn.turn,
|
||||
time: turnStart - removedUserIdle,
|
||||
})
|
||||
previousTurnEnd = previousTurnEnd === null
|
||||
? turnEnd
|
||||
: Math.max(previousTurnEnd, turnEnd)
|
||||
}
|
||||
|
||||
if (spans.length === 0) return null
|
||||
return {
|
||||
start: Math.min(...spans.map(span => span.start)),
|
||||
end: Math.max(...spans.map(span => span.end)),
|
||||
spans,
|
||||
turnBoundaries,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify records active at any point inside an inclusive selected interval.
|
||||
* @param turns - Unfiltered trajectory layout.
|
||||
* @param range - Selected interval in the active projection.
|
||||
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
|
||||
* @returns Record indexes inside the focus interval.
|
||||
*/
|
||||
export function trajectoryTimelineFocusIndexes(
|
||||
turns: readonly TrajectoryTurnModel[],
|
||||
range: TrajectoryTimeRange,
|
||||
mode: TrajectoryTimelineMode = 'sequence',
|
||||
): ReadonlySet<number> {
|
||||
const model = deriveTrajectoryTimeline(turns, mode)
|
||||
return new Set(
|
||||
model?.spans
|
||||
.filter(span => span.start <= range.end && span.end >= range.start)
|
||||
.map(span => span.index),
|
||||
)
|
||||
}
|
||||
104
packages/client/ui-trajectory/src/client/trajectory-record.ts
Normal file
104
packages/client/ui-trajectory/src/client/trajectory-record.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/** Shared trajectory record data and formatting contracts. */
|
||||
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Closed set of trajectory record kinds. */
|
||||
export type TrajectoryCellKind =
|
||||
| 'system'
|
||||
| 'user'
|
||||
| 'context'
|
||||
| 'compacted'
|
||||
| 'message'
|
||||
| 'tool'
|
||||
| 'subtool'
|
||||
|
||||
/** Recorded inputs needed to derive assistant TTFT and decode throughput. */
|
||||
export interface AssistantMetricDetail {
|
||||
timingRecorded: boolean
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
completedTime: number | null
|
||||
usageProvided: boolean
|
||||
outputTokens: number | null
|
||||
}
|
||||
|
||||
/** One source content block preserved in model order for the details panel. */
|
||||
export interface TrajectorySourceBlock {
|
||||
type: string
|
||||
content: string
|
||||
imageSrc?: string
|
||||
imageAlt?: string
|
||||
callId?: string
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
/** Data and optional presentation attributes for one trajectory record. */
|
||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 1-based record index shown as `#N`. */
|
||||
index: number
|
||||
kind: TrajectoryCellKind
|
||||
/** Single-line summary; CSS ellipsis when it overflows. */
|
||||
text: string
|
||||
/** Whether this user record opens a new model turn. */
|
||||
opensTurn?: boolean
|
||||
/** Source session-event seq for cross-record navigation. */
|
||||
sourceSeq?: number
|
||||
/** Producer provenance from a user-role message or context injection. */
|
||||
messageSource?: unknown
|
||||
/** Producer-owned model-hidden metadata carried beside the message source. */
|
||||
/** A separator-only anchor for an auxiliary request with no visible record. */
|
||||
requestOnly?: boolean
|
||||
/** Full request/message content for the details panel. */
|
||||
inputDetail?: string
|
||||
/** Complete system-prompt/tool-catalog state introduced by a SYSTEM record. */
|
||||
promptDetail?: ConversationPromptSnapshot
|
||||
/** System-prompt/tool-catalog state replaced by a SYSTEM update. */
|
||||
previousPromptDetail?: ConversationPromptSnapshot
|
||||
/** Full assistant/tool result content for the details panel. */
|
||||
outputDetail?: string
|
||||
/** Full assistant reasoning content for the details panel. */
|
||||
thinkingDetail?: string
|
||||
/** Original message blocks in source order for the details panel. */
|
||||
sourceBlocks?: readonly TrajectorySourceBlock[]
|
||||
/** Original tool result blocks in source order for the details panel. */
|
||||
outputBlocks?: readonly TrajectorySourceBlock[]
|
||||
/** Call-time model-visible tool schema for the details panel. */
|
||||
schemaDetail?: string
|
||||
/** Assistant-only timing and token facts for the details panel. */
|
||||
assistantMetrics?: AssistantMetricDetail
|
||||
/** Tool-only result summary paired with the call in the same record. */
|
||||
result?: string
|
||||
/** Tool call id used to link message source blocks to tool records. */
|
||||
callId?: string
|
||||
/** Tool-only result failure state. */
|
||||
isError?: boolean
|
||||
/** Own duration in seconds, or `null` when no duration is known. */
|
||||
timeSeconds: number | null
|
||||
/** Unix epoch milliseconds when this operation actually started, when known. */
|
||||
startedAt?: number | null
|
||||
/** Message-only prompt token count. */
|
||||
input?: number
|
||||
/** Message-only input tokens served from a provider cache. */
|
||||
cacheRead?: number
|
||||
/** Message-only input tokens written into a provider cache. */
|
||||
cacheWrite?: number
|
||||
/** Message-only completion token count. */
|
||||
output?: number
|
||||
/** Message-only reasoning token count. */
|
||||
think?: number
|
||||
/** Whether the legacy standalone cell renders its selection treatment. */
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format own-duration for the trailing time column.
|
||||
* @param seconds - Duration seconds, or `null` when absent.
|
||||
* @returns `—` when unknown, otherwise a seconds label.
|
||||
*/
|
||||
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`
|
||||
}
|
||||
@@ -1,89 +1,29 @@
|
||||
/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
|
||||
* cell content width is capped on the turn body (max 880). Under the
|
||||
* active conversation column (`[data-conversation-scroll]`) the parent
|
||||
* owns overflow so the sticky composer stays in the same scrollport. */
|
||||
/* Full-bleed, fixed-height host for the trajectory ledger. */
|
||||
.root {
|
||||
overflow-y: auto;
|
||||
--dsh-trajectory-toolbar-height: 32px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
/* Under the active conversation column (`[data-conversation-scroll]`) the
|
||||
* parent owns overflow so the sticky composer stays in the same scrollport. */
|
||||
:global([data-conversation-scroll]) .root {
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* Waterfall placeholder rows (shared module). */
|
||||
.row {
|
||||
.ledger {
|
||||
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;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
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 = () => {}
|
||||
|
||||
Reference in New Issue
Block a user