@kitlangton/motel 0.1.1 → 0.1.2

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.
@@ -67,8 +67,7 @@ export const TraceDetailsPane = ({
67
67
  const hasTraceSelection = traceSummary !== null
68
68
  const isLoadingTrace = hasTraceSelection && trace === null && traceStatus !== "error"
69
69
 
70
- const focusIndicator = focused ? "\u25b8 " : ""
71
- const headerTitle = `${focusIndicator}TRACE DETAILS`
70
+ const headerTitle = "TRACE DETAILS"
72
71
  const headerRight = traceMeta
73
72
  ? `${traceMeta.errorCount > 0 ? `${traceMeta.errorCount} errors` : traceMeta.isRunning ? "running" : isLoadingTrace ? "loading" : "healthy"} \u00b7 ${formatDuration(traceMeta.durationMs)}${traceLogCount > 0 ? ` \u00b7 ${traceLogCount} logs` : ""}`
74
73
  : traceStatus === "error"
@@ -86,7 +85,6 @@ export const TraceDetailsPane = ({
86
85
  const opLeft = traceMeta?.rootOperationName ?? ""
87
86
  const opGap = Math.max(2, contentWidth - opLeft.length - dateStr.length)
88
87
  const warningCount = traceMeta?.warnings.length ?? 0
89
- const firstWarning = traceMeta?.warnings[0] ?? ""
90
88
 
91
89
  return (
92
90
  <box flexDirection="column" width={paneWidth} height={bodyLines + TRACE_DETAILS_HEADER_ROWS} overflow="hidden">
@@ -101,23 +99,17 @@ export const TraceDetailsPane = ({
101
99
  <span>{" ".repeat(opGap)}</span>
102
100
  <span fg={colors.muted}>{dateStr}</span>
103
101
  </TextLine>
104
- {warningCount > 0 ? (
105
- <TextLine>
106
- <span fg={colors.defaultService}>{trace.serviceName}</span>
107
- <span fg={colors.separator}>{SEPARATOR}</span>
108
- <span fg={colors.count}>{trace.spanCount} spans</span>
109
- <span fg={colors.separator}>{SEPARATOR}</span>
110
- <span fg={colors.error}>{warningCount} warning{warningCount === 1 ? "" : "s"}: {firstWarning}</span>
111
- </TextLine>
112
- ) : (
113
- <TextLine>
114
- <span fg={colors.defaultService}>{trace.serviceName}</span>
115
- <span fg={colors.separator}>{SEPARATOR}</span>
116
- <span fg={colors.count}>{trace.spanCount} spans</span>
117
- <span fg={colors.separator}>{SEPARATOR}</span>
118
- <span fg={colors.muted}>{trace.traceId.slice(0, 16)}</span>
119
- </TextLine>
120
- )}
102
+ <TextLine>
103
+ <span fg={colors.count}>{trace.spanCount} spans</span>
104
+ {warningCount > 0 ? (
105
+ <>
106
+ <span fg={colors.separator}>{SEPARATOR}</span>
107
+ <span fg={colors.error}>{warningCount} warning{warningCount === 1 ? "" : "s"}</span>
108
+ </>
109
+ ) : null}
110
+ <span fg={colors.separator}>{SEPARATOR}</span>
111
+ <span fg={colors.muted}>{trace.traceId}</span>
112
+ </TextLine>
121
113
  </box>
122
114
  <Divider width={paneWidth} />
123
115
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
@@ -143,11 +135,11 @@ export const TraceDetailsPane = ({
143
135
  <span fg={colors.muted}>{dateStr}</span>
144
136
  </TextLine>
145
137
  <TextLine>
146
- <span fg={colors.defaultService}>{traceMeta.serviceName}</span>
147
- <span fg={colors.separator}>{SEPARATOR}</span>
148
138
  <span fg={colors.count}>{traceMeta.spanCount} spans</span>
149
139
  <span fg={colors.separator}>{SEPARATOR}</span>
150
140
  <span fg={colors.count}>warming adjacent trace...</span>
141
+ <span fg={colors.separator}>{SEPARATOR}</span>
142
+ <span fg={colors.muted}>{traceMeta.traceId}</span>
151
143
  </TextLine>
152
144
  </box>
153
145
  <Divider width={paneWidth} />
@@ -1,9 +1,8 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
- import { memo } from "react"
3
- import { config } from "../config.ts"
2
+ import { useLayoutEffect, useRef, useState } from "react"
4
3
  import type { TraceSummaryItem } from "../domain.ts"
5
4
  import { fitCell, formatDuration, lifecycleLabel, relativeTime, traceIndicator, traceIndicatorColor, traceRowId } from "./format.ts"
6
- import { PlainLine, TextLine } from "./primitives.tsx"
5
+ import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
7
6
  import type { LoadStatus } from "./state.ts"
8
7
  import { colors } from "./theme.ts"
9
8
 
@@ -12,9 +11,11 @@ const getTraceRowLayout = (contentWidth: number) => {
12
11
  const durationWidth = 8
13
12
  const countWidth = 6
14
13
  const ageWidth = 4
15
- // Keep the operation column intentionally capped so the metrics cluster stays
16
- // visually closer, but still allow common operation names to fit cleanly.
17
- const titleWidth = Math.min(20, Math.max(8, contentWidth - stateWidth - durationWidth - countWidth - ageWidth - 2))
14
+ // Row layout: state + gap + title + duration + gap + count + gap + age.
15
+ // Let the title expand to fill whatever width is left so the metrics
16
+ // cluster lands against the right edge of the pane.
17
+ const fixed = stateWidth + durationWidth + countWidth + ageWidth + 3
18
+ const titleWidth = Math.max(8, contentWidth - fixed)
18
19
  return { stateWidth, durationWidth, countWidth, ageWidth, titleWidth }
19
20
  }
20
21
 
@@ -40,13 +41,17 @@ const TraceRow = ({
40
41
  : trace.rootOperationName
41
42
  const titleColor = selected ? colors.selectedText : trace.isRunning ? colors.warning : colors.text
42
43
 
44
+ // Always surface a duration, including `0ms` for sub-millisecond traces —
45
+ // a visible duration is easier to scan than a blank column.
46
+ const durationText = formatDuration(Math.max(0, trace.durationMs))
47
+
43
48
  return (
44
49
  <box id={traceRowId(trace.traceId)} height={1} onMouseDown={onSelect}>
45
50
  <TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
46
51
  <span fg={traceIndicatorColor(trace)}>{fitCell(traceIndicator(trace), stateWidth)}</span>
47
52
  <span> </span>
48
53
  <span fg={titleColor}>{fitTraceTitle(title, titleWidth)}</span>
49
- <span fg={selected ? colors.accent : colors.count}>{fitCell(trace.durationMs >= 1 ? formatDuration(trace.durationMs) : "", durationWidth, "right")}</span>
54
+ <span fg={colors.muted}>{fitCell(durationText, durationWidth, "right")}</span>
50
55
  <span> </span>
51
56
  <span fg={colors.muted}>{fitCell(`${trace.spanCount}sp`, countWidth, "right")}</span>
52
57
  <span> </span>
@@ -71,8 +76,53 @@ export interface TraceListProps {
71
76
  readonly onSelectTrace: (traceId: string) => void
72
77
  }
73
78
 
74
- export const TraceList = ({
75
- showHeader,
79
+ interface TraceListBodyProps extends TraceListProps {
80
+ readonly viewportRows: number
81
+ }
82
+
83
+ /**
84
+ * Header strip that sits above the body (renders the `TRACES 100 · filter: x`
85
+ * line). Kept as a separate component so the body can live inside a
86
+ * virtual-windowed box without the header scrolling with it.
87
+ */
88
+ export const TraceListHeader = ({
89
+ traces,
90
+ services,
91
+ selectedService,
92
+ filterText,
93
+ sortMode,
94
+ totalCount,
95
+ contentWidth,
96
+ }: TraceListProps) => {
97
+ const countLabel = totalCount !== undefined && totalCount !== traces.length ? `${traces.length}/${totalCount}` : traces.length > 0 ? String(traces.length) : ""
98
+ const metaLabel = [
99
+ filterText ? `filter: ${filterText}` : null,
100
+ sortMode && sortMode !== "recent" ? `sort: ${sortMode}` : null,
101
+ ].filter((part): part is string => part !== null).join(" · ")
102
+ const serviceLabel = services.length > 1 && selectedService ? `${services.length} services` : ""
103
+ const leftLabel = `TRACES${countLabel ? ` ${countLabel}` : ""}${metaLabel ? ` · ${metaLabel}` : ""}`
104
+ const gap = Math.max(2, contentWidth - leftLabel.length - serviceLabel.length)
105
+ return (
106
+ <TextLine>
107
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>TRACES</span>
108
+ {countLabel ? <span fg={colors.muted}>{` ${countLabel}`}</span> : null}
109
+ {metaLabel ? <span fg={colors.muted}>{` · ${metaLabel}`}</span> : null}
110
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
111
+ <span fg={colors.muted}>{serviceLabel}</span>
112
+ </TextLine>
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Virtual-windowed body for the trace list. Replaces the previous
118
+ * opentui <scrollbox> which had a race with opentui's render-time Yoga
119
+ * layout: useLayoutEffect fires BEFORE the scrollbar's scrollSize has
120
+ * been updated to reflect new content height, so setting scrollTop after
121
+ * a refresh got clamped against the stale max. We own the scroll offset
122
+ * directly as React state and render only the visible rows, eliminating
123
+ * the race entirely.
124
+ */
125
+ export const TraceListBody = ({
76
126
  traces,
77
127
  selectedTraceId,
78
128
  status,
@@ -80,41 +130,103 @@ export const TraceList = ({
80
130
  contentWidth,
81
131
  services,
82
132
  selectedService,
83
- focused = true,
84
- filterText,
85
- sortMode,
86
- totalCount,
133
+ viewportRows,
87
134
  onSelectTrace,
88
- }: { showHeader: boolean } & TraceListProps) => {
89
- if (showHeader) {
90
- const countLabel = totalCount !== undefined && totalCount !== traces.length ? `${traces.length}/${totalCount}` : traces.length > 0 ? String(traces.length) : ""
91
- const metaLabel = [
92
- filterText ? `filter: ${filterText}` : null,
93
- sortMode && sortMode !== "recent" ? `sort: ${sortMode}` : null,
94
- ].filter((part): part is string => part !== null).join(" · ")
95
- const serviceLabel = services.length > 1 && selectedService
96
- ? `${services.length} services`
97
- : ""
98
- const leftLabel = `TRACES${countLabel ? ` ${countLabel}` : ""}${metaLabel ? ` · ${metaLabel}` : ""}`
99
- const gap = Math.max(2, contentWidth - leftLabel.length - serviceLabel.length)
100
- return (
101
- <TextLine>
102
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>TRACES</span>
103
- {countLabel ? <span fg={colors.muted}>{` ${countLabel}`}</span> : null}
104
- {metaLabel ? <span fg={colors.muted}>{` · ${metaLabel}`}</span> : null}
105
- <span fg={colors.muted}>{" ".repeat(gap)}</span>
106
- <span fg={colors.muted}>{serviceLabel}</span>
107
- </TextLine>
108
- )
135
+ }: TraceListBodyProps) => {
136
+ const [scrollOffset, setScrollOffset] = useState(0)
137
+ // Track (selectedTraceId, its index in `traces`) from the previous render
138
+ // so we can detect the refresh-shift case (same traceId, new index because
139
+ // rows were prepended/removed around it) and slide scrollOffset by the
140
+ // same delta preserving the selected row's visual position instead of
141
+ // letting it jump every time auto-refresh pulls in new traces.
142
+ const lastSelectedIdRef = useRef<string | null>(null)
143
+ const lastSelectedIndexRef = useRef<number | null>(null)
144
+ const lastServiceRef = useRef<string | null>(null)
145
+
146
+ const viewport = Math.max(1, viewportRows)
147
+ const maxOffset = Math.max(0, traces.length - viewport)
148
+
149
+ useLayoutEffect(() => {
150
+ // Service change or initial mount: pin to top.
151
+ if (lastServiceRef.current !== selectedService) {
152
+ lastServiceRef.current = selectedService
153
+ lastSelectedIdRef.current = null
154
+ lastSelectedIndexRef.current = null
155
+ setScrollOffset(0)
156
+ return
157
+ }
158
+
159
+ if (!selectedTraceId) {
160
+ lastSelectedIdRef.current = null
161
+ lastSelectedIndexRef.current = null
162
+ return
163
+ }
164
+
165
+ const index = traces.findIndex((t) => t.traceId === selectedTraceId)
166
+ if (index < 0) {
167
+ lastSelectedIdRef.current = null
168
+ lastSelectedIndexRef.current = null
169
+ return
170
+ }
171
+
172
+ const prevId = lastSelectedIdRef.current
173
+ const prevIndex = lastSelectedIndexRef.current
174
+ const isRefreshShift = prevId === selectedTraceId && prevIndex !== null && prevIndex !== index
175
+
176
+ setScrollOffset((current) => {
177
+ let next = current
178
+ if (isRefreshShift) {
179
+ // Same row, new position because rows shifted around it — slide
180
+ // the window by the same delta to keep the row in the same
181
+ // visible slot.
182
+ next = current + (index - prevIndex)
183
+ } else if (index < current) {
184
+ // Selection moved above the viewport (user pressed k/up or
185
+ // jumped via gg/home). Snap the top to the selection.
186
+ next = index
187
+ } else if (index >= current + viewport) {
188
+ // Selection moved below the viewport — snap the bottom to it.
189
+ next = index - viewport + 1
190
+ }
191
+ return Math.max(0, Math.min(next, maxOffset))
192
+ })
193
+
194
+ lastSelectedIdRef.current = selectedTraceId
195
+ lastSelectedIndexRef.current = index
196
+ }, [traces, selectedTraceId, selectedService, viewport, maxOffset])
197
+
198
+ // Mouse wheel moves the scroll window WITHOUT touching selection — lets
199
+ // the user browse ahead of / behind their selected trace freely.
200
+ const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
201
+ const info = event.scroll
202
+ if (!info || traces.length === 0) return
203
+ const magnitude = Math.max(1, Math.round(info.delta))
204
+ const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
205
+ if (signed === 0) return
206
+ setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
207
+ event.stopPropagation?.()
109
208
  }
110
209
 
210
+ if (status === "loading" && traces.length === 0) {
211
+ return <PlainLine text="Loading traces..." fg={colors.muted} />
212
+ }
213
+ if (status === "error") {
214
+ return <PlainLine text={error ?? "Could not load traces."} fg={colors.error} />
215
+ }
216
+ if (status === "ready" && services.length === 0) {
217
+ return <PlainLine text="No services reporting yet. Start your app and emit a span." fg={colors.muted} />
218
+ }
219
+ if (status === "ready" && selectedService && traces.length === 0) {
220
+ return <PlainLine text="No traces in the current lookback window." fg={colors.muted} />
221
+ }
222
+
223
+ const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
224
+ const windowTraces = traces.slice(windowStart, windowStart + viewport)
225
+ const blanks = Math.max(0, viewport - windowTraces.length)
226
+
111
227
  return (
112
- <box flexDirection="column">
113
- {status === "loading" && traces.length === 0 ? <PlainLine text="Loading traces..." fg={colors.muted} /> : null}
114
- {status === "error" ? <PlainLine text={error ?? "Could not load traces."} fg={colors.error} /> : null}
115
- {status === "ready" && services.length === 0 ? <PlainLine text="No services reporting yet. Start your app and emit a span." fg={colors.muted} /> : null}
116
- {status === "ready" && selectedService && traces.length === 0 ? <PlainLine text="No traces in the current lookback window." fg={colors.muted} /> : null}
117
- {traces.map((trace) => (
228
+ <box flexDirection="column" onMouseScroll={handleWheel}>
229
+ {windowTraces.map((trace) => (
118
230
  <TraceRow
119
231
  key={trace.traceId}
120
232
  trace={trace}
@@ -123,6 +235,20 @@ export const TraceList = ({
123
235
  onSelect={() => onSelectTrace(trace.traceId)}
124
236
  />
125
237
  ))}
238
+ {Array.from({ length: blanks }, (_, i) => (
239
+ <BlankRow key={`trace-blank-${i}`} />
240
+ ))}
126
241
  </box>
127
242
  )
128
243
  }
244
+
245
+ // Backwards-compatible single-entry wrapper (header + body) for callers
246
+ // that haven't been updated to the split layout yet.
247
+ export const TraceList = ({
248
+ showHeader,
249
+ viewportRows,
250
+ ...props
251
+ }: { showHeader: boolean; viewportRows?: number } & TraceListProps) => {
252
+ if (showHeader) return <TraceListHeader {...props} />
253
+ return <TraceListBody {...props} viewportRows={viewportRows ?? 20} />
254
+ }
@@ -1,6 +1,6 @@
1
- import { memo, useRef } from "react"
1
+ import { memo, useLayoutEffect, useRef, useState } from "react"
2
2
  import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
3
- import { formatDuration, lifecycleLabel, truncateText } from "./format.ts"
3
+ import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
4
4
  import { BlankRow, TextLine } from "./primitives.tsx"
5
5
  import { colors, waterfallColors } from "./theme.ts"
6
6
 
@@ -92,12 +92,17 @@ const renderWaterfallBar = (
92
92
  barWidth: number,
93
93
  barColor: string,
94
94
  laneColor: string,
95
- ): { readonly segments: readonly WaterfallBarSegment[]; readonly afterCells: number } => {
95
+ rowBg: string,
96
+ ): { readonly segments: readonly WaterfallBarSegment[] } => {
97
+ // Timeline semantics: the leading gap (before the bar starts) is the
98
+ // "runway" showing how long after trace start this span kicked in — render
99
+ // it in the lane color. The trailing gap (after the bar ends) is post-span
100
+ // dead time — render it in the row bg so it visually disappears.
96
101
  if (barWidth < 3 || trace.durationMs === 0) {
97
- return {
98
- segments: [{ text: "\u2588", fg: barColor }],
99
- afterCells: Math.max(0, barWidth - 1),
100
- }
102
+ const trailing = Math.max(0, barWidth - 1)
103
+ const segs: WaterfallBarSegment[] = [{ text: "\u2588", fg: barColor }]
104
+ if (trailing > 0) segs.push({ text: " ".repeat(trailing), fg: rowBg, bg: rowBg })
105
+ return { segments: segs }
101
106
  }
102
107
 
103
108
  const traceStart = trace.startedAt.getTime()
@@ -114,33 +119,40 @@ const renderWaterfallBar = (
114
119
  const endOffset = endUnits % 8
115
120
  const segments: WaterfallBarSegment[] = []
116
121
 
117
- if (startCell > 0) {
118
- segments.push({ text: " ".repeat(startCell), fg: laneColor, bg: laneColor })
122
+ const pushLeading = (cells: number) => {
123
+ if (cells > 0) segments.push({ text: " ".repeat(cells), fg: laneColor, bg: laneColor })
124
+ }
125
+ const pushTrailing = (cells: number) => {
126
+ if (cells > 0) segments.push({ text: " ".repeat(cells), fg: rowBg, bg: rowBg })
119
127
  }
120
128
 
129
+ pushLeading(startCell)
130
+
121
131
  if (startCell === endCell) {
122
132
  const singleCellUnits = Math.max(1, endUnits - startUnits)
123
133
  if (singleCellUnits <= 4) {
124
134
  const centeredMarker = ULTRA_SHORT_MARKERS[Math.max(0, singleCellUnits - 1)] ?? "\u258f"
125
- segments.push({ text: centeredMarker, fg: barColor })
126
- return {
127
- segments,
128
- afterCells: Math.max(0, barWidth - startCell - 1),
129
- }
135
+ // The marker is a left-aligned sliver — the rest of the cell is
136
+ // post-bar space, so it uses the row bg (transparent) rather than
137
+ // carrying the dark lane track past where the span ended.
138
+ segments.push({ text: centeredMarker, fg: barColor, bg: rowBg })
139
+ pushTrailing(Math.max(0, barWidth - startCell - 1))
140
+ return { segments }
130
141
  }
131
142
 
132
143
  if (startOffset === 0) {
133
- segments.push({ text: PARTIAL_BLOCKS[singleCellUnits], fg: barColor })
144
+ // Bar fills from the left of the cell; post-bar pixels fall to row bg.
145
+ segments.push({ text: PARTIAL_BLOCKS[singleCellUnits], fg: barColor, bg: rowBg })
134
146
  } else {
147
+ // Bar starts partway into the cell; left pixels are lane, right is bar.
135
148
  segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
136
149
  }
137
- return {
138
- segments,
139
- afterCells: Math.max(0, barWidth - startCell - 1),
140
- }
150
+ pushTrailing(Math.max(0, barWidth - startCell - 1))
151
+ return { segments }
141
152
  }
142
153
 
143
154
  if (startOffset > 0) {
155
+ // Leading partial: left portion is lane (runway), right is bar.
144
156
  segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
145
157
  }
146
158
 
@@ -152,13 +164,12 @@ const renderWaterfallBar = (
152
164
  }
153
165
 
154
166
  if (endOffset > 0) {
155
- segments.push({ text: PARTIAL_BLOCKS[endOffset], fg: barColor })
167
+ // Trailing partial: left portion is bar, right is row bg (transparent).
168
+ segments.push({ text: PARTIAL_BLOCKS[endOffset], fg: barColor, bg: rowBg })
156
169
  }
157
170
 
158
- return {
159
- segments,
160
- afterCells: Math.max(0, barWidth - endCell - 1),
161
- }
171
+ pushTrailing(Math.max(0, barWidth - endCell - 1))
172
+ return { segments }
162
173
  }
163
174
 
164
175
  const durationColor = (durationMs: number) => {
@@ -169,20 +180,42 @@ const durationColor = (durationMs: number) => {
169
180
  return colors.muted
170
181
  }
171
182
 
172
- export const getWaterfallLayout = (contentWidth: number, traceDurationMs: number) => {
183
+ export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
173
184
  const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
174
- const durationWidth = Math.max(8, formatDuration(traceDurationMs).length + 1)
175
- const logWidth = 5
176
- const barWidth = Math.max(6, contentWidth - labelMaxWidth - durationWidth - logWidth - 2)
177
- return { labelMaxWidth, durationWidth, logWidth, barWidth } as const
185
+ // Two single-space gaps: one between label and bar, one between bar and suffix.
186
+ const barWidth = Math.max(6, contentWidth - labelMaxWidth - suffixWidth - 2)
187
+ return { labelMaxWidth, barWidth } as const
178
188
  }
179
189
 
180
- export const getWaterfallColumns = (contentWidth: number, traceDurationMs: number, durationMs: number, logCount: number) => {
181
- const { labelMaxWidth, durationWidth, logWidth, barWidth } = getWaterfallLayout(contentWidth, traceDurationMs)
182
- const durationCell = formatDuration(Math.max(0, durationMs)).padStart(durationWidth)
183
- const logText = logCount > 0 ? `${logCount}lg` : ""
184
- const logCell = logText.padStart(logWidth)
185
- return { labelMaxWidth, durationWidth, logWidth, barWidth, durationCell, logCell } as const
190
+ export type WaterfallSuffixMetrics = {
191
+ readonly maxDurationWidth: number
192
+ readonly suffixWidth: number
193
+ }
194
+
195
+ /**
196
+ * Compute a shared suffix (duration) width from the visible viewport.
197
+ * Reserving the width once keeps every row's duration right-aligned on the
198
+ * same column regardless of per-row content. Log correlation lives in the
199
+ * span detail pane, not the row suffix.
200
+ */
201
+ export const getWaterfallSuffixMetrics = (
202
+ spans: readonly { readonly durationMs: number; readonly spanId: string }[],
203
+ ): WaterfallSuffixMetrics => {
204
+ let maxDurationWidth = 0
205
+ for (const span of spans) {
206
+ const d = formatDuration(Math.max(0, span.durationMs)).length
207
+ if (d > maxDurationWidth) maxDurationWidth = d
208
+ }
209
+ return { maxDurationWidth, suffixWidth: maxDurationWidth }
210
+ }
211
+
212
+ // Retained for tests: per-row view of the shared layout.
213
+ export const getWaterfallColumns = (
214
+ contentWidth: number,
215
+ metrics: WaterfallSuffixMetrics,
216
+ ) => {
217
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
218
+ return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
186
219
  }
187
220
 
188
221
  export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
@@ -210,7 +243,6 @@ export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[]
210
243
 
211
244
  const WaterfallRow = memo(({
212
245
  span,
213
- logCount,
214
246
  trace,
215
247
  index,
216
248
  spans,
@@ -218,10 +250,10 @@ const WaterfallRow = memo(({
218
250
  selected,
219
251
  collapsed,
220
252
  hasChildSpans,
253
+ suffixMetrics,
221
254
  onSelect,
222
255
  }: {
223
256
  span: TraceSpanItem
224
- logCount: number
225
257
  trace: TraceItem
226
258
  index: number
227
259
  spans: readonly TraceSpanItem[]
@@ -229,6 +261,7 @@ const WaterfallRow = memo(({
229
261
  selected: boolean
230
262
  collapsed: boolean
231
263
  hasChildSpans: boolean
264
+ suffixMetrics: WaterfallSuffixMetrics
232
265
  onSelect: () => void
233
266
  }) => {
234
267
  const prefix = buildTreePrefix(spans, index)
@@ -236,7 +269,7 @@ const WaterfallRow = memo(({
236
269
  const indicator = span.status === "error" ? "!" : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be") : "\u00b7"
237
270
  const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
238
271
 
239
- const { labelMaxWidth, barWidth, durationCell, logCell } = getWaterfallColumns(contentWidth, trace.durationMs, span.durationMs, logCount)
272
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
240
273
 
241
274
  const opMaxWidth = Math.max(4, labelMaxWidth - prefix.length - 2)
242
275
  const opTruncated = opName.length > opMaxWidth ? `${opName.slice(0, opMaxWidth - 1)}\u2026` : opName
@@ -246,14 +279,20 @@ const WaterfallRow = memo(({
246
279
  const isError = span.status === "error"
247
280
  const barColor = selected ? (isError ? waterfallColors.barSelectedError : waterfallColors.barSelected) : isError ? waterfallColors.barError : waterfallColors.bar
248
281
  const laneColor = selected ? waterfallColors.barLane : waterfallColors.barBg
249
- const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor)
282
+ const rowBg = selected ? colors.selectedBg : colors.screenBg
283
+ const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
250
284
  const bg = selected ? colors.selectedBg : undefined
251
285
  const treeColor = selected ? colors.separator : colors.treeLine
252
286
  const indicatorColor = isError ? colors.error : hasChildSpans ? (selected ? colors.selectedText : colors.muted) : colors.passing
253
287
  const opColor = selected ? colors.selectedText : span.isRunning ? colors.warning : colors.text
254
288
 
255
289
  const durationFg = durationColor(span.durationMs)
256
- const logFg = logCount > 0 ? colors.defaultService : colors.muted
290
+ const unitFg = colors.muted
291
+
292
+ // Split the duration so the unit (s/ms) renders dimmer than the number.
293
+ const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
294
+ const durationCell = `${durNumber}${durUnit}`
295
+ const durationPad = " ".repeat(Math.max(0, suffixMetrics.maxDurationWidth - durationCell.length))
257
296
 
258
297
  return (
259
298
  <box height={1} onMouseDown={onSelect}>
@@ -267,8 +306,9 @@ const WaterfallRow = memo(({
267
306
  <span key={`${span.spanId}-bar-${index}`} fg={segment.fg} bg={segment.bg}>{segment.text}</span>
268
307
  ))}
269
308
  <span> </span>
270
- <span fg={durationFg}>{durationCell}</span>
271
- <span fg={logFg}>{logCell}</span>
309
+ <span>{durationPad}</span>
310
+ <span fg={durationFg}>{durNumber}</span>
311
+ <span fg={unitFg}>{durUnit}</span>
272
312
  </TextLine>
273
313
  </box>
274
314
  )
@@ -347,8 +387,6 @@ export const WaterfallTimeline = ({
347
387
  }) => {
348
388
  const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
349
389
 
350
- const { labelMaxWidth, durationWidth, barWidth } = getWaterfallLayout(contentWidth, trace.durationMs)
351
-
352
390
  const spanIndexById = new Map<string, number>()
353
391
  for (let i = 0; i < trace.spans.length; i++) {
354
392
  spanIndexById.set(trace.spans[i].spanId, i)
@@ -380,8 +418,28 @@ export const WaterfallTimeline = ({
380
418
  const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
381
419
  const blankCount = Math.max(0, viewportSize - windowSpans.length)
382
420
 
421
+ // One shared suffix width, measured from the current viewport, so every
422
+ // row's duration cell lines up on the same right-edge column.
423
+ const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
424
+
425
+ // Mouse wheel moves the span selection by the scroll delta. The waterfall
426
+ // uses virtual windowing (not a scrollbox) so native scroll does nothing;
427
+ // we convert wheel events into selection moves, which the windowing code
428
+ // already translates into visible-viewport shifts.
429
+ const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
430
+ const info = event.scroll
431
+ if (!info || filteredSpans.length === 0) return
432
+ const magnitude = Math.max(1, Math.round(info.delta))
433
+ const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
434
+ if (signed === 0) return
435
+ const start = selectedSpanIndex ?? 0
436
+ const next = Math.max(0, Math.min(start + signed, filteredSpans.length - 1))
437
+ if (next !== selectedSpanIndex) onSelectSpan(next)
438
+ event.stopPropagation?.()
439
+ }
440
+
383
441
  return (
384
- <box flexDirection="column">
442
+ <box flexDirection="column" onMouseScroll={handleWheel}>
385
443
  {windowSpans.map((span, index) => {
386
444
  const actualIndex = windowStart + index
387
445
  const fullIndex = spanIndexById.get(span.spanId) ?? -1
@@ -389,7 +447,6 @@ export const WaterfallTimeline = ({
389
447
  <WaterfallRow
390
448
  key={`${trace.traceId}-${span.spanId}`}
391
449
  span={span}
392
- logCount={spanLogCounts.get(span.spanId) ?? 0}
393
450
  trace={trace}
394
451
  index={fullIndex}
395
452
  spans={trace.spans}
@@ -397,6 +454,7 @@ export const WaterfallTimeline = ({
397
454
  selected={selectedSpanIndex === actualIndex}
398
455
  collapsed={collapsedSpanIds.has(span.spanId)}
399
456
  hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
457
+ suffixMetrics={suffixMetrics}
400
458
  onSelect={() => onSelectSpan(actualIndex)}
401
459
  />
402
460
  )
@@ -1,7 +1,5 @@
1
- import type { ScrollBoxRenderable } from "@opentui/core"
2
- import type { RefObject } from "react"
3
1
  import { FilterBar } from "../primitives.tsx"
4
- import { TraceList, type TraceListProps } from "../TraceList.tsx"
2
+ import { TraceListBody, TraceListHeader, type TraceListProps } from "../TraceList.tsx"
5
3
 
6
4
  interface TraceListPaneProps {
7
5
  readonly traceListProps: TraceListProps
@@ -11,9 +9,16 @@ interface TraceListPaneProps {
11
9
  readonly containerHeight: number
12
10
  readonly bodyHeight: number
13
11
  readonly padding: number
14
- readonly scrollRef: RefObject<ScrollBoxRenderable | null>
15
12
  }
16
13
 
14
+ /**
15
+ * Replaced the opentui <scrollbox> with a direct virtual-windowed body.
16
+ * Rationale: the scrollbox's scrollSize is updated during opentui's render
17
+ * pass, not during React commit, so the useLayoutEffect that adjusted
18
+ * scrollTop on refresh was reading a stale max and clamping our intended
19
+ * scroll position. Rendering only the visible rows ourselves keeps the
20
+ * viewport math entirely in React state and eliminates the race.
21
+ */
17
22
  export const TraceListPane = ({
18
23
  traceListProps,
19
24
  filterMode,
@@ -22,13 +27,13 @@ export const TraceListPane = ({
22
27
  containerHeight,
23
28
  bodyHeight,
24
29
  padding,
25
- scrollRef,
26
- }: TraceListPaneProps) => (
27
- <box height={containerHeight} flexDirection="column" paddingLeft={padding} paddingRight={padding}>
28
- <TraceList showHeader {...traceListProps} />
29
- {filterMode ? <FilterBar text={filterText} width={filterWidth} /> : null}
30
- <scrollbox ref={scrollRef} height={filterMode ? bodyHeight - 1 : bodyHeight} flexGrow={0}>
31
- <TraceList showHeader={false} {...traceListProps} />
32
- </scrollbox>
33
- </box>
34
- )
30
+ }: TraceListPaneProps) => {
31
+ const bodyRows = Math.max(1, filterMode ? bodyHeight - 1 : bodyHeight)
32
+ return (
33
+ <box height={containerHeight} flexDirection="column" paddingLeft={padding} paddingRight={0}>
34
+ <TraceListHeader {...traceListProps} />
35
+ {filterMode ? <FilterBar text={filterText} width={filterWidth} /> : null}
36
+ <TraceListBody {...traceListProps} viewportRows={bodyRows} />
37
+ </box>
38
+ )
39
+ }