@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.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- 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
|
+
}
|