|
|
@@ -0,0 +1,2329 @@
|
|
|
+/** Turn-aware trajectory event ledger with a local record inspector. */
|
|
|
+
|
|
|
+import { useEffect, useRef, useState } from 'react'
|
|
|
+import type { CSSProperties, ReactNode } from 'react'
|
|
|
+import {
|
|
|
+ extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
|
|
|
+} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
+import { structuredPatch } from 'diff'
|
|
|
+import type {
|
|
|
+ AssistantRequestConfig, ConversationPromptSnapshot,
|
|
|
+} from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
+import type {
|
|
|
+ AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
|
|
|
+} from './trajectory-record.ts'
|
|
|
+import { formatElapsedSeconds } from './trajectory-record.ts'
|
|
|
+import type { TrajectoryTurnModel } from './layout.ts'
|
|
|
+import css from './TrajectoryTable.module.css'
|
|
|
+
|
|
|
+const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
|
|
+ system: 'SYSTEM',
|
|
|
+ user: 'USER',
|
|
|
+ context: 'CONTEXT',
|
|
|
+ compacted: 'COMPACTED',
|
|
|
+ message: 'ASSISTANT',
|
|
|
+ tool: 'TOOL',
|
|
|
+ subtool: 'SUBTOOL',
|
|
|
+}
|
|
|
+
|
|
|
+interface TableRecord {
|
|
|
+ turn: number
|
|
|
+ group: string
|
|
|
+ groupStart: boolean
|
|
|
+ turnStart: boolean
|
|
|
+ cell: TrajectoryCellProps
|
|
|
+ turnEnd: boolean
|
|
|
+ collapsedSummary?: string
|
|
|
+ collapsedSummaryKind?: 'turn' | 'assistant'
|
|
|
+}
|
|
|
+
|
|
|
+type DetailTab =
|
|
|
+ | 'system-prompt'
|
|
|
+ | 'tools'
|
|
|
+ | 'overview'
|
|
|
+ | 'rendered'
|
|
|
+ | 'source'
|
|
|
+ | 'origin'
|
|
|
+ | 'input'
|
|
|
+ | 'output'
|
|
|
+ | 'schema'
|
|
|
+ | 'options'
|
|
|
+ | 'usage'
|
|
|
+ | 'timing'
|
|
|
+ | 'diff'
|
|
|
+type RecordState = 'complete' | 'running' | 'error'
|
|
|
+
|
|
|
+interface DetailTabItem {
|
|
|
+ id: DetailTab
|
|
|
+ label: string
|
|
|
+}
|
|
|
+
|
|
|
+interface ParentRecords {
|
|
|
+ message?: TableRecord
|
|
|
+ tool?: TableRecord
|
|
|
+}
|
|
|
+
|
|
|
+interface ToolCallTextParts {
|
|
|
+ name: string
|
|
|
+ args?: string
|
|
|
+}
|
|
|
+
|
|
|
+interface SelectedRequest {
|
|
|
+ turn: number
|
|
|
+ number: number
|
|
|
+ group: string
|
|
|
+}
|
|
|
+
|
|
|
+interface DetailsResizeDrag {
|
|
|
+ pointerId: number
|
|
|
+ startX: number
|
|
|
+ startWidth: number
|
|
|
+ splitWidth: number
|
|
|
+ startToolRequestOffset: number
|
|
|
+}
|
|
|
+
|
|
|
+const DETAILS_MIN_WIDTH = 320
|
|
|
+const DETAILS_MAX_WIDTH = 720
|
|
|
+const TABLE_MIN_WIDTH = 280
|
|
|
+const DETAILS_RESIZE_STEP = 16
|
|
|
+const TOOL_REQUEST_SHARE = 0.58
|
|
|
+const TOOL_REQUEST_MIN_WIDTH = 180
|
|
|
+const TOOL_REQUEST_MAX_WIDTH = 480
|
|
|
+const DEFAULT_TOOL_REQUEST_SHARE = 0.36
|
|
|
+const DEFAULT_TOOL_REQUEST_OFFSET = 56
|
|
|
+const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [
|
|
|
+ { id: 'system-prompt', label: 'System Prompt' },
|
|
|
+ { id: 'tools', label: 'Tools' },
|
|
|
+]
|
|
|
+const SYSTEM_UPDATE_TABS: readonly DetailTabItem[] = [
|
|
|
+ { id: 'diff', label: 'Diff' },
|
|
|
+ ...SYSTEM_PROMPT_TABS,
|
|
|
+]
|
|
|
+const REQUEST_TABS: readonly DetailTabItem[] = [
|
|
|
+ { id: 'overview', label: 'Summary' },
|
|
|
+ { id: 'options', label: 'Options' },
|
|
|
+ { id: 'usage', label: 'Usage' },
|
|
|
+ { id: 'timing', label: 'Timing' },
|
|
|
+]
|
|
|
+
|
|
|
+type TrajectorySplitStyle = CSSProperties & {
|
|
|
+ '--trajectory-tool-request-width': string
|
|
|
+}
|
|
|
+
|
|
|
+function clampDetailsWidth(width: number, splitWidth: number): number {
|
|
|
+ const maxWidth = Math.max(
|
|
|
+ DETAILS_MIN_WIDTH,
|
|
|
+ Math.min(DETAILS_MAX_WIDTH, splitWidth - TABLE_MIN_WIDTH),
|
|
|
+ )
|
|
|
+ return Math.round(Math.min(Math.max(width, DETAILS_MIN_WIDTH), maxWidth))
|
|
|
+}
|
|
|
+
|
|
|
+function defaultToolRequestWidth(splitWidth: number): number {
|
|
|
+ return Math.min(
|
|
|
+ Math.max(
|
|
|
+ splitWidth * DEFAULT_TOOL_REQUEST_SHARE - DEFAULT_TOOL_REQUEST_OFFSET,
|
|
|
+ TOOL_REQUEST_MIN_WIDTH,
|
|
|
+ ),
|
|
|
+ TOOL_REQUEST_MAX_WIDTH,
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function formatDurationMs(milliseconds: number): string {
|
|
|
+ if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
|
|
|
+ return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s`
|
|
|
+}
|
|
|
+
|
|
|
+function formatStartedAt(timestamp: number | null): string {
|
|
|
+ if (timestamp === null || !Number.isFinite(timestamp)) return 'Not available'
|
|
|
+ const date = new Date(timestamp)
|
|
|
+ const two = (value: number) => String(value).padStart(2, '0')
|
|
|
+ const three = (value: number) => String(value).padStart(3, '0')
|
|
|
+ const time = `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}.${three(date.getMilliseconds())}`
|
|
|
+ const day = `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
|
|
|
+ return `${day} ${time}`
|
|
|
+}
|
|
|
+
|
|
|
+function StartedAtValue({ timestamp }: { timestamp: number | null }) {
|
|
|
+ const [showUnix, setShowUnix] = useState(false)
|
|
|
+ if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
|
|
|
+ return (
|
|
|
+ <dd>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ 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
|
|
|
+ setShowUnix(current => !current)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {showUnix ? (timestamp / 1_000).toFixed(3) : formatStartedAt(timestamp)}
|
|
|
+ </button>
|
|
|
+ </dd>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function totalTime(metrics: AssistantMetricDetail): string {
|
|
|
+ if (!metrics.timingRecorded) return 'Not recorded'
|
|
|
+ if (metrics.stepStartTime === null) return 'Step start unavailable'
|
|
|
+ if (metrics.completedTime === null) return 'Pending'
|
|
|
+ return formatDurationMs(Math.max(0, metrics.completedTime - metrics.stepStartTime))
|
|
|
+}
|
|
|
+
|
|
|
+function ttft(metrics: AssistantMetricDetail): string {
|
|
|
+ if (!metrics.timingRecorded) return 'Not recorded'
|
|
|
+ if (metrics.stepStartTime === null) return 'Step start unavailable'
|
|
|
+ if (metrics.firstTokenTime === null) return 'First token unavailable'
|
|
|
+ return formatDurationMs(Math.max(0, metrics.firstTokenTime - metrics.stepStartTime))
|
|
|
+}
|
|
|
+
|
|
|
+function generationTime(metrics: AssistantMetricDetail): string {
|
|
|
+ if (!metrics.timingRecorded || metrics.firstTokenTime === null) return 'First token unavailable'
|
|
|
+ if (metrics.completedTime === null) return 'Pending'
|
|
|
+ return formatDurationMs(Math.max(0, metrics.completedTime - metrics.firstTokenTime))
|
|
|
+}
|
|
|
+
|
|
|
+function throughput(metrics: AssistantMetricDetail): string {
|
|
|
+ if (!metrics.usageProvided) return 'Usage unavailable'
|
|
|
+ if (metrics.outputTokens === null) return 'Output tokens unavailable'
|
|
|
+ if (!metrics.timingRecorded || metrics.firstTokenTime === null) return 'First token unavailable'
|
|
|
+ if (metrics.completedTime === null) return 'Pending'
|
|
|
+ const generationSeconds = (metrics.completedTime - metrics.firstTokenTime) / 1_000
|
|
|
+ if (generationSeconds <= 0) return 'Duration too short'
|
|
|
+ return `${(metrics.outputTokens / generationSeconds).toFixed(1)} tok/s`
|
|
|
+}
|
|
|
+
|
|
|
+function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
|
|
|
+ return (
|
|
|
+ <dl className={css.overview}>
|
|
|
+ <div><dt>Started</dt><StartedAtValue timestamp={metrics.stepStartTime} /></div>
|
|
|
+ <div><dt>Total duration</dt><dd>{totalTime(metrics)}</dd></div>
|
|
|
+ <div><dt>TTFT</dt><dd>{ttft(metrics)}</dd></div>
|
|
|
+ <div><dt>Generation</dt><dd>{generationTime(metrics)}</dd></div>
|
|
|
+ <div><dt>Throughput</dt><dd>{throughput(metrics)}</dd></div>
|
|
|
+ </dl>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/** Props for the trajectory ledger. */
|
|
|
+export interface TrajectoryTableProps {
|
|
|
+ /** Session-global request numbers for the request groups visible in this context. */
|
|
|
+ requestNumbers?: readonly TrajectoryRequestNumber[]
|
|
|
+ /** Grouped records in display order. */
|
|
|
+ turns: readonly TrajectoryTurnModel[]
|
|
|
+ /** 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. */
|
|
|
+ searchMatchIndexes?: ReadonlySet<number> | null
|
|
|
+ /** Report the record currently selected in the local inspector. */
|
|
|
+ onSelectedIndexChange?: (index: number | null) => void
|
|
|
+ /** Report a direct user selection from a ledger row. */
|
|
|
+ onRecordSelect?: (index: number) => void
|
|
|
+ /** 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>
|
|
|
+ /** Toggle tool calls under one assistant record. */
|
|
|
+ onToggleAssistant: (index: number) => void
|
|
|
+}
|
|
|
+
|
|
|
+/** One request identity paired with its session-global number. */
|
|
|
+export interface TrajectoryRequestNumber {
|
|
|
+ /** Request anchor event sequence; absent for the currently streaming ordinary request. */
|
|
|
+ seq?: number
|
|
|
+ turn: number
|
|
|
+ step: number
|
|
|
+ group: string
|
|
|
+ number: number
|
|
|
+ purpose?: 'compaction'
|
|
|
+ status?: 'complete' | 'running' | 'error'
|
|
|
+ startedAt?: number
|
|
|
+ completedAt?: number | null
|
|
|
+ error?: string
|
|
|
+ retry?: number
|
|
|
+ maxRetries?: number
|
|
|
+ retryDelayMs?: number
|
|
|
+ resultSeq?: number
|
|
|
+ provider?: string
|
|
|
+ model?: string
|
|
|
+ requestConfig?: AssistantRequestConfig
|
|
|
+ usage?: TrajectoryUsage
|
|
|
+ cumulativeUsage?: TrajectoryUsage
|
|
|
+}
|
|
|
+
|
|
|
+/** Disjoint provider token buckets for one request or a session prefix. */
|
|
|
+export interface TrajectoryUsage {
|
|
|
+ input?: number
|
|
|
+ cacheRead?: number
|
|
|
+ cacheWrite?: number
|
|
|
+ output?: number
|
|
|
+ reasoning?: number
|
|
|
+}
|
|
|
+
|
|
|
+function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
|
|
|
+ return turns.flatMap((turn) => {
|
|
|
+ let firstInTurn = true
|
|
|
+ const records = turn.groups.flatMap((group) => {
|
|
|
+ return group.cells.map((cell, index) => {
|
|
|
+ const turnStart = firstInTurn
|
|
|
+ && cell.requestOnly !== true
|
|
|
+ && cell.kind !== 'system'
|
|
|
+ && cell.kind !== 'compacted'
|
|
|
+ if (turnStart) firstInTurn = false
|
|
|
+ return {
|
|
|
+ turn: turn.turn,
|
|
|
+ group: group.title,
|
|
|
+ groupStart: index === 0,
|
|
|
+ turnStart,
|
|
|
+ cell,
|
|
|
+ turnEnd: false,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ const last = records.at(-1)
|
|
|
+ if (last !== undefined) last.turnEnd = true
|
|
|
+ return records
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function filterRecords(
|
|
|
+ records: readonly TableRecord[],
|
|
|
+ matches: ReadonlySet<number>,
|
|
|
+): TableRecord[] {
|
|
|
+ const filtered = records
|
|
|
+ .filter(record =>
|
|
|
+ record.cell.requestOnly !== true && matches.has(record.cell.index),
|
|
|
+ )
|
|
|
+ .map(record => ({ ...record, groupStart: false, turnStart: false, turnEnd: false }))
|
|
|
+ const startedTurns = new Set<number>()
|
|
|
+ for (const [index, record] of filtered.entries()) {
|
|
|
+ const previous = filtered[index - 1]
|
|
|
+ const next = filtered[index + 1]
|
|
|
+ record.groupStart = previous === undefined
|
|
|
+ || previous.turn !== record.turn
|
|
|
+ || previous.group !== record.group
|
|
|
+ record.turnStart = !startedTurns.has(record.turn)
|
|
|
+ && record.cell.kind !== 'system'
|
|
|
+ && record.cell.kind !== 'compacted'
|
|
|
+ if (record.turnStart) startedTurns.add(record.turn)
|
|
|
+ record.turnEnd = next === undefined || next.turn !== record.turn
|
|
|
+ }
|
|
|
+ return filtered
|
|
|
+}
|
|
|
+
|
|
|
+function requestStep(group: string): number | undefined {
|
|
|
+ if (!group.startsWith('Step ')) return undefined
|
|
|
+ const value = Number(group.slice('Step '.length))
|
|
|
+ return Number.isInteger(value) && value > 0 ? value : undefined
|
|
|
+}
|
|
|
+
|
|
|
+function requestKey(turn: number, group: string): string {
|
|
|
+ return `${turn}\u0000${group}`
|
|
|
+}
|
|
|
+
|
|
|
+function indexRequestNumbers(
|
|
|
+ records: readonly TableRecord[],
|
|
|
+ sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
|
|
|
+): ReadonlyMap<string, number> {
|
|
|
+ const numbers = new Map<string, number>()
|
|
|
+ for (const request of sessionNumbers ?? []) {
|
|
|
+ numbers.set(requestKey(request.turn, request.group), request.number)
|
|
|
+ }
|
|
|
+ let next = Math.max(0, ...numbers.values()) + 1
|
|
|
+ const boundaries = records
|
|
|
+ .filter(record => record.groupStart && requestStep(record.group) !== undefined)
|
|
|
+ .sort((left, right) => left.cell.index - right.cell.index)
|
|
|
+ for (const record of boundaries) {
|
|
|
+ const key = requestKey(record.turn, record.group)
|
|
|
+ if (!numbers.has(key)) numbers.set(key, next++)
|
|
|
+ }
|
|
|
+ return numbers
|
|
|
+}
|
|
|
+
|
|
|
+function summarizeTurn(records: readonly TableRecord[]): string {
|
|
|
+ const steps = new Set(
|
|
|
+ records
|
|
|
+ .map(record => record.group)
|
|
|
+ .filter(group => group.startsWith('Step ')),
|
|
|
+ ).size
|
|
|
+ const toolCalls = records.filter(record =>
|
|
|
+ record.cell.kind === 'tool' || record.cell.kind === 'subtool',
|
|
|
+ ).length
|
|
|
+ return [
|
|
|
+ `${steps} ${steps === 1 ? 'step' : 'steps'}`,
|
|
|
+ `${toolCalls} tool ${toolCalls === 1 ? 'call' : 'calls'}`,
|
|
|
+ ].join(' · ')
|
|
|
+}
|
|
|
+
|
|
|
+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) {
|
|
|
+ const turnRecords = recordsByTurn.get(record.turn) ?? []
|
|
|
+ turnRecords.push(record)
|
|
|
+ recordsByTurn.set(record.turn, turnRecords)
|
|
|
+ }
|
|
|
+ return records.flatMap((record) => {
|
|
|
+ if (!collapsedTurns.has(record.turn)) return [record]
|
|
|
+ const turnRecords = recordsByTurn.get(record.turn) ?? [record]
|
|
|
+ if (record.cell.requestOnly === true || record.cell.kind === 'system') return [record]
|
|
|
+ const contentRecords = turnRecords.filter(candidate =>
|
|
|
+ candidate.cell.requestOnly !== true && candidate.cell.kind !== 'system')
|
|
|
+ if (contentRecords.length <= 1) return [record]
|
|
|
+ if (record.cell.index !== contentRecords[0]?.cell.index) return []
|
|
|
+ return [
|
|
|
+ { ...record, turnEnd: false },
|
|
|
+ {
|
|
|
+ ...record,
|
|
|
+ groupStart: false,
|
|
|
+ turnStart: false,
|
|
|
+ turnEnd: true,
|
|
|
+ collapsedSummary: summarizeTurn(contentRecords.slice(1)),
|
|
|
+ collapsedSummaryKind: 'turn',
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function assistantToolCalls(
|
|
|
+ records: readonly TableRecord[],
|
|
|
+ assistantIndex: number,
|
|
|
+): readonly TableRecord[] {
|
|
|
+ const at = records.findIndex(record => record.cell.index === assistantIndex)
|
|
|
+ if (at === -1 || records[at]?.cell.kind !== 'message') return []
|
|
|
+ const calls: TableRecord[] = []
|
|
|
+ for (let i = at + 1; i < records.length; i++) {
|
|
|
+ const record = records[i]
|
|
|
+ if (record === undefined) break
|
|
|
+ if (record.cell.kind !== 'tool' && record.cell.kind !== 'subtool') break
|
|
|
+ calls.push(record)
|
|
|
+ }
|
|
|
+ return calls
|
|
|
+}
|
|
|
+
|
|
|
+function summarizeAssistantTools(records: readonly TableRecord[]): string {
|
|
|
+ const names = [...new Set(records.map((record) => {
|
|
|
+ const separator = record.cell.text.indexOf(' · ')
|
|
|
+ return separator === -1 ? record.cell.text : record.cell.text.slice(0, separator)
|
|
|
+ }).filter(name => name !== ''))]
|
|
|
+ const count = records.length
|
|
|
+ const summary = `${count} tool ${count === 1 ? 'call' : 'calls'}`
|
|
|
+ return names.length > 0 ? `${summary} · ${names.join(', ')}` : summary
|
|
|
+}
|
|
|
+
|
|
|
+function collapseAssistantRecords(
|
|
|
+ records: readonly TableRecord[],
|
|
|
+ collapsedAssistants: ReadonlySet<number>,
|
|
|
+): 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
|
|
|
+ const calls: TableRecord[] = []
|
|
|
+ for (let j = i + 1; j < records.length; j++) {
|
|
|
+ const candidate = records[j]
|
|
|
+ if (
|
|
|
+ candidate === undefined
|
|
|
+ || candidate.collapsedSummary !== undefined
|
|
|
+ || (candidate.cell.kind !== 'tool' && candidate.cell.kind !== 'subtool')
|
|
|
+ ) break
|
|
|
+ calls.push(candidate)
|
|
|
+ }
|
|
|
+ if (calls.length === 0) continue
|
|
|
+ const last = calls.at(-1)
|
|
|
+ out[out.length - 1] = { ...record, turnEnd: false }
|
|
|
+ out.push({
|
|
|
+ ...record,
|
|
|
+ groupStart: false,
|
|
|
+ turnStart: false,
|
|
|
+ turnEnd: last?.turnEnd ?? false,
|
|
|
+ collapsedSummary: summarizeAssistantTools(calls),
|
|
|
+ collapsedSummaryKind: 'assistant',
|
|
|
+ })
|
|
|
+ i += calls.length
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+function stateOf(record: TableRecord): RecordState {
|
|
|
+ if (record.cell.isError) return 'error'
|
|
|
+ if (record.cell.kind === 'compacted' && record.cell.timeSeconds === null) return 'running'
|
|
|
+ if (
|
|
|
+ (record.cell.kind === 'tool' || record.cell.kind === 'subtool')
|
|
|
+ && record.cell.outputDetail === undefined
|
|
|
+ ) return 'running'
|
|
|
+ return 'complete'
|
|
|
+}
|
|
|
+
|
|
|
+function statusLabel(state: RecordState): string {
|
|
|
+ if (state === 'error') return 'Failed'
|
|
|
+ if (state === 'running') return 'Pending'
|
|
|
+ return 'Completed'
|
|
|
+}
|
|
|
+
|
|
|
+function TokenRows({ cell }: { cell: TrajectoryCellProps }) {
|
|
|
+ const content = cell.output !== undefined && cell.think !== undefined
|
|
|
+ ? Math.max(0, cell.output - cell.think)
|
|
|
+ : undefined
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <div>
|
|
|
+ <dt>Tokens</dt>
|
|
|
+ <dd>{cell.output === undefined ? '—' : `${cell.output} tok`}</dd>
|
|
|
+ </div>
|
|
|
+ {cell.think !== undefined && (
|
|
|
+ <div className={css.requestTokenDetail}>
|
|
|
+ <dt>Reasoning</dt>
|
|
|
+ <dd>{cell.think} tok</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {content !== undefined && (
|
|
|
+ <div className={css.requestTokenDetail}>
|
|
|
+ <dt>Content</dt>
|
|
|
+ <dd>{content} tok</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function inputTotal(usage: TrajectoryUsage): number | undefined {
|
|
|
+ if (
|
|
|
+ usage.input === undefined
|
|
|
+ && usage.cacheRead === undefined
|
|
|
+ && usage.cacheWrite === undefined
|
|
|
+ ) return undefined
|
|
|
+ return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
|
|
|
+}
|
|
|
+
|
|
|
+function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) {
|
|
|
+ if (usage === undefined) return <p className={css.noPayload}>Usage not reported</p>
|
|
|
+ const totalInput = inputTotal(usage)
|
|
|
+ const otherOutput = usage.output !== undefined && usage.reasoning !== undefined
|
|
|
+ ? usage.output - usage.reasoning
|
|
|
+ : undefined
|
|
|
+ return (
|
|
|
+ <dl className={css.overview}>
|
|
|
+ {totalInput !== undefined && (
|
|
|
+ <div><dt>Input</dt><dd>{totalInput} tok</dd></div>
|
|
|
+ )}
|
|
|
+ {usage.cacheRead !== undefined && (
|
|
|
+ <div className={css.requestTokenDetail}>
|
|
|
+ <dt>Cached</dt>
|
|
|
+ <dd>{usage.cacheRead} tok</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {usage.cacheWrite !== undefined && (
|
|
|
+ <div className={css.requestTokenDetail}>
|
|
|
+ <dt>Cache created</dt>
|
|
|
+ <dd>{usage.cacheWrite} tok</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {usage.input !== undefined && (
|
|
|
+ <div className={css.requestTokenDetail}>
|
|
|
+ <dt>Other</dt>
|
|
|
+ <dd>{usage.input} tok</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {usage.output !== undefined && (
|
|
|
+ <div><dt>Output</dt><dd>{usage.output} tok</dd></div>
|
|
|
+ )}
|
|
|
+ {usage.reasoning !== undefined && (
|
|
|
+ <div className={css.requestTokenDetail}>
|
|
|
+ <dt>Reasoning</dt>
|
|
|
+ <dd>{usage.reasoning} tok</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {otherOutput !== undefined && (
|
|
|
+ <div className={css.requestTokenDetail}>
|
|
|
+ <dt>Content</dt>
|
|
|
+ <dd>{otherOutput} tok</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </dl>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function RequestUsagePanel({
|
|
|
+ usage,
|
|
|
+ cumulative,
|
|
|
+}: {
|
|
|
+ usage: TrajectoryUsage | undefined
|
|
|
+ cumulative: TrajectoryUsage | undefined
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div className={css.usagePanel}>
|
|
|
+ <section className={css.usageGroup}>
|
|
|
+ <h4 className={css.usageHeading}>This request</h4>
|
|
|
+ <UsageRows usage={usage} />
|
|
|
+ </section>
|
|
|
+ <section className={css.usageGroup}>
|
|
|
+ <h4 className={css.usageHeading}>Session cumulative</h4>
|
|
|
+ <UsageRows usage={cumulative} />
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function RequestOptions({
|
|
|
+ options,
|
|
|
+ preview = false,
|
|
|
+}: {
|
|
|
+ options: AssistantRequestConfig | undefined
|
|
|
+ preview?: boolean
|
|
|
+}) {
|
|
|
+ if (options === undefined) {
|
|
|
+ return <p className={css.noPayload}>Options not recorded</p>
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <JsonTree
|
|
|
+ data={options}
|
|
|
+ label="Request options JSON"
|
|
|
+ className={preview ? css.jsonPreview : css.jsonPayload}
|
|
|
+ />
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function messageOriginLabel(source: unknown): string {
|
|
|
+ if (typeof source !== 'object' || source === null || Array.isArray(source)) {
|
|
|
+ return 'Unknown'
|
|
|
+ }
|
|
|
+ const properties = source as Record<string, unknown>
|
|
|
+ const kind = properties.kind
|
|
|
+ if (kind === 'user') return 'User'
|
|
|
+ if (kind === 'plugin') {
|
|
|
+ const plugin = properties.plugin
|
|
|
+ return typeof plugin === 'string' && plugin !== ''
|
|
|
+ ? `Plugin · ${plugin}`
|
|
|
+ : 'Plugin'
|
|
|
+ }
|
|
|
+ if (kind === 'goal') {
|
|
|
+ const round = properties.round
|
|
|
+ return typeof round === 'number' && round > 0
|
|
|
+ ? `Goal · Round ${round}`
|
|
|
+ : 'Goal'
|
|
|
+ }
|
|
|
+ if (typeof kind !== 'string' || kind === '') return 'Unknown'
|
|
|
+ return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}`
|
|
|
+}
|
|
|
+
|
|
|
+function MessageOrigin({ record }: { record: TableRecord }) {
|
|
|
+ const source = record.cell.messageSource
|
|
|
+ if (source === undefined) return <p className={css.noPayload}>Origin not recorded</p>
|
|
|
+ const data = typeof source === 'object' && source !== null
|
|
|
+ ? source
|
|
|
+ : { value: source }
|
|
|
+ return (
|
|
|
+ <JsonTree
|
|
|
+ data={data}
|
|
|
+ label="Message origin JSON"
|
|
|
+ className={css.jsonPayload}
|
|
|
+ />
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function isMarkdownRecord(record: TableRecord): boolean {
|
|
|
+ return record.cell.kind === 'user'
|
|
|
+ || record.cell.kind === 'context'
|
|
|
+ || record.cell.kind === 'message'
|
|
|
+}
|
|
|
+
|
|
|
+function parentRecords(
|
|
|
+ records: readonly TableRecord[],
|
|
|
+ record: TableRecord,
|
|
|
+): ParentRecords {
|
|
|
+ if (record.cell.kind !== 'tool' && record.cell.kind !== 'subtool') return {}
|
|
|
+ const at = records.findIndex(candidate => candidate.cell.index === record.cell.index)
|
|
|
+ if (at === -1) return {}
|
|
|
+ let tool: TableRecord | undefined
|
|
|
+ if (record.cell.kind === 'subtool') {
|
|
|
+ for (let i = at - 1; i >= 0; i--) {
|
|
|
+ const candidate = records[i]
|
|
|
+ if (
|
|
|
+ candidate === undefined
|
|
|
+ || candidate.turn !== record.turn
|
|
|
+ || candidate.group !== record.group
|
|
|
+ ) break
|
|
|
+ if (candidate.cell.kind === 'tool') {
|
|
|
+ tool = candidate
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const parentCallId = tool?.cell.callId ?? record.cell.callId
|
|
|
+ let message: TableRecord | undefined
|
|
|
+ if (parentCallId !== undefined) {
|
|
|
+ message = records.find(candidate =>
|
|
|
+ candidate.turn === record.turn
|
|
|
+ && candidate.cell.kind === 'message'
|
|
|
+ && candidate.cell.sourceBlocks?.some(block => block.callId === parentCallId) === true,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return { ...(message === undefined ? {} : { message }), ...(tool === undefined ? {} : { tool }) }
|
|
|
+}
|
|
|
+
|
|
|
+function markdownSource(record: TableRecord): string | undefined {
|
|
|
+ if (record.cell.kind === 'user' || record.cell.kind === 'context') {
|
|
|
+ return record.cell.inputDetail
|
|
|
+ }
|
|
|
+ if (record.cell.kind === 'message' || record.cell.kind === 'compacted') {
|
|
|
+ return record.cell.outputDetail
|
|
|
+ }
|
|
|
+ return undefined
|
|
|
+}
|
|
|
+
|
|
|
+function detailTabs(record: TableRecord): readonly DetailTabItem[] {
|
|
|
+ if (record.cell.kind === 'system') {
|
|
|
+ return record.cell.previousPromptDetail === undefined
|
|
|
+ ? SYSTEM_PROMPT_TABS
|
|
|
+ : SYSTEM_UPDATE_TABS
|
|
|
+ }
|
|
|
+ if (record.cell.kind === 'compacted') {
|
|
|
+ return [
|
|
|
+ { id: 'overview', label: 'Summary' },
|
|
|
+ { id: 'source', label: 'Raw Output' },
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ if (isMarkdownRecord(record)) {
|
|
|
+ return [
|
|
|
+ { id: 'overview', label: 'Summary' },
|
|
|
+ { id: 'rendered', label: 'Preview' },
|
|
|
+ { id: 'source', label: 'Source' },
|
|
|
+ ...(record.cell.messageSource === undefined
|
|
|
+ ? []
|
|
|
+ : [{ id: 'origin', label: 'Origin' } as const]),
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ return [
|
|
|
+ { id: 'overview', label: 'Summary' },
|
|
|
+ ...(record.cell.inputDetail ? [{ id: 'input', label: 'Payload' } as const] : []),
|
|
|
+ ...(record.cell.outputDetail ? [{ id: 'output', label: 'Result' } as const] : []),
|
|
|
+ { id: 'schema', label: 'Schema' },
|
|
|
+ { id: 'timing', label: 'Timing' },
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+function recordDisplayText(cell: TrajectoryCellProps): string {
|
|
|
+ if (isToolCallOnly(cell)) return ''
|
|
|
+ const markdown = cell.kind === 'user' || cell.kind === 'context'
|
|
|
+ ? cell.inputDetail
|
|
|
+ : cell.kind === 'message'
|
|
|
+ ? cell.outputDetail ?? cell.thinkingDetail
|
|
|
+ : undefined
|
|
|
+ if (!markdown) return cell.text
|
|
|
+ return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
|
|
|
+}
|
|
|
+
|
|
|
+function toolCallTextParts(
|
|
|
+ kind: TrajectoryCellKind,
|
|
|
+ text: string,
|
|
|
+): ToolCallTextParts | undefined {
|
|
|
+ if (kind !== 'tool' && kind !== 'subtool') return undefined
|
|
|
+ const separator = text.indexOf(' · ')
|
|
|
+ if (separator === -1) return { name: text }
|
|
|
+ return {
|
|
|
+ name: text.slice(0, separator),
|
|
|
+ args: text.slice(separator + 3),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function isToolCallOnly(cell: TrajectoryCellProps): boolean {
|
|
|
+ return cell.kind === 'message'
|
|
|
+ && !cell.outputDetail
|
|
|
+ && !cell.thinkingDetail
|
|
|
+ && cell.text === 'Tool call only'
|
|
|
+}
|
|
|
+
|
|
|
+function MarkdownFragment({
|
|
|
+ text,
|
|
|
+ rendered,
|
|
|
+ preview,
|
|
|
+}: {
|
|
|
+ text: string
|
|
|
+ rendered: boolean
|
|
|
+ preview: boolean
|
|
|
+}) {
|
|
|
+ if (rendered) {
|
|
|
+ return (
|
|
|
+ <div className={preview ? css.markdownPreview : css.markdownPayload}>
|
|
|
+ <MarkdownText text={text} />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <pre className={`${css.payload} ${preview ? css.payloadPreview : ''}`}>
|
|
|
+ {text}
|
|
|
+ </pre>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function SourceBlocks({
|
|
|
+ blocks,
|
|
|
+ onOpenCall,
|
|
|
+}: {
|
|
|
+ blocks: readonly TrajectorySourceBlock[]
|
|
|
+ onOpenCall: (callId: string) => void
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div className={css.sourceBlocks}>
|
|
|
+ {blocks.map((block, index) => (
|
|
|
+ <section className={css.sourceBlock} key={index}>
|
|
|
+ {block.callId !== undefined
|
|
|
+ ? (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.sourceBlockJumpTarget}
|
|
|
+ aria-label={`Open Block #${index + 1} tool call summary`}
|
|
|
+ title="Open tool call summary"
|
|
|
+ onClick={() => {
|
|
|
+ if (block.callId !== undefined) onOpenCall(block.callId)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <span className={css.sourceBlockLabel}>
|
|
|
+ {`Block #${index + 1} ${block.type}`}
|
|
|
+ </span>
|
|
|
+ <IconChevronRightOutline14 className={css.sourceBlockJumpIcon} size={12} />
|
|
|
+ </button>
|
|
|
+ )
|
|
|
+ : (
|
|
|
+ <div className={css.sourceBlockHeader}>
|
|
|
+ <span className={css.sourceBlockLabel}>
|
|
|
+ {`Block #${index + 1} ${block.type}`}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {block.imageSrc !== undefined
|
|
|
+ ? <PanelImage block={block} />
|
|
|
+ : <pre className={css.sourceBlockContent}>{block.content}</pre>}
|
|
|
+ </section>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function PanelImage({
|
|
|
+ block,
|
|
|
+ preview = false,
|
|
|
+}: {
|
|
|
+ block: TrajectorySourceBlock
|
|
|
+ preview?: boolean
|
|
|
+}) {
|
|
|
+ if (block.imageSrc === undefined) return null
|
|
|
+ return (
|
|
|
+ <a
|
|
|
+ className={preview ? `${css.panelImageLink} ${css.panelImageLinkPreview}` : css.panelImageLink}
|
|
|
+ href={block.imageSrc}
|
|
|
+ target="_blank"
|
|
|
+ rel="noopener noreferrer"
|
|
|
+ title="Open image"
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ className={css.panelImage}
|
|
|
+ src={block.imageSrc}
|
|
|
+ alt={block.imageAlt ?? ''}
|
|
|
+ />
|
|
|
+ </a>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function MessageImages({
|
|
|
+ blocks,
|
|
|
+ preview,
|
|
|
+}: {
|
|
|
+ blocks: readonly TrajectorySourceBlock[] | undefined
|
|
|
+ preview: boolean
|
|
|
+}) {
|
|
|
+ const images = blocks?.filter(block => block.imageSrc !== undefined) ?? []
|
|
|
+ if (images.length === 0) return null
|
|
|
+ return (
|
|
|
+ <div className={preview ? `${css.messageImages} ${css.messageImagesPreview}` : css.messageImages}>
|
|
|
+ {images.map((block, index) => <PanelImage block={block} preview={preview} key={index} />)}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function AssistantToolCalls({
|
|
|
+ blocks,
|
|
|
+ preview,
|
|
|
+ onOpenCall,
|
|
|
+}: {
|
|
|
+ blocks: readonly TrajectorySourceBlock[] | undefined
|
|
|
+ preview: boolean
|
|
|
+ onOpenCall: (callId: string) => void
|
|
|
+}) {
|
|
|
+ const calls = blocks?.filter(block => block.type === 'tool-call') ?? []
|
|
|
+ if (calls.length === 0) return null
|
|
|
+ return (
|
|
|
+ <ul className={preview
|
|
|
+ ? `${css.assistantToolCalls} ${css.assistantToolCallsPreview}`
|
|
|
+ : css.assistantToolCalls}
|
|
|
+ >
|
|
|
+ {calls.map((call, index) => (
|
|
|
+ <li key={call.callId ?? index}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.assistantToolCallButton}
|
|
|
+ title="Open tool call summary"
|
|
|
+ onClick={() => {
|
|
|
+ if (call.callId !== undefined) onOpenCall(call.callId)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <svg
|
|
|
+ className={css.assistantToolCallIcon}
|
|
|
+ width="12"
|
|
|
+ height="12"
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ fill="none"
|
|
|
+ aria-hidden="true"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94z"
|
|
|
+ stroke="currentColor"
|
|
|
+ strokeWidth="1.8"
|
|
|
+ strokeLinecap="round"
|
|
|
+ strokeLinejoin="round"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ <span className={css.assistantToolCallText}>
|
|
|
+ <span className={css.assistantToolCallName}>
|
|
|
+ {call.toolName ?? 'tool-call'}
|
|
|
+ </span>
|
|
|
+ {call.content !== '' && (
|
|
|
+ <span className={css.assistantToolCallArgs}>{call.content}</span>
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ </li>
|
|
|
+ ))}
|
|
|
+ </ul>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function ToolGlyph() {
|
|
|
+ return (
|
|
|
+ <svg
|
|
|
+ className={css.toolCatalogIcon}
|
|
|
+ width="12"
|
|
|
+ height="12"
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ fill="none"
|
|
|
+ aria-hidden="true"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94z"
|
|
|
+ stroke="currentColor"
|
|
|
+ strokeWidth="1.8"
|
|
|
+ strokeLinecap="round"
|
|
|
+ strokeLinejoin="round"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function ToolCatalog({ tools }: { tools: ConversationPromptSnapshot['tools'] }) {
|
|
|
+ if (tools.length === 0) return <p className={css.noPayload}>No tools in this request</p>
|
|
|
+ return (
|
|
|
+ <div className={css.toolCatalog}>
|
|
|
+ {tools.map((tool, index) => (
|
|
|
+ <details className={css.toolCatalogItem} key={`${tool.name}:${index}`}>
|
|
|
+ <summary className={css.toolCatalogSummary}>
|
|
|
+ <IconChevronRightOutline14 className={css.toolCatalogChevron} size={12} />
|
|
|
+ <ToolGlyph />
|
|
|
+ <span className={css.toolCatalogName}>{tool.name}</span>
|
|
|
+ <span className={css.toolCatalogDescription}>{tool.description}</span>
|
|
|
+ </summary>
|
|
|
+ <div className={css.toolCatalogDefinition}>
|
|
|
+ {tool.description !== '' && (
|
|
|
+ <p className={css.toolCatalogFullDescription}>{tool.description}</p>
|
|
|
+ )}
|
|
|
+ <JsonTree
|
|
|
+ data={tool.parameters}
|
|
|
+ label={`${tool.name} parameters JSON`}
|
|
|
+ className={css.toolCatalogTree}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+interface PromptDiffLine {
|
|
|
+ kind: 'meta' | 'context' | 'added' | 'removed'
|
|
|
+ text: string
|
|
|
+}
|
|
|
+
|
|
|
+function promptDiffLines(before: string, after: string): readonly PromptDiffLine[] {
|
|
|
+ const patch = structuredPatch('', '', before, after, undefined, undefined, { context: 3 })
|
|
|
+ return patch.hunks.flatMap((hunk, hunkIndex) => [
|
|
|
+ ...(hunkIndex === 0 ? [] : [{ kind: 'meta' as const, text: '' }]),
|
|
|
+ {
|
|
|
+ kind: 'meta' as const,
|
|
|
+ text: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
|
|
+ },
|
|
|
+ ...hunk.lines.flatMap((line): PromptDiffLine[] => {
|
|
|
+ if (line.startsWith('\\')) return []
|
|
|
+ if (line.startsWith('+')) return [{ kind: 'added', text: line }]
|
|
|
+ if (line.startsWith('-')) return [{ kind: 'removed', text: line }]
|
|
|
+ return [{ kind: 'context', text: line }]
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+}
|
|
|
+
|
|
|
+function PromptDiffSection({
|
|
|
+ title,
|
|
|
+ before,
|
|
|
+ after,
|
|
|
+}: {
|
|
|
+ title: string
|
|
|
+ before: string
|
|
|
+ after: string
|
|
|
+}) {
|
|
|
+ const lines = promptDiffLines(before, after)
|
|
|
+ if (lines.length === 0) return null
|
|
|
+ return (
|
|
|
+ <section className={css.promptDiffSection}>
|
|
|
+ <h3 className={css.promptDiffTitle}>{title}</h3>
|
|
|
+ <pre className={css.promptDiff}>
|
|
|
+ {lines.map((line, index) => (
|
|
|
+ <span className={css[`promptDiffLine${line.kind}`]} key={index}>
|
|
|
+ {line.text || ' '}
|
|
|
+ {'\n'}
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </pre>
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function SystemPromptDiff({
|
|
|
+ before,
|
|
|
+ after,
|
|
|
+}: {
|
|
|
+ before: ConversationPromptSnapshot
|
|
|
+ after: ConversationPromptSnapshot
|
|
|
+}) {
|
|
|
+ const toolsBefore = JSON.stringify(before.tools, null, 2)
|
|
|
+ const toolsAfter = JSON.stringify(after.tools, null, 2)
|
|
|
+ return (
|
|
|
+ <div className={css.promptDiffSections}>
|
|
|
+ {before.system !== after.system && (
|
|
|
+ <PromptDiffSection
|
|
|
+ title="System Prompt"
|
|
|
+ before={before.system}
|
|
|
+ after={after.system}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {toolsBefore !== toolsAfter && (
|
|
|
+ <PromptDiffSection
|
|
|
+ title="Tools"
|
|
|
+ before={toolsBefore}
|
|
|
+ after={toolsAfter}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function ToolOutputBlocks({
|
|
|
+ blocks,
|
|
|
+ preview,
|
|
|
+}: {
|
|
|
+ blocks: readonly TrajectorySourceBlock[]
|
|
|
+ preview: boolean
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div className={preview ? `${css.resultBlocks} ${css.resultBlocksPreview}` : css.resultBlocks}>
|
|
|
+ {blocks.map((block, index) => (
|
|
|
+ block.imageSrc !== undefined
|
|
|
+ ? <PanelImage block={block} preview={preview} key={index} />
|
|
|
+ : block.content !== ''
|
|
|
+ ? <pre className={css.resultBlockText} key={index}>{block.content}</pre>
|
|
|
+ : null
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function MarkdownRecordContent({
|
|
|
+ record,
|
|
|
+ rendered,
|
|
|
+ preview = false,
|
|
|
+ thinkingExpanded,
|
|
|
+ onThinkingExpandedChange,
|
|
|
+ onOpenCall,
|
|
|
+}: {
|
|
|
+ record: TableRecord
|
|
|
+ rendered: boolean
|
|
|
+ preview?: boolean
|
|
|
+ thinkingExpanded: boolean
|
|
|
+ onThinkingExpandedChange: (expanded: boolean) => void
|
|
|
+ onOpenCall: (callId: string) => void
|
|
|
+}) {
|
|
|
+ if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) {
|
|
|
+ return <SourceBlocks blocks={record.cell.sourceBlocks} onOpenCall={onOpenCall} />
|
|
|
+ }
|
|
|
+ if (record.cell.thinkingDetail) {
|
|
|
+ if (!rendered) {
|
|
|
+ const source = [
|
|
|
+ record.cell.thinkingDetail,
|
|
|
+ record.cell.outputDetail,
|
|
|
+ ].filter((value): value is string => value !== undefined && value !== '').join('\n\n')
|
|
|
+ return <MarkdownFragment text={source} rendered={false} preview={preview} />
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <div className={`${css.assistantContent} ${css.assistantContentRendered}`}>
|
|
|
+ <div className={
|
|
|
+ preview && !record.cell.outputDetail
|
|
|
+ ? `${css.thinkingQuote} ${css.thinkingQuoteOnlyPreview}`
|
|
|
+ : css.thinkingQuote
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.thinkingToggle}
|
|
|
+ aria-expanded={thinkingExpanded}
|
|
|
+ onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }}
|
|
|
+ >
|
|
|
+ {thinkingExpanded ? 'Thinking' : 'Thinking ...'}
|
|
|
+ </button>
|
|
|
+ {thinkingExpanded && (
|
|
|
+ <MarkdownFragment
|
|
|
+ text={record.cell.thinkingDetail}
|
|
|
+ rendered={rendered}
|
|
|
+ preview={preview}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {record.cell.outputDetail && (
|
|
|
+ <div className={css.assistantOutput}>
|
|
|
+ <MarkdownFragment
|
|
|
+ text={record.cell.outputDetail}
|
|
|
+ rendered={rendered}
|
|
|
+ preview={preview}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <AssistantToolCalls
|
|
|
+ blocks={record.cell.sourceBlocks}
|
|
|
+ preview={preview}
|
|
|
+ onOpenCall={onOpenCall}
|
|
|
+ />
|
|
|
+ <MessageImages
|
|
|
+ blocks={record.cell.sourceBlocks}
|
|
|
+ preview={preview}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ const source = markdownSource(record)
|
|
|
+ const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true
|
|
|
+ const hasToolCalls = record.cell.kind === 'message'
|
|
|
+ && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true
|
|
|
+ if (!source && !hasImages && !hasToolCalls) {
|
|
|
+ const emptyLabel = isToolCallOnly(record.cell)
|
|
|
+ ? 'Tool call only'
|
|
|
+ : record.cell.text || 'No content'
|
|
|
+ return <p className={css.noPayload}>{emptyLabel}</p>
|
|
|
+ }
|
|
|
+ if (!rendered || (!hasImages && !hasToolCalls)) {
|
|
|
+ return <MarkdownFragment text={source ?? ''} rendered={rendered} preview={preview} />
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ {source && <MarkdownFragment text={source} rendered preview={preview} />}
|
|
|
+ {record.cell.kind === 'message' && (
|
|
|
+ <AssistantToolCalls
|
|
|
+ blocks={record.cell.sourceBlocks}
|
|
|
+ preview={preview}
|
|
|
+ onOpenCall={onOpenCall}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ <MessageImages blocks={record.cell.sourceBlocks} preview={preview} />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function RecordTiming({ record }: { record: TableRecord }) {
|
|
|
+ return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined
|
|
|
+ ? <AssistantTimingPanel metrics={record.cell.assistantMetrics} />
|
|
|
+ : (
|
|
|
+ <dl className={css.overview}>
|
|
|
+ <div><dt>Started</dt><StartedAtValue timestamp={record.cell.startedAt ?? null} /></div>
|
|
|
+ <div><dt>Duration</dt><dd>{formatElapsedSeconds(record.cell.timeSeconds)}</dd></div>
|
|
|
+ <div><dt>Timing source</dt><dd>{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}</dd></div>
|
|
|
+ </dl>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function RequestTiming({
|
|
|
+ assistant,
|
|
|
+ anchor,
|
|
|
+ request,
|
|
|
+}: {
|
|
|
+ assistant: TableRecord | undefined
|
|
|
+ anchor: TableRecord | undefined
|
|
|
+ request: TrajectoryRequestNumber | undefined
|
|
|
+}) {
|
|
|
+ if (assistant !== undefined) return <RecordTiming record={assistant} />
|
|
|
+ if (request?.startedAt !== undefined) {
|
|
|
+ const duration = request.completedAt === null || request.completedAt === undefined
|
|
|
+ ? null
|
|
|
+ : Math.max(0, (request.completedAt - request.startedAt) / 1000)
|
|
|
+ return (
|
|
|
+ <dl className={css.overview}>
|
|
|
+ <div><dt>Started</dt><StartedAtValue timestamp={request.startedAt} /></div>
|
|
|
+ <div><dt>Duration</dt><dd>{formatElapsedSeconds(duration)}</dd></div>
|
|
|
+ <div>
|
|
|
+ <dt>Timing source</dt>
|
|
|
+ <dd>{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}</dd>
|
|
|
+ </div>
|
|
|
+ </dl>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <dl className={css.overview}>
|
|
|
+ <div>
|
|
|
+ <dt>Started</dt>
|
|
|
+ <StartedAtValue timestamp={anchor?.cell.startedAt ?? null} />
|
|
|
+ </div>
|
|
|
+ <div><dt>Duration</dt><dd>—</dd></div>
|
|
|
+ </dl>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function RecordPayload({
|
|
|
+ record,
|
|
|
+ direction,
|
|
|
+ preview = false,
|
|
|
+}: {
|
|
|
+ record: TableRecord
|
|
|
+ direction: 'input' | 'output'
|
|
|
+ preview?: boolean
|
|
|
+}) {
|
|
|
+ const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail
|
|
|
+ const missing = direction === 'input'
|
|
|
+ ? 'No payload captured'
|
|
|
+ : 'No result captured'
|
|
|
+ if (!value) return <p className={css.noPayload}>{missing}</p>
|
|
|
+
|
|
|
+ const json = parseJsonContainer(value)
|
|
|
+ const singleTextResult = direction === 'output'
|
|
|
+ && record.cell.outputBlocks?.length === 1
|
|
|
+ && record.cell.outputBlocks[0]?.type === 'text'
|
|
|
+ if (singleTextResult && json !== undefined) {
|
|
|
+ return (
|
|
|
+ <JsonTree
|
|
|
+ data={json}
|
|
|
+ label="Result JSON"
|
|
|
+ className={preview ? css.jsonPreview : css.jsonPayload}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ if (
|
|
|
+ direction === 'output'
|
|
|
+ && record.cell.outputBlocks?.some(block =>
|
|
|
+ block.imageSrc !== undefined || block.content !== '') === true
|
|
|
+ ) {
|
|
|
+ return (
|
|
|
+ <ToolOutputBlocks
|
|
|
+ blocks={record.cell.outputBlocks}
|
|
|
+ preview={preview}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const markdown = (
|
|
|
+ direction === 'input'
|
|
|
+ && (record.cell.kind === 'user' || record.cell.kind === 'context')
|
|
|
+ ) || (
|
|
|
+ direction === 'output' && record.cell.kind === 'message'
|
|
|
+ )
|
|
|
+ if (markdown) {
|
|
|
+ return (
|
|
|
+ <div className={preview ? css.markdownPreview : css.markdownPayload}>
|
|
|
+ <MarkdownText text={value} />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ if (json !== undefined) {
|
|
|
+ return (
|
|
|
+ <JsonTree
|
|
|
+ data={json}
|
|
|
+ label={`${direction === 'input' ? 'Payload' : 'Result'} JSON`}
|
|
|
+ className={preview ? css.jsonPreview : css.jsonPayload}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <pre className={[
|
|
|
+ css.payload,
|
|
|
+ preview ? css.payloadPreview : undefined,
|
|
|
+ record.cell.isError ? css.error : undefined,
|
|
|
+ value === 'No output' ? css.noOutputText : undefined,
|
|
|
+ ].filter((value): value is string => value !== undefined).join(' ')}
|
|
|
+ >
|
|
|
+ {value}
|
|
|
+ </pre>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function RecordSchema({
|
|
|
+ record,
|
|
|
+ preview = false,
|
|
|
+}: {
|
|
|
+ record: TableRecord
|
|
|
+ preview?: boolean
|
|
|
+}) {
|
|
|
+ if (!record.cell.schemaDetail) {
|
|
|
+ return <p className={css.noPayload}>Schema unavailable</p>
|
|
|
+ }
|
|
|
+ const schema = parseToolSchema(record.cell.schemaDetail)
|
|
|
+ if (schema !== undefined) {
|
|
|
+ return (
|
|
|
+ <div className={preview ? `${css.schema} ${css.schemaPreview}` : css.schema}>
|
|
|
+ <header className={css.schemaIntro}>
|
|
|
+ <h3 className={css.schemaName}>{schema.name}</h3>
|
|
|
+ <p className={css.schemaDescription}>{schema.description}</p>
|
|
|
+ </header>
|
|
|
+ <section className={css.schemaParameters}>
|
|
|
+ <h4 className={css.schemaParametersTitle}>Parameters</h4>
|
|
|
+ <JsonTree
|
|
|
+ data={schema.parameters}
|
|
|
+ label={`${schema.name} parameters JSON`}
|
|
|
+ className={css.schemaTree}
|
|
|
+ />
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <pre className={`${css.payload} ${preview ? css.payloadPreview : ''}`}>
|
|
|
+ {record.cell.schemaDetail}
|
|
|
+ </pre>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+interface ParsedToolSchema {
|
|
|
+ name: string
|
|
|
+ description: string
|
|
|
+ parameters: object
|
|
|
+}
|
|
|
+
|
|
|
+function parseToolSchema(value: string): ParsedToolSchema | undefined {
|
|
|
+ try {
|
|
|
+ const parsed: unknown = JSON.parse(value)
|
|
|
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
|
|
|
+ const schema = parsed as Record<string, unknown>
|
|
|
+ if (
|
|
|
+ typeof schema.name !== 'string'
|
|
|
+ || typeof schema.description !== 'string'
|
|
|
+ || typeof schema.parameters !== 'object'
|
|
|
+ || schema.parameters === null
|
|
|
+ || Array.isArray(schema.parameters)
|
|
|
+ ) return undefined
|
|
|
+ return {
|
|
|
+ name: schema.name,
|
|
|
+ description: schema.description,
|
|
|
+ parameters: schema.parameters,
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function parseJsonContainer(value: string): object | undefined {
|
|
|
+ try {
|
|
|
+ const parsed: unknown = JSON.parse(value)
|
|
|
+ return typeof parsed === 'object' && parsed !== null ? parsed : undefined
|
|
|
+ } catch {
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function OverviewSection({
|
|
|
+ label,
|
|
|
+ onOpen,
|
|
|
+ children,
|
|
|
+}: {
|
|
|
+ label: string
|
|
|
+ onOpen: () => void
|
|
|
+ children: ReactNode
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <section className={css.overviewSection}>
|
|
|
+ <h3 className={css.overviewHeading}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.overviewTitle}
|
|
|
+ onClick={onOpen}
|
|
|
+ >
|
|
|
+ <span>{label}</span>
|
|
|
+ <IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
|
|
|
+ </button>
|
|
|
+ </h3>
|
|
|
+ <div className={css.overviewPreview}>{children}</div>
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Render trajectory events as a dense ledger with turn and step separators.
|
|
|
+ * Clicking ledger whitespace clears the active record or request selection.
|
|
|
+ * @param props - Grouped trajectory data and whole-ledger fold state.
|
|
|
+ * @returns The ledger and an optional local record inspector.
|
|
|
+ */
|
|
|
+export function TrajectoryTable({
|
|
|
+ requestNumbers: sessionRequestNumbers,
|
|
|
+ turns,
|
|
|
+ timelineFocusIndexes = null,
|
|
|
+ searchMatchIndexes = null,
|
|
|
+ onSelectedIndexChange,
|
|
|
+ onRecordSelect,
|
|
|
+ onClearSelection,
|
|
|
+ collapsedTurns,
|
|
|
+ onToggleTurn,
|
|
|
+ collapsedAssistants,
|
|
|
+ onToggleAssistant,
|
|
|
+}: TrajectoryTableProps) {
|
|
|
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
|
|
+ const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
|
|
|
+ const [activeTab, setActiveTab] = useState<DetailTab>('overview')
|
|
|
+ const [thinkingExpanded, setThinkingExpanded] = useState(true)
|
|
|
+ const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
|
|
|
+ const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
|
|
|
+ const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
|
|
|
+ const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
|
|
|
+ useEffect(() => {
|
|
|
+ onSelectedIndexChange?.(selectedIndex)
|
|
|
+ }, [onSelectedIndexChange, selectedIndex])
|
|
|
+ const allRecords = flattenRecords(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 selectedPrompt = selected?.cell.kind === 'system'
|
|
|
+ ? selected.cell.promptDetail
|
|
|
+ : undefined
|
|
|
+ const selectedPreviousPrompt = selected?.cell.kind === 'system'
|
|
|
+ ? selected.cell.previousPromptDetail
|
|
|
+ : undefined
|
|
|
+ const promptSelected = selectedPrompt !== undefined
|
|
|
+ const selectedState = selected === undefined ? undefined : stateOf(selected)
|
|
|
+ const selectedRequestRecords = selectedRequest === null
|
|
|
+ ? []
|
|
|
+ : allRecords.filter(record =>
|
|
|
+ record.turn === selectedRequest.turn
|
|
|
+ && record.group === selectedRequest.group,
|
|
|
+ )
|
|
|
+ const selectedRequestAssistant = selectedRequestRecords.find(
|
|
|
+ record => record.cell.kind === 'message',
|
|
|
+ )
|
|
|
+ const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
|
|
|
+ const selectedRequestInfo = selectedRequest === null
|
|
|
+ ? undefined
|
|
|
+ : sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
|
|
|
+ const selectedRequestState: RecordState | undefined = selectedRequest === null
|
|
|
+ ? undefined
|
|
|
+ : selectedRequestInfo?.status
|
|
|
+ ?? (selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null
|
|
|
+ ? 'running'
|
|
|
+ : selectedRequestAssistant === undefined
|
|
|
+ && selectedRequestRecords.some(record => stateOf(record) === 'running')
|
|
|
+ ? 'running'
|
|
|
+ : 'complete')
|
|
|
+ const selectedRequestToolCalls = selectedRequestRecords.filter(
|
|
|
+ record => record.cell.kind === 'tool',
|
|
|
+ ).length
|
|
|
+ const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
|
|
|
+ record => record.cell.kind === 'subtool',
|
|
|
+ ).length
|
|
|
+ const selectedRequestResult = selectedRequestInfo?.resultSeq === undefined
|
|
|
+ ? selectedRequestAssistant
|
|
|
+ : allRecords.find(record => record.cell.sourceSeq === selectedRequestInfo.resultSeq)
|
|
|
+ const selectedRequestUsage = selectedRequestInfo?.usage ?? (
|
|
|
+ selectedRequestAssistant === undefined
|
|
|
+ ? undefined
|
|
|
+ : {
|
|
|
+ ...(selectedRequestAssistant.cell.input === undefined
|
|
|
+ ? {}
|
|
|
+ : { input: selectedRequestAssistant.cell.input }),
|
|
|
+ ...(selectedRequestAssistant.cell.cacheRead === undefined
|
|
|
+ ? {}
|
|
|
+ : { cacheRead: selectedRequestAssistant.cell.cacheRead }),
|
|
|
+ ...(selectedRequestAssistant.cell.cacheWrite === undefined
|
|
|
+ ? {}
|
|
|
+ : { cacheWrite: selectedRequestAssistant.cell.cacheWrite }),
|
|
|
+ ...(selectedRequestAssistant.cell.output === undefined
|
|
|
+ ? {}
|
|
|
+ : { output: selectedRequestAssistant.cell.output }),
|
|
|
+ ...(selectedRequestAssistant.cell.think === undefined
|
|
|
+ ? {}
|
|
|
+ : { reasoning: selectedRequestAssistant.cell.think }),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ const selectedRequestCumulativeUsage =
|
|
|
+ selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
|
|
|
+ const selectedRequestOptions = selectedRequestInfo?.requestConfig
|
|
|
+ const activeTurn = selectedRequest?.turn ?? selected?.turn
|
|
|
+ const selectedTabs = selectedRequest !== null
|
|
|
+ ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
|
|
|
+ : selected === undefined ? [] : detailTabs(selected)
|
|
|
+ const selectedParents: ParentRecords = selected === undefined
|
|
|
+ ? {}
|
|
|
+ : parentRecords(allRecords, selected)
|
|
|
+ const selectedParentMessage = selectedParents.message
|
|
|
+ const selectedParentTool = selectedParents.tool
|
|
|
+ const selectedAssistantRequest = selected?.cell.kind === 'message'
|
|
|
+ ? requestNumbers.get(requestKey(selected.turn, selected.group))
|
|
|
+ : undefined
|
|
|
+ const selectedAssistantRequestTarget: SelectedRequest | undefined =
|
|
|
+ selected !== undefined && selectedAssistantRequest !== undefined
|
|
|
+ ? {
|
|
|
+ turn: selected.turn,
|
|
|
+ number: selectedAssistantRequest,
|
|
|
+ group: selected.group,
|
|
|
+ }
|
|
|
+ : undefined
|
|
|
+ const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined
|
|
|
+ || selectedParents.message !== undefined
|
|
|
+ || selectedParents.tool !== undefined
|
|
|
+ const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null
|
|
|
+ ? undefined
|
|
|
+ : {
|
|
|
+ '--trajectory-tool-request-width': `calc(58cqw - ${toolRequestOffset}px)`,
|
|
|
+ }
|
|
|
+
|
|
|
+ const activateTab = (tab: DetailTab) => {
|
|
|
+ tabHistory.current.delete(tab)
|
|
|
+ tabHistory.current.add(tab)
|
|
|
+ setActiveTab(tab)
|
|
|
+ }
|
|
|
+
|
|
|
+ const clearInspectorSelection = () => {
|
|
|
+ setSelectedIndex(null)
|
|
|
+ setSelectedRequest(null)
|
|
|
+ }
|
|
|
+
|
|
|
+ const clearAllSelections = () => {
|
|
|
+ clearInspectorSelection()
|
|
|
+ onClearSelection?.()
|
|
|
+ }
|
|
|
+
|
|
|
+ const selectRecord = (index: number) => {
|
|
|
+ const record = allRecords.find(candidate => candidate.cell.index === index)
|
|
|
+ onRecordSelect?.(index)
|
|
|
+ setSelectedRequest(null)
|
|
|
+ setSelectedIndex(index)
|
|
|
+ if (record === undefined) return
|
|
|
+ const tabs = detailTabs(record)
|
|
|
+ const available = new Set(tabs.map(tab => tab.id))
|
|
|
+ const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
|
|
|
+ setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
|
|
|
+ }
|
|
|
+
|
|
|
+ const selectRequest = (
|
|
|
+ request: SelectedRequest,
|
|
|
+ tab: 'overview' | 'timing' = 'overview',
|
|
|
+ ) => {
|
|
|
+ setSelectedIndex(null)
|
|
|
+ setSelectedRequest(request)
|
|
|
+ activateTab(tab)
|
|
|
+ }
|
|
|
+
|
|
|
+ const openRecordSummary = (target: TableRecord) => {
|
|
|
+ const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index)
|
|
|
+ if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn)
|
|
|
+ if (target.cell.kind === 'tool' || target.cell.kind === 'subtool') {
|
|
|
+ for (let i = targetAt - 1; i >= 0; i--) {
|
|
|
+ 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)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ setSelectedRequest(null)
|
|
|
+ setSelectedIndex(target.cell.index)
|
|
|
+ activateTab('overview')
|
|
|
+ }
|
|
|
+
|
|
|
+ const openCallSummary = (callId: string) => {
|
|
|
+ const target = allRecords.find(record => record.cell.callId === callId)
|
|
|
+ if (target !== undefined) openRecordSummary(target)
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={css.split} style={splitStyle}>
|
|
|
+ <div
|
|
|
+ className={css.tablePane}
|
|
|
+ onClick={(event) => {
|
|
|
+ if (event.target === event.currentTarget) clearAllSelections()
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <table className={css.table}>
|
|
|
+ <colgroup>
|
|
|
+ <col className={css.eventColumn} />
|
|
|
+ <col className={css.contentColumn} />
|
|
|
+ </colgroup>
|
|
|
+ <tbody>
|
|
|
+ {records.map((record) => {
|
|
|
+ const displayText = recordDisplayText(record.cell)
|
|
|
+ const toolCallText = toolCallTextParts(record.cell.kind, displayText)
|
|
|
+ const listDisplayText = 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'
|
|
|
+ && record.cell.index === allRecords[0]?.cell.index
|
|
|
+ const request = record.groupStart
|
|
|
+ && !isCollapsedSummary
|
|
|
+ && !collapsedTurns.has(record.turn)
|
|
|
+ ? requestNumbers.get(requestKey(record.turn, record.group))
|
|
|
+ : undefined
|
|
|
+ const requestInfo = request === undefined
|
|
|
+ ? undefined
|
|
|
+ : sessionRequestNumbers?.find(candidate => candidate.number === request)
|
|
|
+ const requestLabel = request === undefined
|
|
|
+ ? undefined
|
|
|
+ : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
|
|
|
+ const requestSelected = request !== undefined
|
|
|
+ && selectedRequest?.turn === record.turn
|
|
|
+ && selectedRequest.number === request
|
|
|
+ return (
|
|
|
+ <tr
|
|
|
+ key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
|
|
|
+ tabIndex={isRequestOnly ? -1 : 0}
|
|
|
+ aria-label={isCollapsedSummary
|
|
|
+ ? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
|
|
|
+ : isRequestOnly
|
|
|
+ ? `Request ${request ?? ''}, compaction`
|
|
|
+ : `${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-record-index={!isCollapsedSummary && !isRequestOnly
|
|
|
+ ? record.cell.index
|
|
|
+ : undefined}
|
|
|
+ data-request-only={isRequestOnly || undefined}
|
|
|
+ data-group-start={record.groupStart || undefined}
|
|
|
+ data-turn-start={record.turnStart || undefined}
|
|
|
+ data-error={record.cell.isError || undefined}
|
|
|
+ data-running={stateOf(record) === 'running' || undefined}
|
|
|
+ data-turn-end={record.turnEnd || undefined}
|
|
|
+ data-collapsed-summary={record.collapsedSummaryKind}
|
|
|
+ data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
|
|
|
+ data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
|
|
|
+ ? undefined
|
|
|
+ : timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
|
|
|
+ onClick={isRequestOnly
|
|
|
+ ? undefined
|
|
|
+ : isCollapsedSummary
|
|
|
+ ? () => {
|
|
|
+ if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
|
|
|
+ else onToggleAssistant(record.cell.index)
|
|
|
+ }
|
|
|
+ : () => { selectRecord(record.cell.index) }}
|
|
|
+ onDoubleClick={(event) => {
|
|
|
+ if (isCollapsedSummary || isRequestOnly) return
|
|
|
+ if (collapsedTurns.has(record.turn)) {
|
|
|
+ event.preventDefault()
|
|
|
+ onToggleTurn(record.turn)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ record.cell.kind === 'message'
|
|
|
+ && assistantToolCalls(allRecords, record.cell.index).length > 0
|
|
|
+ ) {
|
|
|
+ event.preventDefault()
|
|
|
+ onToggleAssistant(record.cell.index)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!record.turnStart) return
|
|
|
+ if (allRecords.filter(candidate =>
|
|
|
+ candidate.turn === record.turn
|
|
|
+ && candidate.cell.requestOnly !== true
|
|
|
+ && candidate.cell.kind !== 'system').length <= 1) return
|
|
|
+ event.preventDefault()
|
|
|
+ onToggleTurn(record.turn)
|
|
|
+ }}
|
|
|
+ onKeyDown={(event) => {
|
|
|
+ if (isRequestOnly) return
|
|
|
+ if (event.key !== 'Enter' && event.key !== ' ') return
|
|
|
+ event.preventDefault()
|
|
|
+ if (isCollapsedSummary) {
|
|
|
+ if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
|
|
|
+ else onToggleAssistant(record.cell.index)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ selectRecord(record.cell.index)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <td className={css.event}>
|
|
|
+ {request !== undefined && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={requestSelected
|
|
|
+ ? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
|
|
|
+ : css.requestBoundaryControl}
|
|
|
+ aria-label={requestLabel}
|
|
|
+ aria-pressed={requestSelected}
|
|
|
+ data-label={requestLabel}
|
|
|
+ onClick={(event) => {
|
|
|
+ event.stopPropagation()
|
|
|
+ selectRequest({
|
|
|
+ turn: record.turn,
|
|
|
+ number: request,
|
|
|
+ group: record.group,
|
|
|
+ })
|
|
|
+ }}
|
|
|
+ onDoubleClick={(event) => { event.stopPropagation() }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {activeTurn === record.turn && !isInitialSystem && (
|
|
|
+ <span className={css.turnRail} aria-hidden="true" />
|
|
|
+ )}
|
|
|
+ {!isCollapsedSummary && selectedIndex === record.cell.index && (
|
|
|
+ <span className={css.selectionRail} aria-hidden="true" />
|
|
|
+ )}
|
|
|
+ {!isCollapsedSummary
|
|
|
+ && !isRequestOnly
|
|
|
+ && record.turnStart && (
|
|
|
+ <span
|
|
|
+ className={activeTurn === record.turn
|
|
|
+ ? `${css.turnLabel} ${css.turnLabelActive}`
|
|
|
+ : css.turnLabel}
|
|
|
+ >
|
|
|
+ Turn {record.turn}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ <div className={css.eventInner}>
|
|
|
+ {!isCollapsedSummary && !isRequestOnly && (
|
|
|
+ <span
|
|
|
+ className={css.kindSlot}
|
|
|
+ >
|
|
|
+ <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]
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {KIND_LABEL[record.cell.kind]}
|
|
|
+ </span>
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td className={css.content}>
|
|
|
+ {isRequestOnly
|
|
|
+ ? null
|
|
|
+ : record.collapsedSummary !== undefined
|
|
|
+ ? (
|
|
|
+ <span className={css.collapsedTurnContent} title={record.collapsedSummary}>
|
|
|
+ <span className={css.collapsedTurnEllipsis}>…</span>
|
|
|
+ <span className={css.collapsedTurnText}>{record.collapsedSummary}</span>
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+ : (
|
|
|
+ <span
|
|
|
+ className={record.cell.result === undefined ? css.contentText : css.resultPreview}
|
|
|
+ title={record.cell.result === undefined
|
|
|
+ ? listDisplayText
|
|
|
+ : `${listDisplayText} → ${record.cell.result}`}
|
|
|
+ >
|
|
|
+ <span className={record.cell.result === undefined ? undefined : css.resultRequest}>
|
|
|
+ {isToolCallOnly(record.cell)
|
|
|
+ ? null
|
|
|
+ : toolCallText === undefined
|
|
|
+ ? listDisplayText || '—'
|
|
|
+ : (
|
|
|
+ <>
|
|
|
+ <span className={css.toolCallNameTypeface}>
|
|
|
+ {toolCallText.name || '—'}
|
|
|
+ </span>
|
|
|
+ {toolCallText.args !== undefined && (
|
|
|
+ <span className={css.toolCallPayload}>
|
|
|
+ {toolCallText.args}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ {record.cell.result !== undefined && (
|
|
|
+ <span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
|
|
|
+ <span className={css.arrow}>→</span>
|
|
|
+ <span className={record.cell.result === 'No output'
|
|
|
+ ? `${css.inlineResultText} ${css.noOutputText}`
|
|
|
+ : css.inlineResultText}
|
|
|
+ >
|
|
|
+ {record.cell.result}
|
|
|
+ </span>
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ )
|
|
|
+ })}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ {(selectedRequest !== null
|
|
|
+ || promptSelected
|
|
|
+ || (selected !== undefined && selectedState !== undefined)) && (
|
|
|
+ <aside
|
|
|
+ className={css.details}
|
|
|
+ aria-label="Event details"
|
|
|
+ style={detailsWidth === null ? undefined : { width: detailsWidth }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ className={css.detailsResizeHandle}
|
|
|
+ role="separator"
|
|
|
+ aria-label="Resize event details"
|
|
|
+ aria-controls="trajectory-detail-panel"
|
|
|
+ aria-orientation="vertical"
|
|
|
+ tabIndex={0}
|
|
|
+ title="Drag to resize. Double-click to reset."
|
|
|
+ onDoubleClick={() => {
|
|
|
+ setDetailsWidth(null)
|
|
|
+ setToolRequestOffset(null)
|
|
|
+ }}
|
|
|
+ onPointerDown={(event) => {
|
|
|
+ if (event.button !== 0) return
|
|
|
+ const details = event.currentTarget.parentElement
|
|
|
+ if (details === null) return
|
|
|
+ const split = details.parentElement
|
|
|
+ if (split === null) return
|
|
|
+ const splitWidth = split.getBoundingClientRect().width
|
|
|
+ detailsResizeDrag.current = {
|
|
|
+ pointerId: event.pointerId,
|
|
|
+ startX: event.clientX,
|
|
|
+ startWidth: details.getBoundingClientRect().width,
|
|
|
+ splitWidth,
|
|
|
+ startToolRequestOffset: toolRequestOffset ?? (
|
|
|
+ splitWidth * TOOL_REQUEST_SHARE - defaultToolRequestWidth(splitWidth)
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ event.currentTarget.setPointerCapture(event.pointerId)
|
|
|
+ event.preventDefault()
|
|
|
+ }}
|
|
|
+ onPointerMove={(event) => {
|
|
|
+ const drag = detailsResizeDrag.current
|
|
|
+ if (drag === null || drag.pointerId !== event.pointerId) return
|
|
|
+ const nextDetailsWidth = clampDetailsWidth(
|
|
|
+ drag.startWidth + drag.startX - event.clientX,
|
|
|
+ drag.splitWidth,
|
|
|
+ )
|
|
|
+ setDetailsWidth(nextDetailsWidth)
|
|
|
+ setToolRequestOffset(
|
|
|
+ drag.startToolRequestOffset
|
|
|
+ + (nextDetailsWidth - drag.startWidth) * TOOL_REQUEST_SHARE,
|
|
|
+ )
|
|
|
+ }}
|
|
|
+ onPointerUp={(event) => {
|
|
|
+ if (detailsResizeDrag.current?.pointerId !== event.pointerId) return
|
|
|
+ detailsResizeDrag.current = null
|
|
|
+ event.currentTarget.releasePointerCapture(event.pointerId)
|
|
|
+ }}
|
|
|
+ onPointerCancel={() => {
|
|
|
+ detailsResizeDrag.current = null
|
|
|
+ }}
|
|
|
+ onKeyDown={(event) => {
|
|
|
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
|
+ const details = event.currentTarget.parentElement
|
|
|
+ if (details === null) return
|
|
|
+ const split = details.parentElement
|
|
|
+ if (split === null) return
|
|
|
+ const direction = event.key === 'ArrowLeft' ? 1 : -1
|
|
|
+ const currentDetailsWidth = details.getBoundingClientRect().width
|
|
|
+ const splitWidth = split.getBoundingClientRect().width
|
|
|
+ const nextDetailsWidth = clampDetailsWidth(
|
|
|
+ currentDetailsWidth + direction * DETAILS_RESIZE_STEP,
|
|
|
+ splitWidth,
|
|
|
+ )
|
|
|
+ const currentToolRequestOffset = toolRequestOffset ?? (
|
|
|
+ splitWidth * TOOL_REQUEST_SHARE - defaultToolRequestWidth(splitWidth)
|
|
|
+ )
|
|
|
+ setDetailsWidth(nextDetailsWidth)
|
|
|
+ setToolRequestOffset(
|
|
|
+ currentToolRequestOffset
|
|
|
+ + (nextDetailsWidth - currentDetailsWidth) * TOOL_REQUEST_SHARE,
|
|
|
+ )
|
|
|
+ event.preventDefault()
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <div className={css.detailsHeader}>
|
|
|
+ <div className={css.detailsTitle}>
|
|
|
+ {selectedRequest !== null
|
|
|
+ ? (
|
|
|
+ <>
|
|
|
+ <span className={css.requestDetailsDot} aria-hidden="true" />
|
|
|
+ <span className={css.requestDetailsName}>
|
|
|
+ Request #{selectedRequest.number}
|
|
|
+ </span>
|
|
|
+ <span className={css.detailsLocation}>
|
|
|
+ {selectedRequestInfo?.purpose === 'compaction'
|
|
|
+ ? `Compaction · Turn ${selectedRequest.turn}`
|
|
|
+ : `Turn ${selectedRequest.turn}`}
|
|
|
+ </span>
|
|
|
+ </>
|
|
|
+ )
|
|
|
+ : promptSelected
|
|
|
+ ? (
|
|
|
+ <>
|
|
|
+ <span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
|
|
|
+ <span className={css.detailsLocation}>{selected?.cell.text}</span>
|
|
|
+ </>
|
|
|
+ )
|
|
|
+ : selected !== undefined && (
|
|
|
+ <>
|
|
|
+ <span className={`${css.kindTag} ${
|
|
|
+ selected.cell.kind === 'context'
|
|
|
+ ? css.contextGreen
|
|
|
+ : selected.cell.kind === 'compacted'
|
|
|
+ ? css.compacted
|
|
|
+ : selected.cell.kind === 'tool'
|
|
|
+ ? css.toolAmber
|
|
|
+ : selected.cell.kind === 'message'
|
|
|
+ ? css.assistantVioletBright
|
|
|
+ : selected.cell.kind === 'subtool'
|
|
|
+ ? css.subtoolAmber
|
|
|
+ : css[selected.cell.kind]
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {KIND_LABEL[selected.cell.kind]}
|
|
|
+ </span>
|
|
|
+ <span className={css.detailsLocation}>
|
|
|
+ {selected.cell.kind === 'compacted'
|
|
|
+ ? `Turn ${selected.turn}`
|
|
|
+ : `Turn ${selected.turn} · ${selected.group}`}
|
|
|
+ </span>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.close}
|
|
|
+ aria-label="Close details"
|
|
|
+ onClick={clearInspectorSelection}
|
|
|
+ >
|
|
|
+ <span aria-hidden="true">×</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div className={css.detailTabs} role="tablist" aria-label="Event details">
|
|
|
+ {selectedTabs.map(tab => (
|
|
|
+ <button
|
|
|
+ key={tab.id}
|
|
|
+ id={`trajectory-detail-${tab.id}`}
|
|
|
+ type="button"
|
|
|
+ role="tab"
|
|
|
+ aria-controls="trajectory-detail-panel"
|
|
|
+ aria-selected={activeTab === tab.id}
|
|
|
+ className={activeTab === tab.id ? `${css.detailTab} ${css.detailTabActive}` : css.detailTab}
|
|
|
+ onClick={() => { activateTab(tab.id) }}
|
|
|
+ >
|
|
|
+ {tab.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ id="trajectory-detail-panel"
|
|
|
+ className={activeTab === 'overview'
|
|
|
+ ? `${css.detailBody} ${css.detailBodySummary}`
|
|
|
+ : css.detailBody}
|
|
|
+ role="tabpanel"
|
|
|
+ aria-labelledby={`trajectory-detail-${activeTab}`}
|
|
|
+ >
|
|
|
+ {selectedRequest !== null
|
|
|
+ && selectedRequestState !== undefined
|
|
|
+ && activeTab === 'overview' && (
|
|
|
+ <>
|
|
|
+ <dl className={css.overview}>
|
|
|
+ <div>
|
|
|
+ <dt>Status</dt>
|
|
|
+ <dd>{statusLabel(selectedRequestState)}</dd>
|
|
|
+ </div>
|
|
|
+ {selectedRequestInfo?.purpose === 'compaction' && (
|
|
|
+ <div>
|
|
|
+ <dt>Purpose</dt>
|
|
|
+ <dd>Compaction</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {(selectedRequestInfo?.provider
|
|
|
+ ?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
|
|
|
+ <div>
|
|
|
+ <dt>Provider</dt>
|
|
|
+ <dd>
|
|
|
+ {selectedRequestInfo?.provider
|
|
|
+ ?? selectedRequestInfo?.requestConfig?.provider}
|
|
|
+ </dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {(selectedRequestInfo?.model
|
|
|
+ ?? selectedRequestInfo?.requestConfig?.model) !== undefined && (
|
|
|
+ <div>
|
|
|
+ <dt>Model</dt>
|
|
|
+ <dd>
|
|
|
+ {selectedRequestInfo?.model
|
|
|
+ ?? selectedRequestInfo?.requestConfig?.model}
|
|
|
+ </dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <div>
|
|
|
+ <dt>Tool calls</dt>
|
|
|
+ <dd>{selectedRequestToolCalls}</dd>
|
|
|
+ </div>
|
|
|
+ {selectedRequestSubtoolCalls > 0 && (
|
|
|
+ <div>
|
|
|
+ <dt>Subtool calls</dt>
|
|
|
+ <dd>{selectedRequestSubtoolCalls}</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {selectedRequestInfo?.error !== undefined && (
|
|
|
+ <div>
|
|
|
+ <dt>Error</dt>
|
|
|
+ <dd>{selectedRequestInfo.error}</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {selectedRequestInfo?.retry !== undefined && (
|
|
|
+ <div>
|
|
|
+ <dt>Retry</dt>
|
|
|
+ <dd>
|
|
|
+ Scheduled {selectedRequestInfo.retry}
|
|
|
+ {selectedRequestInfo.maxRetries === undefined
|
|
|
+ ? ''
|
|
|
+ : ` of ${selectedRequestInfo.maxRetries}`}
|
|
|
+ </dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {selectedRequestInfo?.retryDelayMs !== undefined && (
|
|
|
+ <div>
|
|
|
+ <dt>Retry delay</dt>
|
|
|
+ <dd>{formatDurationMs(selectedRequestInfo.retryDelayMs)}</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {selectedRequestResult !== undefined && (
|
|
|
+ <div>
|
|
|
+ <dt>Result</dt>
|
|
|
+ <dd className={css.overviewParentLinks}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.overviewHierarchyNavLink}
|
|
|
+ onClick={() => {
|
|
|
+ openRecordSummary(selectedRequestResult)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <span>
|
|
|
+ {selectedRequestInfo?.purpose === 'compaction'
|
|
|
+ ? 'Compacted'
|
|
|
+ : 'Assistant Message'}
|
|
|
+ </span>
|
|
|
+ <IconChevronRightOutline14
|
|
|
+ className={css.overviewHierarchyJumpIconTight}
|
|
|
+ size={11}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ </dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </dl>
|
|
|
+ <div className={css.overviewSections}>
|
|
|
+ {selectedRequestOptions !== undefined && (
|
|
|
+ <OverviewSection label="Options" onOpen={() => { activateTab('options') }}>
|
|
|
+ <RequestOptions options={selectedRequestOptions} preview />
|
|
|
+ </OverviewSection>
|
|
|
+ )}
|
|
|
+ <OverviewSection label="Usage" onOpen={() => { activateTab('usage') }}>
|
|
|
+ <UsageRows usage={selectedRequestUsage} />
|
|
|
+ </OverviewSection>
|
|
|
+ <OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
|
|
|
+ <RequestTiming
|
|
|
+ assistant={selectedRequestAssistant}
|
|
|
+ anchor={selectedRequestAnchor}
|
|
|
+ request={selectedRequestInfo}
|
|
|
+ />
|
|
|
+ </OverviewSection>
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {selectedRequest !== null && activeTab === 'options' && (
|
|
|
+ <RequestOptions options={selectedRequestOptions} />
|
|
|
+ )}
|
|
|
+ {selectedRequest !== null && activeTab === 'usage' && (
|
|
|
+ <RequestUsagePanel
|
|
|
+ usage={selectedRequestUsage}
|
|
|
+ cumulative={selectedRequestCumulativeUsage}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {selectedRequest !== null && activeTab === 'timing' && (
|
|
|
+ <RequestTiming
|
|
|
+ assistant={selectedRequestAssistant}
|
|
|
+ anchor={selectedRequestAnchor}
|
|
|
+ request={selectedRequestInfo}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {promptSelected
|
|
|
+ && selectedPreviousPrompt !== undefined
|
|
|
+ && activeTab === 'diff' && (
|
|
|
+ <SystemPromptDiff
|
|
|
+ before={selectedPreviousPrompt}
|
|
|
+ after={selectedPrompt}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {promptSelected && activeTab === 'system-prompt' && (
|
|
|
+ selectedPrompt.system === ''
|
|
|
+ ? <p className={css.noPayload}>No system prompt in this request</p>
|
|
|
+ : (
|
|
|
+ <div className={`${css.markdownPayload} ${css.systemPrompt}`}>
|
|
|
+ <MarkdownText text={selectedPrompt.system} />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ )}
|
|
|
+ {promptSelected && activeTab === 'tools' && (
|
|
|
+ <ToolCatalog tools={selectedPrompt.tools} />
|
|
|
+ )}
|
|
|
+ {!promptSelected
|
|
|
+ && selected?.cell.kind === 'compacted'
|
|
|
+ && selectedState !== undefined
|
|
|
+ && activeTab === 'overview' && (
|
|
|
+ <>
|
|
|
+ <dl className={css.overview}>
|
|
|
+ <div>
|
|
|
+ <dt>Status</dt>
|
|
|
+ <dd>{statusLabel(selectedState)}</dd>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <dt>Duration</dt>
|
|
|
+ <dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <dt>Tokens</dt>
|
|
|
+ <dd>—</dd>
|
|
|
+ </div>
|
|
|
+ </dl>
|
|
|
+ {selected.cell.outputDetail !== undefined && (
|
|
|
+ <div className={css.compactedSummary}>
|
|
|
+ <MarkdownRecordContent
|
|
|
+ record={selected}
|
|
|
+ rendered
|
|
|
+ thinkingExpanded={thinkingExpanded}
|
|
|
+ onThinkingExpandedChange={setThinkingExpanded}
|
|
|
+ onOpenCall={openCallSummary}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {!promptSelected
|
|
|
+ && selected !== undefined
|
|
|
+ && selected.cell.kind !== 'compacted'
|
|
|
+ && selectedState !== undefined
|
|
|
+ && activeTab === 'overview' && (
|
|
|
+ <>
|
|
|
+ <dl className={css.overview}>
|
|
|
+ {selected.cell.messageSource !== undefined && (
|
|
|
+ <div>
|
|
|
+ <dt>Origin</dt>
|
|
|
+ <dd className={css.overviewParentLinks}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.overviewHierarchyNavLink}
|
|
|
+ onClick={() => { activateTab('origin') }}
|
|
|
+ >
|
|
|
+ <span>{messageOriginLabel(selected.cell.messageSource)}</span>
|
|
|
+ <IconChevronRightOutline14
|
|
|
+ className={css.overviewHierarchyJumpIconTight}
|
|
|
+ size={11}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ </dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {hasSelectedHierarchy && (
|
|
|
+ <div>
|
|
|
+ <dt>
|
|
|
+ {selectedAssistantRequestTarget !== undefined
|
|
|
+ ? 'Origin'
|
|
|
+ : 'Hierarchy'}
|
|
|
+ </dt>
|
|
|
+ <dd className={css.overviewParentLinks}>
|
|
|
+ {selectedAssistantRequestTarget !== undefined && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.overviewHierarchyNavLink}
|
|
|
+ onClick={() => {
|
|
|
+ selectRequest(selectedAssistantRequestTarget)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <span>Request #{selectedAssistantRequestTarget.number}</span>
|
|
|
+ <IconChevronRightOutline14
|
|
|
+ className={css.overviewHierarchyJumpIconTight}
|
|
|
+ size={11}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {selectedParentMessage !== undefined && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.overviewHierarchyNavLink}
|
|
|
+ onClick={() => { openRecordSummary(selectedParentMessage) }}
|
|
|
+ >
|
|
|
+ <span>Assistant Message</span>
|
|
|
+ <IconChevronRightOutline14
|
|
|
+ className={css.overviewHierarchyJumpIconTight}
|
|
|
+ size={11}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {selectedParentTool !== undefined && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.overviewHierarchyNavLink}
|
|
|
+ onClick={() => { openRecordSummary(selectedParentTool) }}
|
|
|
+ >
|
|
|
+ <span>Tool Call</span>
|
|
|
+ <IconChevronRightOutline14
|
|
|
+ className={css.overviewHierarchyJumpIconTight}
|
|
|
+ size={11}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <div>
|
|
|
+ <dt>Status</dt>
|
|
|
+ <dd>{statusLabel(selectedState)}</dd>
|
|
|
+ </div>
|
|
|
+ {selected.cell.kind === 'message' && (
|
|
|
+ <TokenRows cell={selected.cell} />
|
|
|
+ )}
|
|
|
+ {(selected.cell.kind === 'user' || selected.cell.kind === 'context') && (
|
|
|
+ <div>
|
|
|
+ <dt>Duration</dt>
|
|
|
+ <dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </dl>
|
|
|
+ <div className={css.overviewSections}>
|
|
|
+ {isMarkdownRecord(selected)
|
|
|
+ ? (
|
|
|
+ <>
|
|
|
+ <OverviewSection label="Preview" onOpen={() => { activateTab('rendered') }}>
|
|
|
+ <MarkdownRecordContent
|
|
|
+ record={selected}
|
|
|
+ rendered
|
|
|
+ preview
|
|
|
+ thinkingExpanded={thinkingExpanded}
|
|
|
+ onThinkingExpandedChange={setThinkingExpanded}
|
|
|
+ onOpenCall={openCallSummary}
|
|
|
+ />
|
|
|
+ </OverviewSection>
|
|
|
+ </>
|
|
|
+ )
|
|
|
+ : (
|
|
|
+ <>
|
|
|
+ {selected.cell.inputDetail && (
|
|
|
+ <OverviewSection label="Payload" onOpen={() => { activateTab('input') }}>
|
|
|
+ <RecordPayload record={selected} direction="input" preview />
|
|
|
+ </OverviewSection>
|
|
|
+ )}
|
|
|
+ {selected.cell.outputDetail && (
|
|
|
+ <OverviewSection label="Result" onOpen={() => { activateTab('output') }}>
|
|
|
+ <RecordPayload record={selected} direction="output" preview />
|
|
|
+ </OverviewSection>
|
|
|
+ )}
|
|
|
+ <OverviewSection label="Schema" onOpen={() => { activateTab('schema') }}>
|
|
|
+ <RecordSchema record={selected} preview />
|
|
|
+ </OverviewSection>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {selectedAssistantRequestTarget !== undefined && (
|
|
|
+ <OverviewSection
|
|
|
+ label="Timing"
|
|
|
+ onOpen={() => {
|
|
|
+ selectRequest(selectedAssistantRequestTarget, 'timing')
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <RecordTiming record={selected} />
|
|
|
+ </OverviewSection>
|
|
|
+ )}
|
|
|
+ {(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && (
|
|
|
+ <OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
|
|
|
+ <RecordTiming record={selected} />
|
|
|
+ </OverviewSection>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {!promptSelected && selected !== undefined && activeTab === 'rendered' && (
|
|
|
+ <MarkdownRecordContent
|
|
|
+ record={selected}
|
|
|
+ rendered
|
|
|
+ thinkingExpanded={thinkingExpanded}
|
|
|
+ onThinkingExpandedChange={setThinkingExpanded}
|
|
|
+ onOpenCall={openCallSummary}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {!promptSelected && selected !== undefined && activeTab === 'source' && (
|
|
|
+ <MarkdownRecordContent
|
|
|
+ record={selected}
|
|
|
+ rendered={false}
|
|
|
+ thinkingExpanded={thinkingExpanded}
|
|
|
+ onThinkingExpandedChange={setThinkingExpanded}
|
|
|
+ onOpenCall={openCallSummary}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {!promptSelected && selected !== undefined && activeTab === 'origin' && (
|
|
|
+ <MessageOrigin record={selected} />
|
|
|
+ )}
|
|
|
+ {!promptSelected && selected !== undefined && activeTab === 'input' && (
|
|
|
+ <RecordPayload record={selected} direction="input" />
|
|
|
+ )}
|
|
|
+ {!promptSelected && selected !== undefined && activeTab === 'output' && (
|
|
|
+ <RecordPayload record={selected} direction="output" />
|
|
|
+ )}
|
|
|
+ {!promptSelected && selected !== undefined && activeTab === 'schema' && (
|
|
|
+ <RecordSchema record={selected} />
|
|
|
+ )}
|
|
|
+ {!promptSelected && selected !== undefined && activeTab === 'timing' && (
|
|
|
+ <RecordTiming record={selected} />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|