@@ -4,6 +4,7 @@ import { type ReactNode, useState } from 'react'
44import { Button } from '@sim/emcn/button'
55import type { IconName } from '@sim/emcn/catalog/icon-geometry'
66import { Icon } from '@sim/emcn/icon'
7+ import { FALLBACK_PROVIDER_MARK , providerMark } from '@sim/emcn/icon/provider-marks'
78import { OverflowText } from '@sim/emcn/overflow-text'
89import { PropertyList } from '@sim/emcn/property-list'
910import { DirectoryHeading } from '@/app/(product)/_components/resource-directory/resource-directory'
@@ -27,8 +28,8 @@ import {
2728} from '@/app/(product)/_components/workspace/workspace-session/format'
2829import type { WorkspaceRead } from '@/app/(product)/_components/workspace/workspace-session/model'
2930import { useViewerTimeZone } from '@/app/(product)/_components/workspace/workspace-session/workspace-session'
30- import { formatTimestamp } from '@/platform/shared/time'
31- import type { RunDetail , TraceSpan } from '@/platform/shared/workspace-records'
31+ import { formatClockTime , formatTimestamp } from '@/platform/shared/time'
32+ import type { RunCost , RunDetail , RunFile , TraceSpan } from '@/platform/shared/workspace-records'
3233
3334const KIND_ICON : Record < TraceSpan [ 'kind' ] , IconName > = {
3435 agent : 'I128' ,
@@ -44,29 +45,57 @@ const KIND_ICON: Record<TraceSpan['kind'], IconName> = {
4445}
4546
4647const PROVIDER_ICON : Partial < Record < string , IconName > > = { slack : 'I123' , openai : 'I124' }
47- /** Providers whose own mark the shared icon catalog carries . */
48- const PROVIDER_MARKS = new Set ( [ 'salesforce' , 'notion ', 'github ' , 'linear ' , 'jira ' , 'hubspot ' ] )
48+ /** Kinds whose own glyph reads before any provider's mark . */
49+ const KIND_FIRST = new Set < TraceSpan [ 'kind' ] > ( [ 'agent ', 'loop ' , 'parallel ' , 'workflow ' , 'mcp ' ] )
4950
51+ /** A step's mark: its kind's glyph, then its tool's, model's or block's provider, as legacy marks it. */
5052function spanMark ( span : TraceSpan ) {
51- const providerIcon = span . providerId ? PROVIDER_ICON [ span . providerId ] : undefined
53+ if ( span . kind === 'iteration' || span . kind === 'branch' ) return undefined
54+ if ( KIND_FIRST . has ( span . kind ) || ! span . providerId ) return < Icon name = { KIND_ICON [ span . kind ] } />
55+ const providerIcon = PROVIDER_ICON [ span . providerId ]
5256 if ( providerIcon ) return < Icon name = { providerIcon } />
53- if ( span . providerId && PROVIDER_MARKS . has ( span . providerId ) )
57+ if ( providerMark ( span . providerId ) !== FALLBACK_PROVIDER_MARK )
5458 return < Icon providerId = { span . providerId } />
5559 return < Icon name = { KIND_ICON [ span . kind ] } />
5660}
5761
58- /** The run's open steps in reading order, each placed on the root's one clock. */
62+ /** One decimal under 10%, whole percents above, as the legacy timing tooltip reads. */
63+ function percentLabel ( value : number ) {
64+ return `${ value . toFixed ( value >= 10 ? 0 : 1 ) } %`
65+ }
66+
67+ /** Whether a step failed: a recorded error on it or on any step beneath it. */
68+ function failedInTree ( span : TraceSpan ) : boolean {
69+ return span . status === 'error' || Boolean ( span . children ?. some ( failedInTree ) )
70+ }
71+
72+ /** The run's open steps in reading order, each placed on the run's one clock. */
5973function traceRows (
6074 spans : readonly TraceSpan [ ] ,
6175 collapsed : ReadonlySet < string > ,
6276 totalMs : number ,
77+ shown : ReadonlySet < string > | null ,
78+ parent ?: TraceSpan ,
6379 depth = 0
6480) : TraceRow [ ] {
6581 const clock = Math . max ( 1 , totalMs )
6682 const share = ( ms : number ) => Math . max ( 0 , Math . min ( 100 , ( 100 * ms ) / clock ) )
6783 return spans . flatMap ( ( span ) => {
84+ if ( shown && ! shown . has ( span . id ) ) return [ ]
6885 const expandable = Boolean ( span . children ?. length )
6986 const start = share ( span . startMs )
87+ const ofTotal = share ( span . durationMs )
88+ const ofParent =
89+ parent && parent . durationMs > 0 ? ( 100 * span . durationMs ) / parent . durationMs : null
90+ const timing = [
91+ span . name ,
92+ `${ formatDuration ( span . durationMs ) } ${ span . startMs > 0 ? ` · +${ formatDuration ( span . startMs ) } ` : '' } ` ,
93+ ofTotal >= 0.1
94+ ? `${ percentLabel ( ofTotal ) } of total${ ofParent !== null && ofParent >= 0.1 ? ` · ${ percentLabel ( ofParent ) } of parent` : '' } `
95+ : null ,
96+ ]
97+ . filter ( Boolean )
98+ . join ( ' · ' )
7099 const row : TraceRow = {
71100 id : span . id ,
72101 name : span . name ,
@@ -79,12 +108,14 @@ function traceRows(
79108 paint : spanPaint ( span ) ,
80109 } ,
81110 startLabel : `Started at ${ formatDuration ( span . startMs ) } ` ,
111+ timing,
112+ failed : depth === 0 ? span . status === 'error' : failedInTree ( span ) ,
82113 expanded : expandable ? ! collapsed . has ( span . id ) : undefined ,
83114 }
84115 return [
85116 row ,
86117 ...( span . children ?. length && ! collapsed . has ( span . id )
87- ? traceRows ( span . children , collapsed , totalMs , depth + 1 )
118+ ? traceRows ( span . children , collapsed , totalMs , shown , span , depth + 1 )
88119 : [ ] ) ,
89120 ]
90121 } )
@@ -97,6 +128,18 @@ function parentIds(spans: readonly TraceSpan[]): string[] {
97128 )
98129}
99130
131+ function countSpans ( spans : readonly TraceSpan [ ] ) : number {
132+ return spans . reduce ( ( total , span ) => total + 1 + countSpans ( span . children ?? [ ] ) , 0 )
133+ }
134+
135+ /** Where the run's last step ends on its clock, so every bar fits the run. */
136+ function traceEnd ( spans : readonly TraceSpan [ ] ) : number {
137+ return spans . reduce (
138+ ( end , span ) => Math . max ( end , span . startMs + span . durationMs , traceEnd ( span . children ?? [ ] ) ) ,
139+ 0
140+ )
141+ }
142+
100143function findSpan ( spans : readonly TraceSpan [ ] , id : string ) : TraceSpan | undefined {
101144 for ( const span of spans ) {
102145 if ( span . id === id ) return span
@@ -106,6 +149,138 @@ function findSpan(spans: readonly TraceSpan[], id: string): TraceSpan | undefine
106149 return undefined
107150}
108151
152+ /** The deepest failed step: where the error came from, not a parent it propagated to. */
153+ export function findLeafErrorSpan ( spans : readonly TraceSpan [ ] ) : TraceSpan | undefined {
154+ for ( const span of spans ) {
155+ const children = span . children ?? [ ]
156+ if ( span . status === 'error' ) return findLeafErrorSpan ( children ) ?? span
157+ const nested = findLeafErrorSpan ( children )
158+ if ( nested ) return nested
159+ }
160+ return undefined
161+ }
162+
163+ /** Steps whose name matches the filter or that hold a match, so a match keeps its ancestors. */
164+ function matchingIds ( spans : readonly TraceSpan [ ] , query : string , into = new Set < string > ( ) ) {
165+ for ( const span of spans ) {
166+ const before = into . size
167+ matchingIds ( span . children ?? [ ] , query , into )
168+ if ( into . size > before || span . name . toLowerCase ( ) . includes ( query ) ) into . add ( span . id )
169+ }
170+ return into
171+ }
172+
173+ /**
174+ * A run's trace as one tree on the run's clock: the open rows with their timing, the selected
175+ * step, and expand and collapse. The deepest failure is selected to begin with, and a selection the
176+ * refreshed trace no longer holds falls back to it. `query` keeps the steps whose names match it,
177+ * with their ancestors.
178+ */
179+ export function useRunTrace ( trace : readonly TraceSpan [ ] , query = '' ) {
180+ const firstError = findLeafErrorSpan ( trace )
181+ const initialId = firstError ?. id ?? trace [ 0 ] ?. id ?? ''
182+ const [ selectedId , setSelectedId ] = useState ( initialId )
183+ const [ collapsed , setCollapsed ] = useState < ReadonlySet < string > > ( ( ) => new Set ( ) )
184+ const totalMs = Math . max ( trace [ 0 ] ?. durationMs ?? 0 , traceEnd ( trace ) )
185+ const filter = query . trim ( ) . toLowerCase ( )
186+ return {
187+ rows : traceRows ( trace , collapsed , totalMs , filter ? matchingIds ( trace , filter ) : null ) ,
188+ selected : findSpan ( trace , selectedId ) ?? findSpan ( trace , initialId ) ,
189+ select : setSelectedId ,
190+ firstErrorId : firstError ?. id ,
191+ spanCount : countSpans ( trace ) ,
192+ totalMs,
193+ toggle : ( id : string , open : boolean ) =>
194+ setCollapsed ( ( current ) => {
195+ const next = new Set ( current )
196+ if ( open ) next . delete ( id )
197+ else next . add ( id )
198+ return next
199+ } ) ,
200+ expandAll : ( ) => setCollapsed ( new Set ( ) ) ,
201+ collapseAll : ( ) => setCollapsed ( new Set ( parentIds ( trace ) ) ) ,
202+ }
203+ }
204+
205+ /** The selected step in the shared inspector, with when it ran on the viewer's clock and its extra data. */
206+ export function RunStep ( { span } : { span : TraceSpan } ) {
207+ const timeZone = useViewerTimeZone ( )
208+ const clock = [
209+ ...( span . startedAt
210+ ? [ { id : 'started' , label : 'Started' , value : formatClockTime ( span . startedAt , timeZone ) } ]
211+ : [ ] ) ,
212+ ...( span . endedAt
213+ ? [ { id : 'ended' , label : 'Ended' , value : formatClockTime ( span . endedAt , timeZone ) } ]
214+ : [ ] ) ,
215+ ]
216+ return (
217+ < FindableSpanInspector
218+ span = { { ...span , context : [ ...span . context , ...clock ] } }
219+ extraPayloads = { span . extras }
220+ />
221+ )
222+ }
223+
224+ /** The files a run produced, each with its size, type and version, and a download. */
225+ export function RunFiles ( { files } : { files : readonly RunFile [ ] } ) {
226+ return (
227+ < ul className = { styles . files } >
228+ { files . map ( ( file ) => (
229+ < li key = { file . id } className = { styles . file } >
230+ < span className = { styles . fileText } >
231+ < span className = { styles . fileName } data-type = 'T060' >
232+ < OverflowText text = { file . name } > { file . name } </ OverflowText >
233+ </ span >
234+ < span className = { styles . fileDetail } data-type = 'T052' >
235+ { file . detail }
236+ </ span >
237+ </ span >
238+ < Button
239+ variant = 'quiet'
240+ size = 'compact'
241+ icon = 'I049'
242+ aria-label = { `Download ${ file . name } ` }
243+ render = {
244+ < a href = { file . href } target = '_blank' rel = 'noopener noreferrer' >
245+ Download
246+ </ a >
247+ }
248+ >
249+ Download
250+ </ Button >
251+ </ li >
252+ ) ) }
253+ </ ul >
254+ )
255+ }
256+
257+ /** What a run cost, line by line from its usage ledger, with its total, tokens and the base charge. */
258+ export function RunCostList ( { cost } : { cost : RunCost } ) {
259+ return (
260+ < >
261+ < PropertyList
262+ title = 'Cost'
263+ titleHidden
264+ properties = { [
265+ ...cost . items . map ( ( item ) => ( {
266+ id : `item-${ item . id } ` ,
267+ label : item . label ,
268+ kind : 'text' as const ,
269+ value : item . value ,
270+ } ) ) ,
271+ { id : 'total' , label : 'Total' , kind : 'text' as const , value : cost . total } ,
272+ ...( cost . tokens
273+ ? [ { id : 'tokens' , label : 'Tokens' , kind : 'text' as const , value : cost . tokens } ]
274+ : [ ] ) ,
275+ ] }
276+ />
277+ < p className = { styles . note } data-type = 'T052' >
278+ { cost . note }
279+ </ p >
280+ </ >
281+ )
282+ }
283+
109284interface RunDetailsProps {
110285 /** How the page's read stands; ready when omitted. */
111286 read ?: WorkspaceRead
@@ -121,22 +296,10 @@ interface RunDetailsProps {
121296 */
122297export function RunDetails ( { run, back, read, actions } : RunDetailsProps ) {
123298 const timeZone = useViewerTimeZone ( )
124- const root = run . trace [ 0 ]
125- const [ selectedId , setSelectedId ] = useState ( root ?. id ?? '' )
126- const [ collapsed , setCollapsed ] = useState < ReadonlySet < string > > ( ( ) => new Set ( ) )
127- const selected = findSpan ( run . trace , selectedId ) ?? root
299+ const trace = useRunTrace ( run . trace )
128300 const shortId = run . id . slice ( 0 , 8 )
129301 const usage = formatComputeUnits ( run . cu )
130302
131- function toggle ( id : string , open : boolean ) {
132- setCollapsed ( ( current ) => {
133- const next = new Set ( current )
134- if ( open ) next . delete ( id )
135- else next . add ( id )
136- return next
137- } )
138- }
139-
140303 return (
141304 < DetailSplit
142305 read = { read }
@@ -145,7 +308,7 @@ export function RunDetails({ run, back, read, actions }: RunDetailsProps) {
145308 label = { `Run ${ shortId } trace` }
146309 railKind = 'inspector'
147310 railLabel = 'Selected step'
148- rail = { selected && < FindableSpanInspector key = { selected . id } span = { selected } /> }
311+ rail = { trace . selected && < RunStep key = { trace . selected . id } span = { trace . selected } /> }
149312 >
150313 < DirectoryHeading title = { `Run ${ shortId } ` } />
151314 < MetadataFields
@@ -165,74 +328,30 @@ export function RunDetails({ run, back, read, actions }: RunDetailsProps) {
165328 { id : 'execution' , label : 'Execution ID' , value : run . id , copyable : true } ,
166329 ] }
167330 />
168- { root && (
331+ { run . trace . length > 0 && (
169332 < DetailSection title = 'Trace' >
170333 < TraceToolbar
171334 copyText = { JSON . stringify ( run . trace , null , 2 ) }
172- onExpandAll = { ( ) => setCollapsed ( new Set ( ) ) }
173- onCollapseAll = { ( ) => setCollapsed ( new Set ( parentIds ( run . trace ) ) ) }
335+ onExpandAll = { trace . expandAll }
336+ onCollapseAll = { trace . collapseAll }
174337 />
175338 < TraceTree
176339 label = { `Run ${ shortId } steps` }
177- rows = { traceRows ( run . trace , collapsed , root . durationMs ) }
178- selectedId = { selected ?. id ?? null }
179- onSelect = { setSelectedId }
180- onToggle = { toggle }
340+ rows = { trace . rows }
341+ selectedId = { trace . selected ?. id ?? null }
342+ onSelect = { trace . select }
343+ onToggle = { trace . toggle }
181344 />
182345 </ DetailSection >
183346 ) }
184347 { run . files && (
185348 < DetailSection title = { `Files (${ run . files . length } )` } >
186- < ul className = { styles . files } >
187- { run . files . map ( ( file ) => (
188- < li key = { file . id } className = { styles . file } >
189- < span className = { styles . fileText } >
190- < span className = { styles . fileName } data-type = 'T060' >
191- < OverflowText text = { file . name } > { file . name } </ OverflowText >
192- </ span >
193- < span className = { styles . fileDetail } data-type = 'T052' >
194- { file . detail }
195- </ span >
196- </ span >
197- < Button
198- variant = 'quiet'
199- size = 'compact'
200- icon = 'I049'
201- aria-label = { `Download ${ file . name } ` }
202- render = {
203- < a href = { file . href } target = '_blank' rel = 'noopener noreferrer' >
204- Download
205- </ a >
206- }
207- >
208- Download
209- </ Button >
210- </ li >
211- ) ) }
212- </ ul >
349+ < RunFiles files = { run . files } />
213350 </ DetailSection >
214351 ) }
215352 { run . cost && (
216353 < DetailSection title = 'Cost' >
217- < PropertyList
218- title = 'Cost'
219- titleHidden
220- properties = { [
221- ...run . cost . items . map ( ( item ) => ( {
222- id : `item-${ item . id } ` ,
223- label : item . label ,
224- kind : 'text' as const ,
225- value : item . value ,
226- } ) ) ,
227- { id : 'total' , label : 'Total' , kind : 'text' as const , value : run . cost . total } ,
228- ...( run . cost . tokens
229- ? [ { id : 'tokens' , label : 'Tokens' , kind : 'text' as const , value : run . cost . tokens } ]
230- : [ ] ) ,
231- ] }
232- />
233- < p className = { styles . note } data-type = 'T052' >
234- { run . cost . note }
235- </ p >
354+ < RunCostList cost = { run . cost } />
236355 </ DetailSection >
237356 ) }
238357 </ DetailSplit >
0 commit comments