Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
#	THIRD_PARTY_NOTICES.md
#	apps/cli/composition.md
#	apps/cli/config/base.cordis.yml
#	apps/cli/package.json
#	apps/cli/src/app-cli-entry.ts
#	apps/cli/src/bin.ts
#	apps/cli/tests/args.spec.ts
#	apps/web/tests/built-boot.snapshot.ts
#	apps/web/tests/navigation-panes.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/bundle/README.i18n.yaml
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.md
#	packages/client/connection/README.zh.md
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/http-bridge.ts
#	packages/client/connection/src/index.ts
#	packages/client/connection/tests/fixture.spec.ts
#	packages/client/connection/tests/node-half.spec.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/contract/session.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.module.css
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/input/contract.ts
#	packages/client/ui-conversation/src/client/input/facade.ts
#	packages/client/ui-conversation/src/client/input/hub.ts
#	packages/client/ui-conversation/src/client/locales.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/README.zh.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/ui/tui/README.md
#	packages/ui/tui/README.zh.md
#	packages/ui/tui/src/components/content.ts
#	packages/ui/tui/src/components/transcript.ts
#	packages/ui/tui/tests/tui.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-08 01:56:29 +08:00
3973 changed files with 144864 additions and 60887 deletions

View File

@@ -13,12 +13,62 @@
}
.tablePane {
position: relative;
flex: 1;
min-width: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px);
container: trajectory-table / inline-size;
}
.historyLoading {
position: sticky;
z-index: 5;
top: 0;
height: 0;
overflow: visible;
pointer-events: none;
}
.historyLoadingBar {
display: flex;
width: 100%;
height: 30px;
align-items: center;
justify-content: center;
gap: 6px;
box-sizing: border-box;
border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-12);
}
.historyLoadingSpinner {
width: 10px;
height: 10px;
box-sizing: border-box;
border: 1.5px solid var(--dsw-alias-border-l2);
border-top-color: var(--dsw-alias-state-business-primary);
border-radius: 50%;
animation: history-loading-spin 700ms linear infinite;
}
.table:not([data-scroll-ready='true']) {
visibility: hidden;
}
@keyframes history-loading-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.historyLoadingSpinner {
animation: none;
}
}
.table {
--trajectory-turn-accent: color-mix(
in srgb,
@@ -27,7 +77,7 @@
);
width: 100%;
min-width: 480px;
min-width: 0;
border-spacing: 0;
table-layout: fixed;
color: var(--dsw-alias-label-primary);
@@ -77,7 +127,17 @@
white-space: nowrap;
}
.table tbody tr:not([data-collapsed-summary]) {
.table tbody .virtualSpacer {
pointer-events: none;
}
.table tbody .virtualSpacer td {
height: var(--trajectory-virtual-spacer-height);
padding: 0;
border: 0;
}
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) {
cursor: default;
outline: none;
transition:
@@ -89,7 +149,7 @@
opacity: 0.24;
}
.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -98,13 +158,13 @@
}
.table tbody tr[data-request-only='true'] td {
height: 1px;
height: 0;
padding-top: 0;
padding-bottom: 0;
border-bottom: 0;
}
.table tbody tr[data-request-only='true']:last-child td {
.table tbody tr[data-terminal-request-boundary='true'] td {
/* Retain the lower half of the 16px boundary marker at the table's end. */
height: 9px;
}
@@ -123,10 +183,12 @@
}
.requestBoundaryControl {
--request-boundary-base-left: 12px;
position: absolute;
z-index: 6;
top: -8px;
left: 12px;
left: calc(var(--request-boundary-base-left) + var(--request-boundary-offset, 0px));
width: 16px;
height: 16px;
padding: 0;
@@ -191,6 +253,12 @@
box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.requestBoundaryControl[data-request-status='error']::before,
.requestBoundaryControl[data-request-status='error']:hover::before,
.requestBoundaryControl[data-request-status='error']:focus-visible::before {
background: var(--dsw-alias-state-error-primary);
}
.requestBoundaryControl:hover::after,
.requestBoundaryControl:focus-visible::after {
opacity: 1;
@@ -277,7 +345,7 @@
z-index: 3;
top: 0;
left: 0;
display: inline-flex;
display: inline-grid;
flex: none;
align-items: center;
box-sizing: border-box;
@@ -292,8 +360,18 @@
white-space: nowrap;
}
.turnLabelFull,
.turnLabelCompact {
display: none;
grid-area: 1 / 1;
max-width: 64px;
overflow: hidden;
opacity: 1;
white-space: nowrap;
}
.turnLabelCompact {
max-width: 0;
opacity: 0;
}
.turnLabelActive {
@@ -321,6 +399,13 @@
width: 76px;
}
.kindSlot :global([role='tooltip']) {
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
}
.content {
padding-left: 4px !important;
color: var(--dsw-alias-label-primary);
@@ -343,15 +428,23 @@
}
.kindTagIcon {
display: none;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 13px;
width: 0;
height: 13px;
overflow: hidden;
opacity: 0;
transform: scale(0.8);
}
.kindTagLabel {
display: inline;
display: inline-block;
max-width: 72px;
overflow: hidden;
opacity: 1;
white-space: nowrap;
}
.table .kindSlot .message {
@@ -370,7 +463,7 @@
}
.requestBoundaryControl {
left: 6px;
--request-boundary-base-left: 6px;
}
.kindSlot {
@@ -386,19 +479,66 @@
}
.kindTagIcon {
display: inline-flex;
width: 13px;
opacity: 1;
transform: scale(1);
}
.kindTagLabel {
display: none;
max-width: 0;
opacity: 0;
}
.turnLabelFull {
display: none;
max-width: 0;
opacity: 0;
}
.turnLabelCompact {
display: inline;
max-width: 64px;
opacity: 1;
}
}
@media (prefers-reduced-motion: no-preference) {
.eventColumn,
.event,
.requestBoundaryControl,
.kindSlot,
.kindTag,
.kindTagIcon,
.kindTagLabel,
.turnLabelFull,
.turnLabelCompact {
transition-duration: 180ms;
transition-timing-function: var(--ds-ease-in-out);
}
.eventColumn,
.kindSlot {
transition-property: width;
}
.event {
transition-property: padding-right, padding-left;
}
.requestBoundaryControl {
transition-property: left;
}
.kindTag {
transition-property: padding-right, padding-left;
}
.kindTagIcon {
transition-property: width, opacity, transform;
}
.kindTagLabel,
.turnLabelFull,
.turnLabelCompact {
transition-property: max-width, opacity;
}
}
@@ -538,6 +678,10 @@
white-space: nowrap;
}
.toolCallOnly {
color: var(--dsw-alias-label-tertiary);
}
.table tbody tr[data-collapsed-summary='turn'] td,
.table tbody tr[data-collapsed-summary='assistant'] td {
height: 20px;
@@ -827,14 +971,17 @@
.detailBody {
flex: 1;
min-height: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px);
scrollbar-gutter: stable;
}
.detailBodySummary {
display: flex;
box-sizing: border-box;
flex-direction: column;
padding-bottom: 12px;
padding-bottom: calc(12px + var(--dsh-trajectory-bottom-clearance, 0px));
overflow: hidden;
}
@@ -851,6 +998,17 @@
overflow: auto;
}
.summaryScrollRegion {
--dsh-scrollbar-thumb: transparent;
--dsh-scrollbar-thumb-hover: transparent;
}
.summaryScrollRegion:hover,
.summaryScrollRegion:focus-within {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.compactedSummary .markdownPayload {
padding-right: 18px;
}
@@ -938,7 +1096,6 @@
margin: 0;
overflow: hidden;
color: var(--dsw-alias-label-primary);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -948,7 +1105,6 @@
color: inherit;
cursor: pointer;
font: inherit;
font-variant-numeric: tabular-nums;
user-select: text;
}
@@ -989,7 +1145,7 @@
margin: 0;
padding: 0 0 3px 14px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-base);
background: var(--dsw-alias-bg-layer-1);
font: var(--dsw-font-xs-strong-13);
user-select: none;
}
@@ -1163,9 +1319,19 @@
background: transparent;
cursor: pointer;
font: 600 12px/18px var(--dsw-font-family);
gap: 2px;
user-select: none;
}
.thinkingChevron {
flex: none;
transition: transform 120ms var(--ds-ease-in-out);
}
.thinkingToggle[aria-expanded='true'] .thinkingChevron {
transform: rotate(90deg);
}
.thinkingToggle:hover {
color: var(--dsw-alias-label-secondary);
}

View File

@@ -1,7 +1,8 @@
/** Turn-aware trajectory event ledger with a local record inspector. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
IconChevronRightOutline14,
IconSettingsOutline16,
@@ -18,10 +19,20 @@ import type {
import type {
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
import { formatElapsedSeconds, trajectoryRecordId } from './trajectory-record.ts'
import {
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
} from './trajectory-virtual-rows.ts'
import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
const OLDER_LOAD_THRESHOLD_PX = 48
const VIRTUALIZATION_THRESHOLD = 100
const VIRTUAL_OVERSCAN_ROWS = 12
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'SYSTEM',
user: 'USER',
@@ -115,6 +126,30 @@ interface TableRecord {
collapsedSummaryKind?: 'turn' | 'assistant'
}
interface VirtualRowStructure {
height: number
key: string
}
function useStableVirtualRowStructure(
rows: readonly TrajectoryVirtualRow<TableRecord>[],
): readonly VirtualRowStructure[] {
const cache = useRef<{
rows: readonly TrajectoryVirtualRow<TableRecord>[]
structure: readonly VirtualRowStructure[]
}>({ rows: [], structure: [] })
if (cache.current.rows === rows) return cache.current.structure
const structure = cache.current.structure.length === rows.length
&& rows.every((row, index) => {
const previous = cache.current.structure[index]
return previous?.key === row.key && previous.height === row.height
})
? cache.current.structure
: rows.map(row => ({ key: row.key, height: row.height }))
cache.current = { rows, structure }
return structure
}
type DetailTab =
| 'system-prompt'
| 'tools'
@@ -148,9 +183,8 @@ interface ToolCallTextParts {
interface SelectedRequest {
turn: number | null
section: number
number: number
group: string
seq?: number
}
interface DetailsResizeDrag {
@@ -189,6 +223,20 @@ type TrajectorySplitStyle = CSSProperties & {
'--trajectory-tool-request-width': string
}
type RequestBoundaryStyle = CSSProperties & {
'--request-boundary-offset': string
}
type VirtualSpacerStyle = CSSProperties & {
'--trajectory-virtual-spacer-height': string
}
interface OlderLoadAnchor {
readonly historyStartSeq: number | undefined
readonly scrollHeight: number
readonly scrollTop: number
}
function clampDetailsWidth(width: number, splitWidth: number): number {
const maxWidth = Math.max(
DETAILS_MIN_WIDTH,
@@ -222,6 +270,15 @@ function formatStartedAt(timestamp: number | null): string {
return `${day} ${time}`
}
/** Whether a click lands on an active text selection and should keep it. */
function clickSelectsText(target: Node): boolean {
const selection = window.getSelection()
return selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(target)
}
function StartedAtValue({ timestamp }: { timestamp: number | null }) {
const [showUnix, setShowUnix] = useState(false)
if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
@@ -232,13 +289,7 @@ function StartedAtValue({ timestamp }: { timestamp: number | null }) {
className={css.timestampToggle}
title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
onClick={(event) => {
const selection = window.getSelection()
if (
selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(event.currentTarget)
) return
if (clickSelectsText(event.currentTarget)) return
setShowUnix(current => !current)
}}
>
@@ -296,6 +347,8 @@ export interface TrajectoryTableProps {
requestNumbers?: readonly TrajectoryRequestNumber[]
/** Grouped records in display order. */
turns: readonly TrajectoryTurnModel[]
/** In-flight cells whose content replaces the matching structural record index. */
streamingCells?: readonly TrajectoryCellProps[]
/** Record indexes emphasized by the active timeline focus. */
timelineFocusIndexes?: ReadonlySet<number> | null
/** Record indexes retained by the active live search, or null without a query. */
@@ -306,16 +359,26 @@ export interface TrajectoryTableProps {
onRecordSelect?: (index: number) => void
/** One externally requested record selection; a new object repeats the request. */
recordSelection?: { readonly index: number } | null
/** One externally requested record focus without changing inspector selection. */
recordFocus?: { readonly index: number } | null
/** Whether the initial history tail is still loading. */
historyLoading?: boolean
/** First loaded raw event, used to preserve scroll position after prepending a page. */
historyStartSeq?: number | undefined
/** Whether one older history page can be requested. */
hasOlderRecords?: boolean
/** Load one older history page. */
onLoadOlder?: () => Promise<boolean>
/** Clear selection state owned by the ledger host. */
onClearSelection?: () => void
/** Turn ids whose rows after the first are folded into a summary. */
collapsedTurns: ReadonlySet<number>
/** Toggle one turn between folded and expanded. */
onToggleTurn: (turn: number) => void
/** Assistant record indexes whose tool calls are folded. */
collapsedAssistants: ReadonlySet<number>
/** Stable Assistant record ids whose tool calls are folded. */
collapsedAssistants: ReadonlySet<string>
/** Toggle tool calls under one assistant record. */
onToggleAssistant: (index: number) => void
onToggleAssistant: (id: string) => void
/** One-shot cross-view inspect: open and scroll to this call's record. */
inspectCallId?: string | null
/** Acknowledge a consumed (or unresolvable) inspect request. */
@@ -451,6 +514,22 @@ function indexRequestNumbers(
return numbers
}
function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap<number, number> {
const indexes = new Map<number, number>()
let runLength = 0
for (const record of records) {
if (record.cell.requestOnly === true) {
indexes.set(record.cell.index, runLength++)
continue
}
if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) {
indexes.set(record.cell.index, runLength)
}
runLength = 0
}
return indexes
}
function summarizeTurn(records: readonly TableRecord[]): string {
const steps = new Set(
records
@@ -470,7 +549,6 @@ function collapseTurnRecords(
records: readonly TableRecord[],
collapsedTurns: ReadonlySet<number>,
): TableRecord[] {
if (collapsedTurns.size === 0) return [...records]
const recordsByTurn = new Map<number, TableRecord[]>()
for (const record of records) {
if (record.turn === null) continue
@@ -528,15 +606,17 @@ function summarizeAssistantTools(records: readonly TableRecord[]): string {
function collapseAssistantRecords(
records: readonly TableRecord[],
collapsedAssistants: ReadonlySet<number>,
collapsedAssistants: ReadonlySet<string>,
): TableRecord[] {
if (collapsedAssistants.size === 0) return [...records]
const out: TableRecord[] = []
for (let i = 0; i < records.length; i++) {
const record = records[i]
if (record === undefined) continue
out.push(record)
if (record.cell.kind !== 'message' || !collapsedAssistants.has(record.cell.index)) continue
if (
record.cell.kind !== 'message'
|| !collapsedAssistants.has(trajectoryRecordId(record.cell))
) continue
const calls: TableRecord[] = []
for (let j = i + 1; j < records.length; j++) {
const candidate = records[j]
@@ -1210,7 +1290,8 @@ function MarkdownRecordContent({
aria-expanded={thinkingExpanded}
onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }}
>
{thinkingExpanded ? 'Thinking' : 'Thinking ...'}
Thinking
<IconChevronRightOutline14 className={css.thinkingChevron} size={12} />
</button>
{thinkingExpanded && (
<MarkdownFragment
@@ -1312,7 +1393,7 @@ function RequestTiming({
<dt>Started</dt>
<StartedAtValue timestamp={anchor?.cell.startedAt ?? null} />
</div>
<div><dt>Duration</dt><dd>—</dd></div>
<div><dt>Duration</dt><dd>{formatElapsedSeconds(null)}</dd></div>
</dl>
)
}
@@ -1496,7 +1577,12 @@ function OverviewSection({
<IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
</button>
</h3>
<div className={css.overviewPreview}>{children}</div>
<div
className={`${css.overviewPreview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{children}
</div>
</section>
)
}
@@ -1510,11 +1596,17 @@ function OverviewSection({
export function TrajectoryTable({
requestNumbers: sessionRequestNumbers,
turns,
streamingCells = [],
timelineFocusIndexes = null,
searchMatchIndexes = null,
onSelectedIndexChange,
onRecordSelect,
recordSelection = null,
recordFocus = null,
historyLoading = false,
historyStartSeq,
hasOlderRecords = false,
onLoadOlder,
onClearSelection,
collapsedTurns,
onToggleTurn,
@@ -1523,7 +1615,7 @@ export function TrajectoryTable({
inspectCallId = null,
onInspectApplied,
}: TrajectoryTableProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('overview')
const [thinkingExpanded, setThinkingExpanded] = useState(false)
@@ -1531,19 +1623,116 @@ export function TrajectoryTable({
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
const appliedRecordFocus = useRef<TrajectoryTableProps['recordFocus']>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
const rootRef = useRef<HTMLDivElement>(null)
const tablePaneRef = useRef<HTMLDivElement>(null)
const followsTableTail = useRef(false)
const tableScrollInitialized = useRef(false)
const [tableScrollReady, setTableScrollReady] = useState(false)
const pendingScrollRecordId = useRef<string | null>(null)
const loadingOlder = useRef(false)
const [olderLoading, setOlderLoading] = useState(false)
const olderLoadAnchor = useRef<OlderLoadAnchor | null>(null)
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const streamingCellsByIndex = useMemo(
() => new Map(streamingCells.map(cell => [cell.index, cell])),
[streamingCells],
)
const currentRecord = useCallback((record: TableRecord): TableRecord => {
const cell = streamingCellsByIndex.get(record.cell.index)
return cell === undefined ? record : { ...record, cell }
}, [streamingCellsByIndex])
const selectedTemplate = useMemo(() => selectedRecordId === null
? undefined
: allRecords.find(record => trajectoryRecordId(record.cell) === selectedRecordId),
[allRecords, selectedRecordId])
const selected = selectedTemplate === undefined
? undefined
: currentRecord(selectedTemplate)
const selectedIndex = selected?.cell.index ?? null
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const records = searchMatchIndexes === null
? collapseAssistantRecords(
collapseTurnRecords(allRecords, collapsedTurns),
collapsedAssistants,
)
: filterRecords(allRecords, searchMatchIndexes)
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const requestNumbers = useMemo(
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
[allRecords, sessionRequestNumbers],
)
const records = useMemo(() => {
if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
const turnRecords = collapsedTurns.size === 0
? allRecords
: collapseTurnRecords(allRecords, collapsedTurns)
return collapsedAssistants.size === 0
? turnRecords
: collapseAssistantRecords(turnRecords, collapsedAssistants)
}, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes])
const projectedVirtualRows = useMemo(
() => groupTrajectoryVirtualRows(records),
[records],
)
const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows)
const virtualizationEnabled = hasOlderRecords
|| records.length > VIRTUALIZATION_THRESHOLD
const estimateVirtualRowSize = useCallback(
(index: number) => virtualRowStructure[index]?.height ?? 30,
[virtualRowStructure],
)
const getVirtualRowKey = useCallback(
(index: number) => virtualRowStructure[index]?.key ?? index,
[virtualRowStructure],
)
const getTableScrollElement = useCallback(() => tablePaneRef.current, [])
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: virtualizationEnabled ? virtualRowStructure.length : 0,
enabled: virtualizationEnabled,
estimateSize: estimateVirtualRowSize,
getItemKey: getVirtualRowKey,
getScrollElement: getTableScrollElement,
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
anchorTo: 'end',
overscan: VIRTUAL_OVERSCAN_ROWS,
scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
})
const virtualIndexByRecordId = useMemo(() => {
const indexes = new Map<string, number>()
for (const [virtualIndex, row] of projectedVirtualRows.entries()) {
for (const entry of row.entries) {
if (entry.record.collapsedSummary === undefined) {
indexes.set(trajectoryRecordId(entry.record.cell), virtualIndex)
}
}
}
return indexes
}, [projectedVirtualRows])
const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
const virtualTop = virtualItems[0]?.start ?? 0
const virtualBottom = virtualItems.length === 0
? 0
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0))
const renderedRecords = virtualizationEnabled
? virtualItems.flatMap((item) => {
const row = projectedVirtualRows[item.index]
if (row === undefined) return []
return row.entries.map((entry, entryIndex) => ({
record: currentRecord(entry.record),
position: entry.logicalIndex,
terminalRequestBoundary:
entry.record.cell.requestOnly === true
&& row.entries.at(-1)?.record.cell.requestOnly === true
&& entryIndex === row.entries.length - 1,
}))
})
: records.map((record, position) => ({
record: currentRecord(record),
position,
terminalRequestBoundary:
record.cell.requestOnly === true && position === records.length - 1,
}))
const requestBoundaryRuns = useMemo(
() => indexRequestBoundaryRuns(records),
[records],
)
const selectedPrompt = selected?.cell.kind === 'system'
? selected.cell.promptDetail
: undefined
@@ -1552,20 +1741,25 @@ export function TrajectoryTable({
: undefined
const promptSelected = selectedPrompt !== undefined
const selectedState = selected === undefined ? undefined : stateOf(selected)
const selectedRequestRecords = selectedRequest === null
const selectedRequestRecordTemplates = useMemo(() => selectedRequest === null
? []
: allRecords.filter(record =>
record.turn === selectedRequest.turn
&& record.section === selectedRequest.section
&& record.group === selectedRequest.group,
)
), [allRecords, selectedRequest])
const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord)
const selectedRequestAssistant = selectedRequestRecords.find(
record => record.cell.kind === 'message',
)
const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
const selectedRequestNumber = selectedRequest === null
? undefined
: requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group))
const selectedRequestInfo = selectedRequest === null
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
: sessionRequestNumbers?.find(request => selectedRequest.seq === undefined
? request.turn === selectedRequest.turn && request.group === selectedRequest.group
: request.seq === selectedRequest.seq)
const selectedRequestState: RecordState | undefined = selectedRequest === null
? undefined
: selectedRequestInfo?.status
@@ -1581,9 +1775,12 @@ export function TrajectoryTable({
const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
record => record.cell.kind === 'subtool',
).length
const selectedRequestResult = selectedRequestInfo?.resultSeq === undefined
const selectedRequestResultTemplate = selectedRequestInfo?.resultSeq === undefined
? selectedRequestAssistant
: allRecords.find(record => record.cell.sourceSeq === selectedRequestInfo.resultSeq)
const selectedRequestResult = selectedRequestResultTemplate === undefined
? undefined
: currentRecord(selectedRequestResultTemplate)
const selectedRequestUsage = selectedRequestInfo?.usage ?? (
selectedRequestAssistant === undefined
? undefined
@@ -1609,7 +1806,9 @@ export function TrajectoryTable({
selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
const selectedRequestOptions = selectedRequestInfo?.requestConfig
const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn
const activeSection = selectedRequest === null ? selected?.section : selectedRequest.section
const activeSection = selectedRequest === null
? selected?.section
: selectedRequestRecords[0]?.section
const selectedTabs = selectedRequest !== null
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: selected === undefined ? [] : detailTabs(selected)
@@ -1621,13 +1820,17 @@ export function TrajectoryTable({
const selectedAssistantRequest = selected?.cell.kind === 'message'
? requestNumbers.get(requestKey(selected.turn, selected.group))
: undefined
const selectedAssistantRequestInfo = selectedAssistantRequest === undefined
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest)
const selectedAssistantRequestTarget: SelectedRequest | undefined =
selected !== undefined && selectedAssistantRequest !== undefined
? {
turn: selected.turn,
section: selected.section,
number: selectedAssistantRequest,
group: selected.group,
...(selectedAssistantRequestInfo?.seq === undefined
? {}
: { seq: selectedAssistantRequestInfo.seq }),
}
: undefined
const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined
@@ -1646,7 +1849,7 @@ export function TrajectoryTable({
}
const clearInspectorSelection = () => {
setSelectedIndex(null)
setSelectedRecordId(null)
setSelectedRequest(null)
}
@@ -1659,7 +1862,7 @@ export function TrajectoryTable({
const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index)
setSelectedRequest(null)
setSelectedIndex(index)
setSelectedRecordId(record === undefined ? null : trajectoryRecordId(record.cell))
if (record === undefined) return
const tabs = detailTabs(record)
const available = new Set(tabs.map(tab => tab.id))
@@ -1673,13 +1876,25 @@ export function TrajectoryTable({
) return
appliedRecordSelection.current = recordSelection
selectRecord(recordSelection.index)
}, [recordSelection, selectRecord])
const record = allRecords.find(candidate => candidate.cell.index === recordSelection.index)
pendingScrollRecordId.current = record === undefined
? null
: trajectoryRecordId(record.cell)
}, [allRecords, recordSelection, selectRecord])
useEffect(() => {
if (recordFocus === null || appliedRecordFocus.current === recordFocus) return
appliedRecordFocus.current = recordFocus
const record = allRecords.find(candidate => candidate.cell.index === recordFocus.index)
pendingScrollRecordId.current = record === undefined
? null
: trajectoryRecordId(record.cell)
}, [allRecords, recordFocus])
const selectRequest = (
request: SelectedRequest,
tab: 'overview' | 'timing' = 'overview',
) => {
setSelectedIndex(null)
setSelectedRecordId(null)
setSelectedRequest(request)
activateTab(tab)
}
@@ -1692,12 +1907,13 @@ export function TrajectoryTable({
const candidate = allRecords[i]
if (candidate === undefined || candidate.turn !== target.turn) break
if (candidate.cell.kind !== 'message') continue
if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index)
const assistantId = trajectoryRecordId(candidate.cell)
if (collapsedAssistants.has(assistantId)) onToggleAssistant(assistantId)
break
}
}
setSelectedRequest(null)
setSelectedIndex(target.cell.index)
setSelectedRecordId(trajectoryRecordId(target.cell))
activateTab('overview')
}
@@ -1710,8 +1926,6 @@ export function TrajectoryTable({
// open its summary, and remember the row to scroll once the un-collapsed
// ledger has rendered. Not-found leaves the request pending (`turns` in the
// deps retries as history pages in); the ack clears the store field.
const rootRef = useRef<HTMLDivElement>(null)
const pendingScrollIndex = useRef<number | null>(null)
const openRecordSummaryRef = useRef(openRecordSummary)
openRecordSummaryRef.current = openRecordSummary
useEffect(() => {
@@ -1719,42 +1933,213 @@ export function TrajectoryTable({
const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId)
if (target === undefined) return
openRecordSummaryRef.current(target)
pendingScrollIndex.current = target.cell.index
pendingScrollRecordId.current = trajectoryRecordId(target.cell)
onInspectApplied?.()
}, [inspectCallId, turns, onInspectApplied])
useEffect(() => {
const index = pendingScrollIndex.current
if (index === null) return
const row = rootRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row === undefined || row === null) return
pendingScrollIndex.current = null
const id = pendingScrollRecordId.current
if (id === null) return
const position = records.findIndex(record =>
trajectoryRecordId(record.cell) === id && record.collapsedSummary === undefined)
if (position === -1) return
if (virtualizationEnabled) {
const virtualIndex = virtualIndexByRecordId.get(id)
if (virtualIndex === undefined) return
pendingScrollRecordId.current = null
followsTableTail.current = false
rowVirtualizer.scrollToIndex(virtualIndex, { behavior: 'smooth', align: 'center' })
return
}
pendingScrollRecordId.current = null
followsTableTail.current = false
const recordIndex = records[position]?.cell.index
const row = recordIndex === undefined
? null
: rootRef.current?.querySelector<HTMLElement>(`tr[data-record-index="${recordIndex}"]`)
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (typeof row.scrollIntoView === 'function') {
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
}, [records, rowVirtualizer, virtualIndexByRecordId, virtualizationEnabled])
useEffect(() => {
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
const focusedPositions = records.flatMap((record, position) =>
record.collapsedSummary === undefined
&& record.cell.requestOnly !== true
&& timelineFocusIndexes.has(record.cell.index)
? [position]
: [])
const first = focusedPositions.at(0)
const last = focusedPositions.at(-1)
if (first === undefined || last === undefined) return
if (!virtualizationEnabled) {
const ledger = rootRef.current
if (ledger === null) return
const focusedRows = [
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
]
const firstRow = focusedRows.at(0)
const lastRow = focusedRows.at(-1)
if (firstRow === undefined || lastRow === undefined) return
const focusHeight =
lastRow.getBoundingClientRect().bottom - firstRow.getBoundingClientRect().top
const target = focusHeight > ledger.clientHeight
? firstRow
: focusedRows[Math.floor((focusedRows.length - 1) / 2)]
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (target !== undefined && typeof target.scrollIntoView === 'function') {
followsTableTail.current = false
target.scrollIntoView({
behavior: 'smooth',
block: focusHeight > ledger.clientHeight ? 'start' : 'center',
})
}
return
}
const focusedVirtualIndexes = [...new Set(focusedPositions.flatMap((position) => {
const record = records[position]
if (record === undefined) return []
const virtualIndex = virtualIndexByRecordId.get(trajectoryRecordId(record.cell))
return virtualIndex === undefined ? [] : [virtualIndex]
}))].sort((left, right) => left - right)
const firstVirtual = focusedVirtualIndexes.at(0)
const lastVirtual = focusedVirtualIndexes.at(-1)
if (firstVirtual === undefined || lastVirtual === undefined) return
const paneHeight = tablePaneRef.current?.clientHeight ?? 0
const focusHeight = projectedVirtualRows
.slice(firstVirtual, lastVirtual + 1)
.reduce((height, row) => height + row.height, 0)
followsTableTail.current = false
rowVirtualizer.scrollToIndex(
focusHeight > paneHeight
? firstVirtual
: focusedVirtualIndexes[Math.floor((focusedVirtualIndexes.length - 1) / 2)]
?? firstVirtual,
{
behavior: 'smooth',
align: focusHeight > paneHeight ? 'start' : 'center',
},
)
}, [
projectedVirtualRows,
records,
rowVirtualizer,
timelineFocusIndexes,
virtualIndexByRecordId,
virtualizationEnabled,
])
const requestOlder = useCallback((pane: HTMLDivElement) => {
if (
!hasOlderRecords
|| onLoadOlder === undefined
|| loadingOlder.current
|| pane.scrollTop > OLDER_LOAD_THRESHOLD_PX
) return
loadingOlder.current = true
setOlderLoading(true)
olderLoadAnchor.current = {
historyStartSeq,
scrollHeight: pane.scrollHeight,
scrollTop: pane.scrollTop,
}
void onLoadOlder().then((advanced) => {
if (!advanced) olderLoadAnchor.current = null
}).finally(() => {
loadingOlder.current = false
setOlderLoading(false)
})
}, [hasOlderRecords, historyStartSeq, onLoadOlder])
useLayoutEffect(() => {
const pane = tablePaneRef.current
if (pane === null) return
const anchor = olderLoadAnchor.current
if (anchor !== null && anchor.historyStartSeq !== historyStartSeq) {
if (!virtualizationEnabled) {
pane.scrollTop = anchor.scrollTop + pane.scrollHeight - anchor.scrollHeight
}
olderLoadAnchor.current = null
followsTableTail.current = false
return
}
if (!tableScrollInitialized.current) {
if (historyLoading) return
tableScrollInitialized.current = true
followsTableTail.current = true
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
else pane.scrollTop = pane.scrollHeight
setTableScrollReady(true)
return
}
if (!followsTableTail.current) return
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
else pane.scrollTop = pane.scrollHeight
}, [
historyLoading,
historyStartSeq,
rowVirtualizer,
virtualRowStructure,
virtualizationEnabled,
])
const loadingLabel = olderLoading
? 'Loading earlier history…'
: 'Loading trajectory…'
const showLoading = historyLoading || olderLoading || !tableScrollReady
return (
<div ref={rootRef} className={css.split} style={splitStyle}>
<div
ref={tablePaneRef}
className={css.tablePane}
data-trajectory-scroll=""
onScroll={(event) => {
const pane = event.currentTarget
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
requestOlder(pane)
}}
onClick={(event) => {
if (event.target === event.currentTarget) clearAllSelections()
}}
>
<table className={css.table}>
{showLoading && (
<div className={css.historyLoading} role="status" aria-live="polite">
<span className={css.historyLoadingBar}>
<span className={css.historyLoadingSpinner} aria-hidden="true" />
{loadingLabel}
</span>
</div>
)}
<table
className={css.table}
data-scroll-ready={tableScrollReady || undefined}
aria-rowcount={records.length}
>
<colgroup>
<col className={css.eventColumn} />
<col className={css.contentColumn} />
</colgroup>
<tbody>
{records.map((record) => {
{virtualTop > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true">
<td
colSpan={2}
style={{
'--trajectory-virtual-spacer-height': `${virtualTop}px`,
} as VirtualSpacerStyle}
/>
</tr>
)}
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => {
const displayText = recordDisplayText(record.cell)
const toolCallOnly = isToolCallOnly(record.cell)
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
const listDisplayText = toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const listDisplayText = toolCallOnly
? '(tool call only)'
: toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
@@ -1767,20 +2152,26 @@ export function TrajectoryTable({
const requestInfo = request === undefined
? undefined
: sessionRequestNumbers?.find(candidate => candidate.number === request)
const requestStatus = requestInfo?.status
?? (record.cell.isError === true ? 'error' : undefined)
const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0
const requestBoundaryStyle: RequestBoundaryStyle = {
'--request-boundary-offset': `${requestRunIndex * 8}px`,
}
const requestLabel = request === undefined
? undefined
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.section === record.section
&& selectedRequest.number === request
&& selectedRequest.group === record.group
const sectionActive = record.turn === null
? activeSection === record.section
: activeTurn === record.turn
return (
<tr
key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
key={trajectoryVirtualRecordKey(record)}
tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: isRequestOnly
@@ -1788,10 +2179,13 @@ export function TrajectoryTable({
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
data-kind={record.cell.kind}
data-trajectory-row-key={trajectoryVirtualRecordKey(record)}
data-virtual-position={virtualizationEnabled ? position : undefined}
data-record-index={!isCollapsedSummary && !isRequestOnly
? record.cell.index
: undefined}
data-request-only={isRequestOnly || undefined}
data-terminal-request-boundary={terminalRequestBoundary || undefined}
data-group-start={record.groupStart || undefined}
data-turn-start={record.turnStart || undefined}
data-error={record.cell.isError || undefined}
@@ -1808,7 +2202,7 @@ export function TrajectoryTable({
? () => {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
} else onToggleAssistant(trajectoryRecordId(record.cell))
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
@@ -1823,7 +2217,7 @@ export function TrajectoryTable({
&& assistantToolCalls(allRecords, record.cell.index).length > 0
) {
event.preventDefault()
onToggleAssistant(record.cell.index)
onToggleAssistant(trajectoryRecordId(record.cell))
return
}
if (!record.turnStart) return
@@ -1842,7 +2236,7 @@ export function TrajectoryTable({
if (isCollapsedSummary) {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
} else onToggleAssistant(trajectoryRecordId(record.cell))
return
}
selectRecord(record.cell.index)
@@ -1858,13 +2252,15 @@ export function TrajectoryTable({
aria-label={requestLabel}
aria-pressed={requestSelected}
data-label={requestLabel}
data-request-run-index={requestRunIndex}
data-request-status={requestStatus}
style={requestBoundaryStyle}
onClick={(event) => {
event.stopPropagation()
selectRequest({
turn: record.turn,
section: record.section,
number: request,
group: record.group,
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
})
}}
onDoubleClick={(event) => { event.stopPropagation() }}
@@ -1906,33 +2302,36 @@ export function TrajectoryTable({
<span
className={css.kindSlot}
>
<Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
>
<Tooltip
label={KIND_LABEL[record.cell.kind]}
side="right"
>
<span className={css.kindTagIcon} aria-hidden="true">
{KIND_ICON[record.cell.kind]}
</span>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</Tooltip>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</Tooltip>
</span>
</span>
)}
</div>
@@ -1955,8 +2354,8 @@ export function TrajectoryTable({
: `${listDisplayText} → ${record.cell.result}`}
>
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
{isToolCallOnly(record.cell)
? null
{toolCallOnly
? <span className={css.toolCallOnly}>(tool call only)</span>
: toolCallText === undefined
? listDisplayText || '—'
: (
@@ -1989,6 +2388,16 @@ export function TrajectoryTable({
</tr>
)
})}
{virtualBottom > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
<td
colSpan={2}
style={{
'--trajectory-virtual-spacer-height': `${virtualBottom}px`,
} as VirtualSpacerStyle}
/>
</tr>
)}
</tbody>
</table>
</div>
@@ -2083,7 +2492,7 @@ export function TrajectoryTable({
<>
<span className={css.requestDetailsDot} aria-hidden="true" />
<span className={css.requestDetailsName}>
Request #{selectedRequest.number}
Request #{selectedRequestNumber ?? '—'}
</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
@@ -2162,7 +2571,10 @@ export function TrajectoryTable({
&& selectedRequestState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedRequestState === 'error' ? css.error : undefined}>
@@ -2313,7 +2725,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedState === 'error' ? css.error : undefined}>
@@ -2330,7 +2745,10 @@ export function TrajectoryTable({
</div>
</dl>
{selected.cell.outputDetail !== undefined && (
<div className={css.compactedSummary}>
<div
className={`${css.compactedSummary} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<MarkdownRecordContent
record={selected}
rendered
@@ -2348,7 +2766,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{selected.cell.messageSource !== undefined && (
<div>
<dt>Origin</dt>
@@ -2383,7 +2804,7 @@ export function TrajectoryTable({
selectRequest(selectedAssistantRequestTarget)
}}
>
<span>Request #{selectedAssistantRequestTarget.number}</span>
<span>Request #{selectedAssistantRequest ?? '—'}</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
@@ -2470,7 +2891,7 @@ export function TrajectoryTable({
)}
{selectedAssistantRequestTarget !== undefined && (
<OverviewSection
label="Timing"
label="Request Timing"
onOpen={() => {
selectRequest(selectedAssistantRequestTarget, 'timing')
}}

View File

@@ -1,12 +1,16 @@
.root {
position: relative;
z-index: 0;
z-index: 1;
isolation: isolate;
flex: none;
border-bottom: 1px solid var(--dsw-alias-border-l2);
user-select: none;
}
.root :global([role='tooltip']) {
font: var(--dsw-font-xxxs-11);
}
.plot {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
@@ -53,6 +57,50 @@
touch-action: none;
}
.track[data-panning='true'] {
cursor: grabbing;
}
.earlierHistory {
position: absolute;
z-index: 5;
top: 0;
bottom: 0;
left: 0;
display: flex;
width: 28px;
align-items: center;
justify-content: flex-start;
appearance: none;
box-sizing: border-box;
padding-left: 3px;
border: 0;
outline: none;
background: linear-gradient(
to right,
var(--dsw-alias-bg-layer-2) 0,
var(--dsw-alias-bg-layer-2) 38%,
transparent 100%
);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
line-height: 1;
opacity: 0.72;
cursor: pointer;
}
.earlierHistory:hover {
opacity: 1;
}
.earlierHistory[aria-disabled='true'] {
cursor: default;
}
.earlierHistory:focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2);
}
.empty {
position: absolute;
top: 50%;
@@ -105,8 +153,15 @@
.span {
position: absolute;
top: calc(var(--trajectory-span-lane) * 14px);
left: calc(var(--trajectory-span-left) + 1px);
width: max(2px, calc(var(--trajectory-span-width) - 2px));
left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap));
width: max(
2px,
calc(
var(--trajectory-span-width)
- var(--trajectory-span-gap)
- var(--trajectory-span-gap)
)
);
height: 8px;
min-width: 2px;
border-radius: 1px;
@@ -127,23 +182,35 @@
}
.span[data-timeline-span='message'] {
background: color-mix(
--trajectory-assistant-decoding-color: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
var(--dsw-alias-state-error-secondary)
);
}
.span[data-timeline-span='tool'] {
background: var(--dsw-alias-state-warn-label);
}
.span[data-timeline-span='subtool'] {
background: color-mix(
--trajectory-assistant-ttft-color: color-mix(
in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
var(--trajectory-assistant-decoding-color) 54%,
var(--dsw-alias-bg-layer-2)
);
background: var(--trajectory-assistant-decoding-color);
opacity: 1;
}
.span[data-timeline-span='message'][data-assistant-timing='true'] {
background: linear-gradient(
to right,
var(--trajectory-assistant-ttft-color) 0,
var(--trajectory-assistant-ttft-color) var(--trajectory-assistant-ttft),
var(--trajectory-assistant-decoding-color) var(--trajectory-assistant-ttft),
var(--trajectory-assistant-decoding-color) 100%
);
}
.span[data-timeline-span='tool'],
.span[data-timeline-span='subtool'] {
background: var(--dsw-alias-state-warn-label);
opacity: 1;
}
.span[data-error='true'] {
@@ -161,7 +228,7 @@
.span[data-hovered='true']:not([data-current='true']) {
z-index: 1;
opacity: 0.78;
opacity: 1;
box-shadow:
0 0 0 1px var(--dsw-alias-bg-layer-2),
0 0 0 2px color-mix(

View File

@@ -2,9 +2,11 @@
import {
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
type PointerEvent, type WheelEvent,
type PointerEvent,
} from 'react'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TrajectoryTurnModel } from './layout.ts'
import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
import {
deriveTrajectoryTimeline,
formatTimelineOffset,
@@ -18,6 +20,14 @@ const MINIMUM_ZOOM_OPERATIONS = 4
const EDGE_PAN_ZONE_FRACTION = 0.08
const EDGE_PAN_STEP_FRACTION = 0.025
const MAXIMUM_EDGE_PAN_PX = 32
const TIMELINE_TOOLTIP_DELAY_MS = 500
interface TimelineRecordDetail {
decodingMs?: number
durationMs?: number
startedAt?: number
ttftMs?: number
}
interface FractionRange {
start: number
@@ -29,11 +39,103 @@ interface HoverPoint {
recordIndex: number | null
}
interface PanGesture {
anchorClientX: number
anchorStart: number
moved: boolean
pannable: boolean
pointerId: number
}
function assistantTimingDetail(
metrics: AssistantMetricDetail | undefined,
): Pick<TimelineRecordDetail, 'ttftMs' | 'decodingMs'> {
const start = metrics?.stepStartTime
const first = metrics?.firstTokenTime
const completed = metrics?.completedTime
if (
metrics?.timingRecorded !== true
|| typeof start !== 'number'
|| typeof first !== 'number'
|| typeof completed !== 'number'
|| !Number.isFinite(start)
|| !Number.isFinite(first)
|| !Number.isFinite(completed)
|| first < start
|| completed < first
) return {}
return { ttftMs: first - start, decodingMs: completed - first }
}
function timelineRecordDetail(cell: TrajectoryCellProps): TimelineRecordDetail {
const durationMs = cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
? undefined
: Math.max(0, cell.timeSeconds * 1_000)
const startedAt = cell.startedAt === null || !Number.isFinite(cell.startedAt)
? undefined
: cell.startedAt
return {
...(durationMs === undefined ? {} : { durationMs }),
...(startedAt === undefined ? {} : { startedAt }),
...assistantTimingDetail(cell.assistantMetrics),
}
}
function timelineKindLabel(kind: TrajectoryCellKind): string {
switch (kind) {
case 'system': return 'SYSTEM'
case 'user': return 'USER'
case 'context': return 'CONTEXT'
case 'compacted': return 'COMPACTED'
case 'message': return 'ASSISTANT'
case 'tool': return 'TOOL'
case 'subtool': return 'SUBTOOL'
}
}
function formatRecordedTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
})
}
function timelineTooltipLabel(
kind: TrajectoryCellKind,
detail: TimelineRecordDetail | undefined,
): string {
const heading = timelineKindLabel(kind)
if (detail === undefined) return heading
const duration = detail.durationMs === undefined
? null
: `Total ${formatTimelineOffset(detail.durationMs)}`
const range = detail.startedAt === undefined
? null
: detail.durationMs === undefined
? `Started ${formatRecordedTime(detail.startedAt)}`
: `${formatRecordedTime(detail.startedAt)} → ${formatRecordedTime(
detail.startedAt + detail.durationMs,
)}`
const segments = detail.ttftMs === undefined || detail.decodingMs === undefined
? null
: `TTFT ${formatTimelineOffset(detail.ttftMs)} · Decoding ${formatTimelineOffset(
detail.decodingMs,
)}`
const timing = [duration, segments].filter(value => value !== null).join(' · ')
return [heading, range, timing].filter(value => value !== null && value !== '').join('\n')
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
mode: TrajectoryTimelineMode
range: TrajectoryTimeRange | null
/** Whether the loaded timeline omits an earlier history prefix. */
hasEarlierRecords?: boolean
/** Load one earlier history page from the truncation control. */
onLoadEarlier?: () => Promise<boolean>
selectedIndex?: number | null
/** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null
@@ -70,11 +172,17 @@ function rangeFraction(
range: TrajectoryTimeRange,
start: number,
duration: number,
minimum: number,
maximum: number,
): FractionRange {
return orderedRange(
clampFraction((range.start - start) / duration),
clampFraction((range.end - start) / duration),
const bounded = orderedRange(
Math.min(maximum, Math.max(minimum, range.start)),
Math.min(maximum, Math.max(minimum, range.end)),
)
return {
start: (bounded.start - start) / duration,
end: (bounded.end - start) / duration,
}
}
function LaneLabels() {
@@ -87,11 +195,49 @@ function LaneLabels() {
)
}
function EarlierHistoryBoundary({
loading,
onHover,
onLoad,
}: {
loading: boolean
onHover: () => void
onLoad: (() => void) | undefined
}) {
return (
<Tooltip
label={loading ? 'Loading earlier history…' : 'Click to load earlier history'}
side="right"
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
>
<button
type="button"
className={css.earlierHistory}
data-earlier-history
data-loading={loading || undefined}
aria-label={loading ? 'Loading earlier history' : 'Load earlier history'}
aria-disabled={loading || onLoad === undefined}
onClick={onLoad}
onPointerEnter={(event) => {
event.stopPropagation()
onHover()
}}
onPointerMove={(event) => { event.stopPropagation() }}
onPointerDown={(event) => { event.stopPropagation() }}
>
…
</button>
</Tooltip>
)
}
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns,
mode,
range,
hasEarlierRecords = false,
onLoadEarlier,
selectedIndex = null,
searchMatchIndexes = null,
onRangeChange,
@@ -99,14 +245,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onRecordFocus,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
const durationByIndex = useMemo(
const detailByIndex = useMemo(
() => new Map(turns.flatMap(turn =>
turn.groups.flatMap(group =>
group.cells.flatMap(cell =>
cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
? []
: [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const],
),
group.cells.map(cell => [cell.index, timelineRecordDetail(cell)] as const),
),
)),
[turns],
@@ -117,8 +259,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
anchorClientX: number
recordIndex: number | null
} | null>(null)
const panRef = useRef<PanGesture | null>(null)
const rootRef = useRef<HTMLElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const [panning, setPanning] = useState(false)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
useEffect(() => {
@@ -174,6 +321,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
const showsEarlierBoundary = hasEarlierRecords
&& model !== null
&& domainStart === model.start
const loadEarlier = onLoadEarlier === undefined || loadingEarlier
? undefined
: () => {
setLoadingEarlier(true)
void onLoadEarlier().finally(() => { setLoadingEarlier(false) })
}
const projectedDomainStyle = model === null
? undefined
: {
@@ -183,20 +339,59 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
} as CSSProperties
const committed = model === null || range === null
? null
: rangeFraction(range, domainStart, domainDuration)
: rangeFraction(range, domainStart, domainDuration, model.start, model.end)
const draftFraction = model === null || draft === null
? null
: rangeFraction(draft, domainStart, domainDuration)
: rangeFraction(draft, domainStart, domainDuration, model.start, model.end)
const visibleRange = draftFraction ?? committed
const activeRange = draft ?? range
useEffect(() => {
const root = rootRef.current
if (root === null) return
const onWheel = (event: globalThis.WheelEvent): void => {
event.preventDefault()
const track = trackRef.current
if (track === null || model === null) return
setAnimateViewport(false)
const rect = track.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 })
}
root.addEventListener('wheel', onWheel, { passive: false })
return () => { root.removeEventListener('wheel', onWheel) }
}, [domainDuration, domainStart, fullDuration, mode, model])
if (model === null) {
return (
<section className={css.root} aria-label="Trajectory timeline">
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
<div className={css.plot}>
<LaneLabels />
<div className={css.track}>
<span className={css.empty}>No timing data</span>
{hasEarlierRecords && (
<EarlierHistoryBoundary
loading={loadingEarlier}
onHover={() => { setHover(null) }}
onLoad={loadEarlier}
/>
)}
</div>
</div>
</section>
@@ -227,6 +422,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button === 2) {
panRef.current = {
anchorClientX: event.clientX,
anchorStart: domainStart,
moved: false,
pannable: viewport !== null,
pointerId: event.pointerId,
}
if (viewport !== null) setAnimateViewport(false)
setPanning(true)
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
return
}
if (event.button !== 0) return
const anchor = fractionAt(event)
const anchorTime = domainStart + anchor * domainDuration
@@ -245,10 +455,24 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
const rect = event.currentTarget.getBoundingClientRect()
const fraction = fractionAt(event)
setHover({ fraction, recordIndex: recordIndexAt(event) })
const pan = panRef.current
if (pan !== null && pan.pointerId === event.pointerId) {
if (Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX) {
pan.moved = true
}
if (!pan.pannable) return
const delta = (event.clientX - pan.anchorClientX) / Math.max(1, rect.width)
const nextStart = Math.min(
Math.max(pan.anchorStart - delta * domainDuration, model.start),
model.end - domainDuration,
)
setViewport({ start: nextStart, end: nextStart + domainDuration })
return
}
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
let nextDomainStart = domainStart
if (viewport !== null) {
@@ -286,6 +510,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const pan = panRef.current
if (pan !== null && pan.pointerId === event.pointerId) {
const moved = pan.moved
|| Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX
panRef.current = null
setPanning(false)
if (!moved) onRangeChange(null)
return
}
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
const pointFraction = fractionAt(event)
@@ -335,41 +568,20 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const onPointerCancel = () => {
dragRef.current = null
panRef.current = null
setDraft(null)
setHover(null)
}
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
setAnimateViewport(false)
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 })
setPanning(false)
}
return (
<section className={css.root} aria-label="Trajectory timeline">
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
<div className={css.plot}>
<LaneLabels />
<div
ref={trackRef}
className={css.track}
data-panning={panning || undefined}
aria-label="Timeline overview; drag horizontally to focus events"
tabIndex={0}
onKeyDown={onKeyDown}
@@ -378,20 +590,23 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onPointerUp={onPointerEnd}
onPointerCancel={onPointerCancel}
onPointerLeave={() => {
if (dragRef.current === null) setHover(null)
if (dragRef.current === null && panRef.current === null) setHover(null)
}}
onDoubleClick={(event) => {
event.preventDefault()
onRangeChange(null)
}}
onWheel={onWheel}
onContextMenu={(event) => {
event.preventDefault()
setAnimateViewport(false)
onRangeChange(null)
setViewport(null)
}}
>
{showsEarlierBoundary && (
<EarlierHistoryBoundary
loading={loadingEarlier}
onHover={() => { setHover(null) }}
onLoad={loadEarlier}
/>
)}
{hover !== null && hover.recordIndex === null && draft === null && (
<div
className={css.hoverLine}
@@ -451,7 +666,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
className={css.lanes}
data-animate-viewport={animateViewport || undefined}
data-timeline-domain
aria-hidden="true"
style={projectedDomainStyle}
>
{model.spans
@@ -461,34 +675,51 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
.map((span) => {
const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / fullDuration
const durationMs = durationByIndex.get(span.index)
const widthPercent = width * 100
const detail = detailByIndex.get(span.index)
const ttftMs = detail?.ttftMs
const decodingMs = detail?.decodingMs
const ttftFraction = ttftMs === undefined
|| decodingMs === undefined
|| ttftMs + decodingMs <= 0
? null
: ttftMs / (ttftMs + decodingMs)
return (
<span
className={css.span}
data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
data-selected={activeRange === null
? undefined
: span.start <= activeRange.end && span.end >= activeRange.start
? 'true'
: 'false'}
<Tooltip
key={span.index}
title={durationMs === undefined
? span.label
: `${span.label} · ${formatTimelineOffset(durationMs)}`}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
'--trajectory-span-lane': span.lane,
} as CSSProperties}
/>
label={timelineTooltipLabel(span.kind, detail)}
side="bottom"
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
>
<span
aria-hidden="true"
className={css.span}
data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-assistant-timing={ttftFraction === null ? undefined : 'true'}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
data-selected={activeRange === null
? undefined
: span.start <= activeRange.end && span.end >= activeRange.start
? 'true'
: 'false'}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${widthPercent}%`,
'--trajectory-span-gap': `min(${widthPercent * 0.08}%, 1px)`,
'--trajectory-span-lane': span.lane,
...(ttftFraction === null
? {}
: { '--trajectory-assistant-ttft': `${ttftFraction * 100}%` }),
} as CSSProperties}
/>
</Tooltip>
)
})}
</div>

View File

@@ -149,7 +149,7 @@
font: var(--dsw-font-xxs-12);
}
.action:hover:not(:disabled) {
.action:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -159,11 +159,6 @@
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);

View File

@@ -8,18 +8,14 @@ export interface TrajectoryToolbarProps {
actualDuration: boolean
/** Select recorded-duration or equal-width blocks. */
onActualDurationChange: (actualDuration: boolean) => void
/** Whether recorded timing retains idle gaps between user turns. */
/** Whether recorded timing retains idle gaps between operations. */
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. */
@@ -40,10 +36,8 @@ export function TrajectoryToolbar({
onActualDurationChange,
actualTime,
onActualTimeChange,
collapsibleTurns,
allTurnsCollapsed,
onToggleAllTurns,
collapsibleAssistants,
allAssistantsCollapsed,
onToggleAllAssistants,
searchQuery,
@@ -91,7 +85,6 @@ export function TrajectoryToolbar({
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">
@@ -105,7 +98,6 @@ export function TrajectoryToolbar({
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">

View File

@@ -4,8 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantMessageNode, ConversationContext,
SessionHistoryFace,
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
SessionHistoryFace, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
@@ -17,20 +17,62 @@ import {
} from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import {
appendTrajectoryPartialLayout, deriveTrajectoryLayout,
type TrajectoryTurnModel,
} from './layout.ts'
import {
trajectoryTimelineFocusIndexes,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
let last = 0
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) last = Math.max(last, cell.index)
}
}
return last
}
function timelineBlock(block: AssistantBlock): AssistantBlock {
switch (block.kind) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
case 'image': return block
case 'tool-call': return {
kind: 'tool-call',
callId: block.callId,
name: block.name,
argsRaw: '',
}
case 'other': return { kind: 'other', block: null }
}
}
function partialStructureSignature(partial: ConversationSnapshot['partial']): string {
if (partial === null) return ''
return partial.blocks.map(block => block.kind === 'tool-call'
? `${block.kind}:${block.callId}:${block.name}`
: block.kind).join('\u0000')
}
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
hooks: { history: SessionHistoryFace }
loadAllHistory: (signal: AbortSignal) => Promise<void>
hooks: {
history: SessionHistoryFace
duration: SnapshotStore<boolean>
}
loadHistoryTail: (signal: AbortSignal) => Promise<void>
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
setActualDuration: (actualDuration: boolean) => void
}
interface UsageLike {
@@ -133,43 +175,62 @@ function searchMatches(
return matches
}
function mergeSearchMatches(
finalized: ReadonlySet<number> | null,
partial: ReadonlySet<number> | null,
): ReadonlySet<number> | null {
if (finalized === null || partial === null) return null
return new Set([...finalized, ...partial])
}
export function TrajectoryView({
useHistory, loadAllHistory, inspect, onInspectDone,
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration,
inspect, onInspectDone,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchId: number
branchKey: string
range: TrajectoryTimeRange
} | null>(null)
const [actualDuration, setActualDuration] = useState(false)
const actualDuration = useDuration(value => value)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
} | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
readonly index: number
} | null>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const historyLoading = useHistory(snapshot =>
snapshot.state === 'cold' || snapshot.state === 'loading')
const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq)
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadAllHistoryRef = useRef(loadAllHistory)
loadAllHistoryRef.current = loadAllHistory
const loadHistoryTailRef = useRef(loadHistoryTail)
loadHistoryTailRef.current = loadHistoryTail
const historyControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadAllHistoryRef.current(controller.signal)
historyControllerRef.current = controller
void loadHistoryTailRef.current(controller.signal)
return () => { controller.abort() }
}, [])
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const historyContexts = inspection.contexts
const interruptedNodes = inspection.interruptedNodes
const contexts = useMemo<readonly ConversationContext[]>(
() => inspection.contexts.length === 0
() => historyContexts.length === 0
? [{ id: 0, nodes }]
: inspection.contexts,
[inspection, nodes],
: historyContexts,
[historyContexts, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
@@ -179,18 +240,18 @@ export function TrajectoryView({
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) {
for (const node of interruptedNodes) {
selected.set(node.seq, node)
}
return [...selected.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch, inspection])
}, [currentBranch.nodes, interruptedNodes])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
@@ -291,63 +352,74 @@ export function TrajectoryView({
})
}
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,
contexts, nodes, requests,
])
const requestNumbers = globalRequestNumbers
const turns = useMemo(
() => deriveTrajectoryLayout({
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null
const finalized = useMemo(() => {
const turns = deriveTrajectoryLayout({
nodes: selectedNodes,
partial,
partial: partialTurn === null || partialStep === null
? null
: { turn: partialTurn, step: partialStep, blocks: [] },
runningCalls,
requests: selectedRequests,
callSchemas,
codeDispatches,
}),
[
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
],
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas, codeDispatches,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
? null
: {
turn: partial.turn,
step: partial.step,
blocks: partial.blocks.map(block => timelineBlock(block)),
},
[partialStep, partialTurn, timelinePartialSignature])
const timelineTurns = useMemo(
() => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex),
[finalized, timelinePartial],
)
const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration'
: actualTime ? 'time' : 'sequence'
const searchMatchIndexes = useMemo(
() => searchMatches(turns, searchQuery),
[searchQuery, turns],
const finalizedSearchMatches = useMemo(
() => searchMatches(finalized.turns, searchQuery),
[finalized, searchQuery],
)
const timelineRange = timelineSelection?.branchId === currentBranch.id
const partialSearchTurns = useMemo(
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
[finalized.lastIndex, partial],
)
const streamingCells = useMemo(
() => partialSearchTurns.flatMap(turn =>
turn.groups.flatMap(group => group.cells),
),
[partialSearchTurns],
)
const partialSearchMatches = useMemo(
() => searchMatches(partialSearchTurns, searchQuery),
[partialSearchTurns, searchQuery],
)
const searchMatchIndexes = useMemo(
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
[finalizedSearchMatches, partialSearchMatches],
)
const timelineRange = timelineSelection?.branchKey === currentBranch.key
? timelineSelection.range
: null
const timelineFocusIndexes = useMemo(
() => timelineRange === null
? null
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
[timelineMode, timelineRange, turns],
: trajectoryTimelineFocusIndexes(timelineTurns, timelineRange, timelineMode),
[timelineMode, timelineRange, timelineTurns],
)
const handleRecordSelect = useCallback((index: number) => {
if (
@@ -357,31 +429,22 @@ export function TrajectoryView({
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 handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => {
setTimelineSelection(range === null ? null : {
branchKey: currentBranch.key,
range,
})
}, [currentBranch.key])
const handleTimelineRecordSelect = useCallback((index: number) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(index)
}, [])
const handleTimelineRecordFocus = useCallback((index: number) => {
setTimelineRecordFocus({ index })
}, [])
const collapsibleTurnIds = useMemo(
() => turns
() => timelineTurns
.filter(turn =>
turn.turn !== null
&&
@@ -392,23 +455,25 @@ export function TrajectoryView({
0,
) > 1)
.flatMap(turn => turn.turn === null ? [] : [turn.turn]),
[turns],
[timelineTurns],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = []
for (const turn of turns) {
const ids: string[] = []
for (const turn of timelineTurns) {
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)
if (next?.kind === 'tool' || next?.kind === 'subtool') {
ids.push(trajectoryRecordId(cell))
}
}
}
return ids
}, [turns])
}, [timelineTurns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
@@ -433,11 +498,11 @@ export function TrajectoryView({
})
}
const toggleAssistant = (index: number) => {
const toggleAssistant = (id: string) => {
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index)
if (collapsed.has(id)) collapsed.delete(id)
else collapsed.add(id)
return collapsed
})
}
@@ -454,8 +519,15 @@ export function TrajectoryView({
})
}
const loadEarlierHistory = useCallback(() => {
const signal = historyControllerRef.current?.signal
return signal?.aborted === false
? loadOlderHistory(signal)
: Promise.resolve(false)
}, [loadOlderHistory])
return (
<div className={css.root}>
<div className={css.root} data-conversation-composer-overlay="">
<TrajectoryToolbar
actualDuration={actualDuration}
onActualDurationChange={(nextActualDuration) => {
@@ -467,55 +539,41 @@ export function TrajectoryView({
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}
turns={timelineTurns}
mode={timelineMode}
range={timelineRange}
hasEarlierRecords={hasOlderHistory}
onLoadEarlier={loadEarlierHistory}
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : {
branchId: currentBranch.id,
range,
})
}}
onRecordSelect={(index) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(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' })
}
}}
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' })
}
}}
onRangeChange={handleTimelineRangeChange}
onRecordSelect={handleTimelineRecordSelect}
onRecordFocus={handleTimelineRecordFocus}
/>
<div ref={ledgerRef} className={css.ledger}>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
key={currentBranch.key}
requestNumbers={requestNumbers}
turns={turns}
turns={timelineTurns}
streamingCells={streamingCells}
timelineFocusIndexes={timelineFocusIndexes}
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
recordSelection={timelineRecordSelection}
recordFocus={timelineRecordFocus}
historyLoading={historyLoading}
historyStartSeq={historyBaseSeq}
hasOlderRecords={hasOlderHistory}
onLoadOlder={loadEarlierHistory}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}

View File

@@ -7,6 +7,8 @@ import type {
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
export interface TrajectoryContextBranch {
id: number
/** Identity stable when older context generations are prepended. */
key: string
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
@@ -18,6 +20,7 @@ export interface TrajectoryContextBranch {
interface MutableBranch {
id: number
key: string
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
@@ -63,6 +66,9 @@ export function deriveTrajectoryContextBranches(
)
mutable.push({
id: context.id,
key: context.origin === 'rewind' && context.originSeq !== undefined
? `rewind:${context.originSeq}`
: 'root',
contexts: [context],
latest: context,
nodes: new Map(
@@ -84,6 +90,7 @@ export function deriveTrajectoryContextBranches(
}
return mutable.map(branch => ({
id: branch.id,
key: branch.key,
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),

View File

@@ -0,0 +1,13 @@
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
/**
* Create the browser-wide trajectory duration preference source.
* @returns a persisted source shared by every session view in one plugin lifecycle.
*/
export function createTrajectoryDurationStore(): SnapshotStore<boolean> {
return createSnapshotStore(false, {
persist: { name: 'dsh.trajectory.duration' },
})
}

View File

@@ -7,16 +7,11 @@ 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 { createTrajectoryDurationStore } from './duration-store.ts'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
* edge, not a call dependency: the 'conversation.view' slot is declared by
* ui-conversation's apply (which then provides the service), and register()
* into an undeclared slot throws — service waiting is what orders this
* apply after the declaring one.
*/
export const inject = ['slots', 'conversation', 'sessionHistory']
/** Required services: the conversation view slot and independent history source. */
export const inject = ['slots', 'sessionHistory']
/**
* Client plugin body: register the trajectory view tab. The registration
@@ -24,7 +19,8 @@ export const inject = ['slots', 'conversation', 'sessionHistory']
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
ctx.slots.register({
const duration = createTrajectoryDurationStore()
ctx.slots.inject('conversation.view', () => ctx.slots.register({
name: 'conversation.view',
id: 'trajectory',
order: 10,
@@ -32,9 +28,11 @@ export function apply(ctx: Context): void {
inject: (sessionId: SessionId): TrajectoryViewInjected => {
const history = ctx.sessionHistory.source(sessionId)
return {
hooks: { history },
loadAllHistory: signal => history.loadAll(signal),
hooks: { history, duration },
loadHistoryTail: signal => history.loadTail(signal),
loadOlderHistory: signal => history.loadOlder(signal),
setActualDuration: (value) => { duration.set(value) },
}
},
}, TrajectoryView)
}, TrajectoryView))
}

View File

@@ -17,6 +17,7 @@ import type {
TrajectoryCellProps,
TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
/** One Message or Step group inside a turn. */
export interface TrajectoryGroupModel {
@@ -75,7 +76,7 @@ const PREVIEW_OUTPUT_CHARACTERS = 512
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'steering' | 'context' }
{ kind: 'user' | 'context' }
>
type OrderedLayoutEntry =
@@ -325,19 +326,17 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
const { node, nodeIndex: i } = entry
if (node.kind === 'user' || node.kind === 'steering') {
if (node.kind === 'user') {
// user/message has no turn on the wire; enclose it in the next assistant
// (or partial) turn, else open the turn after the last assistant.
const turn = node.kind === 'steering'
? node.turn
: enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
index: ++index,
kind: 'user',
...inputCellDetail(node),
opensTurn: node.kind === 'user',
opensTurn: true,
},
})
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
@@ -453,7 +452,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
else for (const laid of laidList) pushMessage(call.turn, laid)
}
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
// Orphan turn-0 cells (orphaned tools) fold into Turn 1.
const prologue = turns.get(0)
if (prologue !== undefined) {
turns.delete(0)
@@ -475,6 +474,67 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
].sort((left, right) => firstCellIndex(left) - firstCellIndex(right))
}
/**
* Append the changing in-flight assistant cells to a stable finalized layout.
* @param turns - Finalized layout derived with an empty-block partial anchor.
* @param partial - Current in-flight assistant projection.
* @param lastIndex - Highest cell index in the finalized layout.
* @returns The original layout without a partial, otherwise a layout sharing every unaffected turn.
*/
export function appendTrajectoryPartialLayout(
turns: readonly TrajectoryTurnModel[],
partial: ConversationSnapshot['partial'],
lastIndex: number,
): readonly TrajectoryTurnModel[] {
if (partial === null) return turns
const partialTurn = deriveTrajectoryLayout({
nodes: [],
partial,
runningCalls: [],
codeDispatches: new Map(),
}).at(0)
if (partialTurn === undefined) return turns
const streamed: TrajectoryTurnModel = {
...partialTurn,
groups: partialTurn.groups.map(group => ({
...group,
cells: group.cells.map(cell => ({ ...cell, index: cell.index + lastIndex })),
})),
}
const turnIndex = turns.findIndex(turn => turn.turn === streamed.turn)
if (turnIndex === -1) return [...turns, streamed]
const current = turns[turnIndex]
/* v8 ignore next -- findIndex proved the dense array position exists. */
if (current === undefined) return turns
const groups = [...current.groups]
for (const streamedGroup of streamed.groups) {
const groupIndex = groups.findIndex(group => group.title === streamedGroup.title)
if (groupIndex === -1) {
groups.push(streamedGroup)
continue
}
const group = groups[groupIndex]
/* v8 ignore next -- findIndex proved the dense array position exists. */
if (group === undefined) continue
const streamedCallIds = new Set(
streamedGroup.cells.flatMap(cell => cell.callId === undefined ? [] : [cell.callId]),
)
groups[groupIndex] = {
...streamedGroup,
cells: [
...group.cells.filter(cell =>
cell.requestOnly !== true
&& (cell.callId === undefined || !streamedCallIds.has(cell.callId)),
),
...streamedGroup.cells,
],
}
}
const updated = [...turns]
updated[turnIndex] = { ...current, groups }
return updated
}
function attachToolSchema(
laid: LaidCell,
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
@@ -542,9 +602,7 @@ 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`
return formatElapsedSeconds(seconds)
}
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
@@ -566,6 +624,7 @@ function expandAssistant(
callStarts: ReadonlyMap<string, number>,
opts?: { streaming?: boolean },
): LaidCell[] {
if (opts?.streaming === true && node.blocks.length === 0) return []
const out: LaidCell[] = []
let index = startIndex - 1
const usage = node.usage as UsageLike | undefined
@@ -585,6 +644,7 @@ function expandAssistant(
.join('\n\n')
const message: TrajectoryCellProps = {
index: ++index,
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
kind: 'message',
sourceSeq: node.seq,
text: messageText !== ''
@@ -739,7 +799,7 @@ function stringifySourceValue(value: unknown): string {
}
/**
* Turn that encloses a user/message: next assistant/steering turn, else the
* Turn that encloses a user/message: next assistant turn, else the
* in-flight partial, else the turn after the last finalized assistant (or 1).
*/
function enclosingUserTurn(
@@ -752,7 +812,7 @@ function enclosingUserTurn(
const n = nodes[i]
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
if (n === undefined) continue
if (n.kind === 'assistant' || n.kind === 'steering') return n.turn
if (n.kind === 'assistant') return n.turn
}
if (partial !== null) return partial.turn
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
@@ -776,7 +836,7 @@ function firstVisibleTurn(
partial: ConversationSnapshot['partial'],
): number {
const turns = nodes.flatMap(node =>
(node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0
node.kind === 'assistant' && node.turn > 0
? [node.turn]
: [],
)

View File

@@ -1,6 +1,7 @@
/** Operation-sequence and recorded-time projections for the trajectory overview. */
import type { TrajectoryTurnModel } from './layout.ts'
import { formatDurationMillis } from './trajectory-record.ts'
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
/** Horizontal projection used by the trajectory timeline. */
@@ -34,14 +35,12 @@ export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
}
/**
* Format a timeline duration with a compact unit.
* Format a timeline duration as an integer-millisecond label.
* @param milliseconds - Non-negative duration in milliseconds.
* @returns Millisecond or second label.
* @returns Millisecond label with thousands separators.
*/
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`
return formatDurationMillis(milliseconds)
}
function laneFor(kind: TrajectoryCellKind): number {
@@ -116,14 +115,9 @@ export function deriveTrajectoryTimeline(
function deriveTimedTimeline(
turns: readonly TrajectoryTurnModel[],
actualDuration: boolean,
removeUserIdle: boolean,
compressIdle: boolean,
): TrajectoryTimelineModel | null {
const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
let removedUserIdle = 0
let previousTurnEnd: number | null = null
for (const turn of turns) {
const timedTurns = turns.flatMap((turn) => {
const rawSpans = turn.groups.flatMap(group =>
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
if (cell.requestOnly === true) return []
@@ -140,30 +134,43 @@ function deriveTimedTimeline(
}]
}),
)
if (rawSpans.length === 0) continue
return rawSpans.length === 0 ? [] : [{ turn: turn.turn, rawSpans }]
})
const rawSpans = timedTurns.flatMap(turn => turn.rawSpans)
if (rawSpans.length === 0) return null
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)
const removedIdleBySpan = new Map<TrajectoryTimelineSpan, number>()
let removedIdle = 0
let coveredUntil: number | null = null
for (const span of [...rawSpans].sort((left, right) =>
left.start - right.start || left.end - right.end)) {
if (compressIdle && coveredUntil !== null && span.start > coveredUntil) {
removedIdle += span.start - coveredUntil
}
spans.push(...rawSpans.map(span => ({
...span,
start: span.start - removedUserIdle,
end: (actualDuration ? span.end : span.start) - removedUserIdle,
})))
removedIdleBySpan.set(span, removedIdle)
coveredUntil = coveredUntil === null ? span.end : Math.max(coveredUntil, span.end)
}
const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
for (const turn of timedTurns) {
const projected = turn.rawSpans.map((span): TrajectoryTimelineSpan => {
const offset = removedIdleBySpan.get(span) ?? 0
return {
...span,
start: span.start - offset,
end: (actualDuration ? span.end : span.start) - offset,
}
})
spans.push(...projected)
if (turn.turn !== null) {
turnBoundaries.push({
turn: turn.turn,
time: turnStart - removedUserIdle,
time: Math.min(...projected.map(span => span.start)),
})
}
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)),

View File

@@ -37,6 +37,8 @@ export interface TrajectorySourceBlock {
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** 1-based record index shown as `#N`. */
index: number
/** Projection-stable identity when no single source event owns the record lifecycle. */
recordId?: string
kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */
text: string
@@ -92,13 +94,32 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
}
/**
* Format own-duration for the trailing time column.
* Resolve the identity that survives prepending older projected records.
* @param cell - Projected trajectory record.
* @returns Stable identity from the owning event or tool call, with a fixture fallback.
*/
export function trajectoryRecordId(cell: TrajectoryCellProps): string {
if (cell.recordId !== undefined) return cell.recordId
if (cell.callId !== undefined) return `${cell.kind}\u0000call\u0000${cell.callId}`
if (cell.sourceSeq !== undefined) return `${cell.kind}\u0000seq\u0000${cell.sourceSeq}`
return `${cell.kind}\u0000index\u0000${cell.index}`
}
/**
* Format a duration in milliseconds with thousands separators.
* @param milliseconds - Duration in milliseconds, or `null` when absent.
* @returns `—` when unknown, otherwise an integer-millisecond label.
*/
export function formatDurationMillis(milliseconds: number | null): string {
if (milliseconds === null || !Number.isFinite(milliseconds)) return '—'
return `${Math.round(milliseconds).toLocaleString('en-US')} ms`
}
/**
* Format an elapsed duration given in seconds as a millisecond label.
* @param seconds - Duration seconds, or `null` when absent.
* @returns `—` when unknown, otherwise a seconds label.
* @returns `—` when unknown, otherwise an integer-millisecond 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`
return formatDurationMillis(seconds === null ? null : seconds * 1000)
}

View File

@@ -0,0 +1,83 @@
/** Pure projection from trajectory records to measurable virtual ledger rows. */
import type { TrajectoryCellProps } from './trajectory-record.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
const CONTENT_ROW_HEIGHT = 30
const COLLAPSED_SUMMARY_HEIGHT = 20
const TERMINAL_BOUNDARY_HEIGHT = 9
/** Minimal record shape required by the trajectory virtual-row projection. */
export interface VirtualizableTrajectoryRecord {
cell: TrajectoryCellProps
collapsedSummaryKind?: 'turn' | 'assistant'
}
/** One logical record retained inside a measurable virtual row. */
export interface TrajectoryVirtualRowEntry<T extends VirtualizableTrajectoryRecord> {
logicalIndex: number
record: T
}
/** One virtualizer item, which may carry zero-height request boundaries. */
export interface TrajectoryVirtualRow<T extends VirtualizableTrajectoryRecord> {
entries: readonly TrajectoryVirtualRowEntry<T>[]
height: number
key: string
}
/**
* Derive the DOM-safe row identity shared by React, the virtualizer, and
* browser scroll contracts.
* @param record - Display record whose identity is required.
* @returns Stable record identity with a suffix for synthetic fold summaries.
*/
export function trajectoryVirtualRecordKey(
record: VirtualizableTrajectoryRecord,
): string {
const identity = encodeURIComponent(trajectoryRecordId(record.cell))
return record.collapsedSummaryKind === undefined
? identity
: `${identity}\u0000summary\u0000${record.collapsedSummaryKind}`
}
/**
* Attach separator-only records to the next content row so the virtualizer
* never owns a zero-height item. A terminal separator retains its CSS-owned
* lower-marker clearance as a standalone item.
* @param records - Final search/fold projection in ledger order.
* @returns Measurable virtual rows with original logical positions retained.
*/
export function groupTrajectoryVirtualRows<T extends VirtualizableTrajectoryRecord>(
records: readonly T[],
): readonly TrajectoryVirtualRow<T>[] {
const rows: TrajectoryVirtualRow<T>[] = []
let pending: TrajectoryVirtualRowEntry<T>[] = []
for (const [logicalIndex, record] of records.entries()) {
const entry = { logicalIndex, record }
if (record.cell.requestOnly === true) {
pending.push(entry)
continue
}
const entries = [...pending, entry]
pending = []
rows.push({
entries,
height: record.collapsedSummaryKind === undefined
? CONTENT_ROW_HEIGHT
: COLLAPSED_SUMMARY_HEIGHT,
key: trajectoryVirtualRecordKey(record),
})
}
if (pending.length > 0) {
rows.push({
entries: pending,
height: TERMINAL_BOUNDARY_HEIGHT,
key: pending.map(candidate => trajectoryVirtualRecordKey(candidate.record)).join('|'),
})
}
return rows
}

View File

@@ -13,23 +13,16 @@
background: var(--dsw-alias-bg-layer-1);
}
/* Trajectory keeps the ledger and details panel inside the remaining
* conversation height. Only the ledger pane scrolls; the composer remains
* the fixed flex sibling below this view. */
:global([data-conversation-scroll]):has(.root) {
overflow: hidden;
}
:global([data-conversation-scroll]):has(.root) > :first-child {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.ledger {
position: relative;
z-index: 0;
isolation: isolate;
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
/* ConversationRoot publishes the floating composer's live height. */
--dsh-trajectory-bottom-clearance: calc(var(--dsh-composer-height, 152px) + 16px);
}