Skip to content

Commit 52c50a1

Browse files
committed
refactor(tables): show a table cell's run through the Logs adapter and run pieces
The table side panel kept its own run viewer beside Logs: a second trace model (trace.ts), tree and inspector wiring (trace-view.tsx), and copies of the file, cost, credit and trigger-label code. It now reads the run with useLiveRun, maps it through the Logs adapter, and renders the shared RunFiles, RunCostList, useRunTrace and RunStep that the Logs run page uses too. The adapter takes over what only the table trace knew: legacy tool calls as tool steps, an agent's model segments hidden when it called no tool, MCP and tool display names, provider marks, a model step's thinking and tool calls as extra payloads, and the legacy span facts (child run, tries, provider, model, failed models, finish reason, TTFT, throughput, cache and reasoning tokens, iteration, started and ended). The shared trace selects the deepest failure first, keeps a selection the refreshed trace still holds, and gives each row its timing tooltip. Integration trigger labels come from one shared helper. The panel keeps what Logs does not show: the legacy level, the workflow link, the failure with Troubleshoot in Chat, the workflow's input and output on the overview, Open in Logs, Escape to close, and the trace tab's summary, Jump to error and span filter.
1 parent cf36d3e commit 52c50a1

13 files changed

Lines changed: 851 additions & 984 deletions

File tree

‎apps/sim/app/(product)/_components/workspace/logs/run-details/run-details.tsx‎

Lines changed: 194 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type ReactNode, useState } from 'react'
44
import { Button } from '@sim/emcn/button'
55
import type { IconName } from '@sim/emcn/catalog/icon-geometry'
66
import { Icon } from '@sim/emcn/icon'
7+
import { FALLBACK_PROVIDER_MARK, providerMark } from '@sim/emcn/icon/provider-marks'
78
import { OverflowText } from '@sim/emcn/overflow-text'
89
import { PropertyList } from '@sim/emcn/property-list'
910
import { DirectoryHeading } from '@/app/(product)/_components/resource-directory/resource-directory'
@@ -27,8 +28,8 @@ import {
2728
} from '@/app/(product)/_components/workspace/workspace-session/format'
2829
import type { WorkspaceRead } from '@/app/(product)/_components/workspace/workspace-session/model'
2930
import { 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

3334
const KIND_ICON: Record<TraceSpan['kind'], IconName> = {
3435
agent: 'I128',
@@ -44,29 +45,57 @@ const KIND_ICON: Record<TraceSpan['kind'], IconName> = {
4445
}
4546

4647
const 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. */
5052
function 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. */
5973
function 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+
100143
function 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+
109284
interface RunDetailsProps {
110285
/** How the page's read stands; ready when omitted. */
111286
read?: WorkspaceRead
@@ -121,22 +296,10 @@ interface RunDetailsProps {
121296
*/
122297
export 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

Comments
 (0)