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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
13
packages/client/ui-trajectory/src/client/duration-store.ts
Normal file
13
packages/client/ui-trajectory/src/client/duration-store.ts
Normal 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' },
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
: [],
|
||||
)
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user