@aryasaatvik/motel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,389 @@
1
+ import { memo, useLayoutEffect, useState } from "react"
2
+ import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
3
+ import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
4
+ import { BlankRow, TextLine } from "./primitives.tsx"
5
+ import { colors, waterfallColors } from "./theme.ts"
6
+ export { getVisibleSpans } from "./waterfallModel.ts"
7
+ import {
8
+ buildTreePrefix,
9
+ findFirstChildIndex,
10
+ getWaterfallLayout,
11
+ getWaterfallSuffixMetrics,
12
+ type WaterfallSuffixMetrics,
13
+ spanPreviewEntries,
14
+ } from "./waterfallModel.ts"
15
+
16
+ const PARTIAL_BLOCKS = ["", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"] as const
17
+ const ULTRA_SHORT_MARKERS = ["\u258f", "\u258e", "\u258d", "\u258c"] as const
18
+
19
+ type WaterfallBarSegment = {
20
+ readonly text: string
21
+ readonly fg: string
22
+ readonly bg?: string
23
+ }
24
+
25
+ const renderWaterfallBar = (
26
+ span: TraceSpanItem,
27
+ trace: TraceItem,
28
+ barWidth: number,
29
+ barColor: string,
30
+ laneColor: string,
31
+ rowBg: string,
32
+ ): { readonly segments: readonly WaterfallBarSegment[] } => {
33
+ // Timeline semantics: the leading gap (before the bar starts) is the
34
+ // "runway" showing how long after trace start this span kicked in — render
35
+ // it in the lane color. The trailing gap (after the bar ends) is post-span
36
+ // dead time — render it in the row bg so it visually disappears.
37
+ if (barWidth < 3 || trace.durationMs === 0) {
38
+ const trailing = Math.max(0, barWidth - 1)
39
+ const segs: WaterfallBarSegment[] = [{ text: "\u2588", fg: barColor }]
40
+ if (trailing > 0) segs.push({ text: " ".repeat(trailing), fg: rowBg, bg: rowBg })
41
+ return { segments: segs }
42
+ }
43
+
44
+ const traceStart = trace.startedAt.getTime()
45
+ const spanStart = span.startTime.getTime()
46
+ const relativeStart = Math.max(0, spanStart - traceStart)
47
+ const startFrac = relativeStart / trace.durationMs
48
+ const endFrac = Math.min(1, Math.max(startFrac, (relativeStart + Math.max(0, span.durationMs)) / trace.durationMs))
49
+ const totalUnits = barWidth * 8
50
+ const startUnits = Math.max(0, Math.min(totalUnits - 1, Math.floor(startFrac * totalUnits)))
51
+ const endUnits = Math.max(startUnits + 1, Math.min(totalUnits, Math.ceil(endFrac * totalUnits)))
52
+ const startCell = Math.floor(startUnits / 8)
53
+ const endCell = Math.floor((endUnits - 1) / 8)
54
+ const startOffset = startUnits % 8
55
+ const endOffset = endUnits % 8
56
+ const segments: WaterfallBarSegment[] = []
57
+
58
+ const pushLeading = (cells: number) => {
59
+ if (cells > 0) segments.push({ text: " ".repeat(cells), fg: laneColor, bg: laneColor })
60
+ }
61
+ const pushTrailing = (cells: number) => {
62
+ if (cells > 0) segments.push({ text: " ".repeat(cells), fg: rowBg, bg: rowBg })
63
+ }
64
+
65
+ pushLeading(startCell)
66
+
67
+ if (startCell === endCell) {
68
+ const singleCellUnits = Math.max(1, endUnits - startUnits)
69
+ if (singleCellUnits <= 4) {
70
+ const centeredMarker = ULTRA_SHORT_MARKERS[Math.max(0, singleCellUnits - 1)] ?? "\u258f"
71
+ // The marker is a left-aligned sliver — the rest of the cell is
72
+ // post-bar space, so it uses the row bg (transparent) rather than
73
+ // carrying the dark lane track past where the span ended.
74
+ segments.push({ text: centeredMarker, fg: barColor, bg: rowBg })
75
+ pushTrailing(Math.max(0, barWidth - startCell - 1))
76
+ return { segments }
77
+ }
78
+
79
+ if (startOffset === 0) {
80
+ // Bar fills from the left of the cell; post-bar pixels fall to row bg.
81
+ segments.push({ text: PARTIAL_BLOCKS[singleCellUnits], fg: barColor, bg: rowBg })
82
+ } else {
83
+ // Bar starts partway into the cell; left pixels are lane, right is bar.
84
+ segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
85
+ }
86
+ pushTrailing(Math.max(0, barWidth - startCell - 1))
87
+ return { segments }
88
+ }
89
+
90
+ if (startOffset > 0) {
91
+ // Leading partial: left portion is lane (runway), right is bar.
92
+ segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
93
+ }
94
+
95
+ const fullStartCell = startCell + (startOffset > 0 ? 1 : 0)
96
+ const fullEndCell = endCell - (endOffset > 0 ? 1 : 0)
97
+ const fullCells = Math.max(0, fullEndCell - fullStartCell + 1)
98
+ if (fullCells > 0) {
99
+ segments.push({ text: "\u2588".repeat(fullCells), fg: barColor })
100
+ }
101
+
102
+ if (endOffset > 0) {
103
+ // Trailing partial: left portion is bar, right is row bg (transparent).
104
+ segments.push({ text: PARTIAL_BLOCKS[endOffset], fg: barColor, bg: rowBg })
105
+ }
106
+
107
+ pushTrailing(Math.max(0, barWidth - endCell - 1))
108
+ return { segments }
109
+ }
110
+
111
+ const durationColor = (durationMs: number) => {
112
+ if (durationMs >= 10_000) return colors.warning
113
+ if (durationMs >= 1_000) return colors.accent
114
+ if (durationMs >= 100) return colors.count
115
+ if (durationMs > 0) return colors.muted
116
+ return colors.muted
117
+ }
118
+
119
+
120
+ const WaterfallRow = memo(({
121
+ span,
122
+ trace,
123
+ index,
124
+ spans,
125
+ contentWidth,
126
+ selected,
127
+ collapsed,
128
+ hasChildSpans,
129
+ suffixMetrics,
130
+ dimmed,
131
+ onSelect,
132
+ }: {
133
+ span: TraceSpanItem
134
+ trace: TraceItem
135
+ index: number
136
+ spans: readonly TraceSpanItem[]
137
+ contentWidth: number
138
+ selected: boolean
139
+ collapsed: boolean
140
+ hasChildSpans: boolean
141
+ suffixMetrics: WaterfallSuffixMetrics
142
+ dimmed: boolean
143
+ onSelect: () => void
144
+ }) => {
145
+ const prefix = buildTreePrefix(spans, index)
146
+ const isAi = isAiSpan(span.tags)
147
+ // Indicator column: `!` on error, chevron on collapsible parents,
148
+ // `✦` on AI leaves (LLM payloads detected — enter drills into a
149
+ // specialized chat view), `·` on other leaves. AI parents keep the
150
+ // chevron glyph so tree structure stays readable; the accent color
151
+ // (applied below) carries the "AI content lives here" signal.
152
+ const indicator = span.status === "error" ? "!"
153
+ : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be")
154
+ : isAi ? "\u2726"
155
+ : "\u00b7"
156
+ const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
157
+
158
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
159
+
160
+ // Op name budget = labelMaxWidth minus (prefix + indicator + 1 space).
161
+ // Never force a minimum: at very deep nesting or narrow widths the
162
+ // prefix + indicator may already fill the label column, in which
163
+ // case we render the op as an empty string (or a lone ellipsis) so
164
+ // the line stays within contentWidth. Previous code forced op to 4
165
+ // chars which could push total row width past the pane and make
166
+ // OpenTUI smear "..." across the right edge.
167
+ const opMaxWidth = Math.max(0, labelMaxWidth - prefix.length - 2)
168
+ const opTruncated = opMaxWidth === 0
169
+ ? ""
170
+ : opName.length > opMaxWidth
171
+ ? `${opName.slice(0, Math.max(0, opMaxWidth - 1))}\u2026`
172
+ : opName
173
+ const labelLen = prefix.length + 2 + opTruncated.length
174
+ const labelPad = " ".repeat(Math.max(0, labelMaxWidth - labelLen))
175
+
176
+ const isError = span.status === "error"
177
+ const barColor = selected ? (isError ? waterfallColors.barSelectedError : waterfallColors.barSelected) : isError ? waterfallColors.barError : waterfallColors.bar
178
+ const laneColor = selected ? waterfallColors.barLane : waterfallColors.barBg
179
+ const rowBg = selected ? colors.selectedBg : colors.screenBg
180
+ const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
181
+ const bg = selected ? colors.selectedBg : undefined
182
+ // Dimmed rows (non-matching under an active waterfall filter) collapse
183
+ // their palette to the muted separator color so matches stand out.
184
+ // Selection always wins — the selected row keeps its full brightness
185
+ // so you can still see where the cursor is while scanning.
186
+ const treeColor = selected ? colors.separator : dimmed ? colors.separator : colors.treeLine
187
+ const indicatorColor = selected ? colors.selectedText
188
+ : dimmed ? colors.separator
189
+ : isError ? colors.error
190
+ // AI accent outranks parent/leaf color so both AI parents and AI
191
+ // leaves scan as "there's an LLM payload here" from across the
192
+ // waterfall. Error still wins because a failed AI span is first
193
+ // and foremost a failure.
194
+ : isAi ? colors.accent
195
+ : hasChildSpans ? colors.muted
196
+ : colors.passing
197
+ const opColor = selected ? colors.selectedText
198
+ : dimmed ? colors.separator
199
+ : span.isRunning ? colors.warning
200
+ : colors.text
201
+
202
+ const durationFg = selected ? colors.selectedText : dimmed ? colors.separator : durationColor(span.durationMs)
203
+ const unitFg = dimmed && !selected ? colors.separator : colors.muted
204
+
205
+ // Split the duration so the unit (s/ms) renders dimmer than the number.
206
+ const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
207
+ const durationCell = `${durNumber}${durUnit}`
208
+ const durationPad = " ".repeat(Math.max(0, suffixMetrics.maxDurationWidth - durationCell.length))
209
+
210
+ return (
211
+ <box height={1} onMouseDown={onSelect}>
212
+ <TextLine bg={bg}>
213
+ {prefix ? <span fg={treeColor}>{prefix}</span> : null}
214
+ <span fg={indicatorColor}>{indicator}</span>
215
+ <span fg={opColor}>{` ${opTruncated}`}</span>
216
+ <span>{labelPad}</span>
217
+ <span> </span>
218
+ {segments.map((segment, index) => (
219
+ <span key={`${span.spanId}-bar-${index}`} fg={segment.fg} bg={segment.bg}>{segment.text}</span>
220
+ ))}
221
+ <span> </span>
222
+ <span>{durationPad}</span>
223
+ <span fg={durationFg}>{durNumber}</span>
224
+ <span fg={unitFg}>{durUnit}</span>
225
+ </TextLine>
226
+ </box>
227
+ )
228
+ })
229
+ WaterfallRow.displayName = "WaterfallRow"
230
+
231
+ export const SpanPreview = ({
232
+ span,
233
+ logs,
234
+ contentWidth,
235
+ maxLines,
236
+ }: {
237
+ span: TraceSpanItem
238
+ logs: readonly LogItem[]
239
+ contentWidth: number
240
+ maxLines: number
241
+ }) => {
242
+ const entries = spanPreviewEntries(span, logs, maxLines)
243
+ if (entries.length === 0) return null
244
+
245
+ const maxKeyLen = Math.min(22, entries.reduce((max, e) => Math.max(max, e.key.length), 0))
246
+ const valMaxWidth = Math.max(8, contentWidth - maxKeyLen - 3)
247
+ const indent = " ".repeat(maxKeyLen + 2)
248
+
249
+ const lines: Array<{ keyPart: string; valPart: string; isWarning?: boolean }> = []
250
+ for (const entry of entries) {
251
+ const keyStr = entry.key.length > maxKeyLen ? `${entry.key.slice(0, maxKeyLen - 1)}\u2026` : entry.key.padEnd(maxKeyLen)
252
+ const val = entry.value
253
+ if (val.length <= valMaxWidth) {
254
+ lines.push({ keyPart: keyStr, valPart: val, isWarning: entry.isWarning })
255
+ } else {
256
+ let remaining = val
257
+ let first = true
258
+ while (remaining.length > 0) {
259
+ const chunk = remaining.slice(0, valMaxWidth)
260
+ remaining = remaining.slice(valMaxWidth)
261
+ lines.push({ keyPart: first ? keyStr : indent, valPart: chunk, isWarning: entry.isWarning })
262
+ first = false
263
+ }
264
+ }
265
+ }
266
+
267
+ return (
268
+ <box flexDirection="column">
269
+ {lines.slice(0, maxLines).map((line, i) => (
270
+ <TextLine key={`preview-${i}`}>
271
+ <span fg={line.isWarning ? colors.error : colors.previewKey}>{line.keyPart}</span>
272
+ <span fg={colors.separator}> </span>
273
+ <span fg={line.isWarning ? colors.error : colors.muted}>{line.valPart}</span>
274
+ </TextLine>
275
+ ))}
276
+ </box>
277
+ )
278
+ }
279
+
280
+ export const WaterfallTimeline = ({
281
+ trace,
282
+ filteredSpans,
283
+ contentWidth,
284
+ bodyLines,
285
+ selectedSpanIndex,
286
+ collapsedSpanIds,
287
+ matchingSpanIds,
288
+ onSelectSpan,
289
+ }: {
290
+ trace: TraceItem
291
+ filteredSpans: readonly TraceSpanItem[]
292
+ contentWidth: number
293
+ bodyLines: number
294
+ selectedSpanIndex: number | null
295
+ collapsedSpanIds: ReadonlySet<string>
296
+ /**
297
+ * When set, spans whose spanId is NOT in this set are dimmed. Null
298
+ * means no filter active — skip the per-row lookup entirely.
299
+ */
300
+ matchingSpanIds?: ReadonlySet<string> | null
301
+ onSelectSpan: (index: number) => void
302
+ }) => {
303
+ const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
304
+
305
+ const spanIndexById = new Map<string, number>()
306
+ for (let i = 0; i < trace.spans.length; i++) {
307
+ spanIndexById.set(trace.spans[i].spanId, i)
308
+ }
309
+
310
+ // Virtual windowing: only render visible rows. We track scroll offset
311
+ // as state so the mouse wheel can scroll the window INDEPENDENTLY of
312
+ // the selected span (mirrors TraceList behavior). Selection still
313
+ // follows: if the user moves selection off-screen via j/k, we nudge
314
+ // the window to keep it visible — but wheel-scrolling never changes
315
+ // selection, only clicking a row does.
316
+ const viewportSize = Math.max(1, bodyLines)
317
+ const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
318
+ const [scrollOffset, setScrollOffset] = useState(0)
319
+
320
+ // Reset scroll offset when the trace changes. Keep this out of render so
321
+ // a trace switch doesn't force a render-phase state update on hot paths.
322
+ useLayoutEffect(() => {
323
+ setScrollOffset(0)
324
+ }, [trace.traceId])
325
+
326
+ // Auto-follow selection: only if the selected span would be hidden
327
+ // by the current window, shift just enough to bring it back. Runs in
328
+ // layout effect so the visible window is accurate on the same paint
329
+ // that the selection changed.
330
+ useLayoutEffect(() => {
331
+ if (selectedSpanIndex === null) return
332
+ setScrollOffset((current) => {
333
+ if (selectedSpanIndex < current) return selectedSpanIndex
334
+ if (selectedSpanIndex >= current + viewportSize) return selectedSpanIndex - viewportSize + 1
335
+ return current
336
+ })
337
+ }, [selectedSpanIndex, viewportSize])
338
+
339
+ const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
340
+ const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
341
+ const blankCount = Math.max(0, viewportSize - windowSpans.length)
342
+
343
+ // One shared suffix width, measured from the current viewport, so every
344
+ // row's duration cell lines up on the same right-edge column.
345
+ const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
346
+
347
+ // Mouse wheel scrolls the window without touching selection — matches
348
+ // the trace list, so the user can browse ahead of their cursor freely
349
+ // and click a row to commit. Delta is scaled 1:1 with opentui's wheel
350
+ // reporting (1 notch ≈ 3 rows on most terminals).
351
+ const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
352
+ const info = event.scroll
353
+ if (!info || filteredSpans.length === 0) return
354
+ const magnitude = Math.max(1, Math.round(info.delta))
355
+ const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
356
+ if (signed === 0) return
357
+ setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
358
+ event.stopPropagation?.()
359
+ }
360
+
361
+ return (
362
+ <box flexDirection="column" onMouseScroll={handleWheel}>
363
+ {windowSpans.map((span, index) => {
364
+ const actualIndex = windowStart + index
365
+ const fullIndex = spanIndexById.get(span.spanId) ?? -1
366
+ const dimmed = matchingSpanIds != null && !matchingSpanIds.has(span.spanId)
367
+ return (
368
+ <WaterfallRow
369
+ key={`${trace.traceId}-${span.spanId}`}
370
+ span={span}
371
+ trace={trace}
372
+ index={fullIndex}
373
+ spans={trace.spans}
374
+ contentWidth={contentWidth}
375
+ selected={selectedSpanIndex === actualIndex}
376
+ collapsed={collapsedSpanIds.has(span.spanId)}
377
+ hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
378
+ suffixMetrics={suffixMetrics}
379
+ dimmed={dimmed}
380
+ onSelect={() => onSelectSpan(actualIndex)}
381
+ />
382
+ )
383
+ })}
384
+ {Array.from({ length: blankCount }, (_, i) => (
385
+ <BlankRow key={`blank-${i}`} />
386
+ ))}
387
+ </box>
388
+ )
389
+ }