@kitlangton/motel 0.1.0 → 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.
@@ -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, includeLogs = true) => {
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 = includeLogs ? 5 : 0
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
188
+ }
189
+
190
+ export type WaterfallSuffixMetrics = {
191
+ readonly maxDurationWidth: number
192
+ readonly suffixWidth: number
178
193
  }
179
194
 
180
- export const getWaterfallColumns = (contentWidth: number, traceDurationMs: number, durationMs: number, logCount: number, includeLogs = true) => {
181
- const { labelMaxWidth, durationWidth, logWidth, barWidth } = getWaterfallLayout(contentWidth, traceDurationMs, includeLogs)
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
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,11 +250,10 @@ const WaterfallRow = memo(({
218
250
  selected,
219
251
  collapsed,
220
252
  hasChildSpans,
253
+ suffixMetrics,
221
254
  onSelect,
222
- includeLogs,
223
255
  }: {
224
256
  span: TraceSpanItem
225
- logCount: number
226
257
  trace: TraceItem
227
258
  index: number
228
259
  spans: readonly TraceSpanItem[]
@@ -230,15 +261,15 @@ const WaterfallRow = memo(({
230
261
  selected: boolean
231
262
  collapsed: boolean
232
263
  hasChildSpans: boolean
264
+ suffixMetrics: WaterfallSuffixMetrics
233
265
  onSelect: () => void
234
- includeLogs: boolean
235
266
  }) => {
236
267
  const prefix = buildTreePrefix(spans, index)
237
268
  // Match the trace list indicator: `!` on error, chevron on collapsible parents, `·` on leaves.
238
269
  const indicator = span.status === "error" ? "!" : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be") : "\u00b7"
239
270
  const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
240
271
 
241
- const { labelMaxWidth, barWidth, durationCell, logCell } = getWaterfallColumns(contentWidth, trace.durationMs, span.durationMs, logCount, includeLogs)
272
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
242
273
 
243
274
  const opMaxWidth = Math.max(4, labelMaxWidth - prefix.length - 2)
244
275
  const opTruncated = opName.length > opMaxWidth ? `${opName.slice(0, opMaxWidth - 1)}\u2026` : opName
@@ -248,14 +279,20 @@ const WaterfallRow = memo(({
248
279
  const isError = span.status === "error"
249
280
  const barColor = selected ? (isError ? waterfallColors.barSelectedError : waterfallColors.barSelected) : isError ? waterfallColors.barError : waterfallColors.bar
250
281
  const laneColor = selected ? waterfallColors.barLane : waterfallColors.barBg
251
- const { segments, afterCells } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor)
282
+ const rowBg = selected ? colors.selectedBg : colors.screenBg
283
+ const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
252
284
  const bg = selected ? colors.selectedBg : undefined
253
285
  const treeColor = selected ? colors.separator : colors.treeLine
254
286
  const indicatorColor = isError ? colors.error : hasChildSpans ? (selected ? colors.selectedText : colors.muted) : colors.passing
255
287
  const opColor = selected ? colors.selectedText : span.isRunning ? colors.warning : colors.text
256
288
 
257
289
  const durationFg = durationColor(span.durationMs)
258
- 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))
259
296
 
260
297
  return (
261
298
  <box height={1} onMouseDown={onSelect}>
@@ -268,10 +305,10 @@ const WaterfallRow = memo(({
268
305
  {segments.map((segment, index) => (
269
306
  <span key={`${span.spanId}-bar-${index}`} fg={segment.fg} bg={segment.bg}>{segment.text}</span>
270
307
  ))}
271
- {afterCells > 0 ? <span fg={laneColor} bg={laneColor}>{" ".repeat(afterCells)}</span> : null}
272
308
  <span> </span>
273
- <span fg={durationFg}>{durationCell}</span>
274
- {logCell.length > 0 ? <span fg={logFg}>{logCell}</span> : null}
309
+ <span>{durationPad}</span>
310
+ <span fg={durationFg}>{durNumber}</span>
311
+ <span fg={unitFg}>{durUnit}</span>
275
312
  </TextLine>
276
313
  </box>
277
314
  )
@@ -349,7 +386,6 @@ export const WaterfallTimeline = ({
349
386
  onSelectSpan: (index: number) => void
350
387
  }) => {
351
388
  const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
352
- const includeLogs = filteredSpans.some((span) => (spanLogCounts.get(span.spanId) ?? 0) > 0)
353
389
 
354
390
  const spanIndexById = new Map<string, number>()
355
391
  for (let i = 0; i < trace.spans.length; i++) {
@@ -382,8 +418,28 @@ export const WaterfallTimeline = ({
382
418
  const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
383
419
  const blankCount = Math.max(0, viewportSize - windowSpans.length)
384
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
+
385
441
  return (
386
- <box flexDirection="column">
442
+ <box flexDirection="column" onMouseScroll={handleWheel}>
387
443
  {windowSpans.map((span, index) => {
388
444
  const actualIndex = windowStart + index
389
445
  const fullIndex = spanIndexById.get(span.spanId) ?? -1
@@ -391,7 +447,6 @@ export const WaterfallTimeline = ({
391
447
  <WaterfallRow
392
448
  key={`${trace.traceId}-${span.spanId}`}
393
449
  span={span}
394
- logCount={spanLogCounts.get(span.spanId) ?? 0}
395
450
  trace={trace}
396
451
  index={fullIndex}
397
452
  spans={trace.spans}
@@ -399,7 +454,7 @@ export const WaterfallTimeline = ({
399
454
  selected={selectedSpanIndex === actualIndex}
400
455
  collapsed={collapsedSpanIds.has(span.spanId)}
401
456
  hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
402
- includeLogs={includeLogs}
457
+ suffixMetrics={suffixMetrics}
403
458
  onSelect={() => onSelectSpan(actualIndex)}
404
459
  />
405
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
+ }
@@ -1,5 +1,3 @@
1
- import type { ScrollBoxRenderable } from "@opentui/core"
2
- import type { RefObject } from "react"
3
1
  import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
4
2
  import { formatShortDate, formatTimestamp } from "../format.ts"
5
3
  import { AlignedHeaderLine, BlankRow, Divider, SeparatorColumn, TextLine } from "../primitives.tsx"
@@ -21,7 +19,6 @@ interface TraceWorkspaceProps {
21
19
  readonly filterMode: boolean
22
20
  readonly filterText: string
23
21
  readonly traceListProps: TraceListProps
24
- readonly traceListScrollRef: RefObject<ScrollBoxRenderable | null>
25
22
  readonly selectedTraceService: string | null
26
23
  readonly serviceLogState: ServiceLogState
27
24
  readonly selectedServiceLogIndex: number
@@ -44,7 +41,6 @@ export const TraceWorkspace = ({
44
41
  filterMode,
45
42
  filterText,
46
43
  traceListProps,
47
- traceListScrollRef,
48
44
  selectedTraceService,
49
45
  serviceLogState,
50
46
  selectedServiceLogIndex,
@@ -107,10 +103,11 @@ export const TraceWorkspace = ({
107
103
  }
108
104
 
109
105
  if (isWideLayout) {
110
- return (
111
- <box flexGrow={1} flexDirection="row">
112
- <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
113
- {viewLevel <= 1 ? (
106
+ // L0: list (left) + trace preview (right). The two-pane zoom.
107
+ if (viewLevel === 0) {
108
+ return (
109
+ <box flexGrow={1} flexDirection="row">
110
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
114
111
  <TraceListPane
115
112
  traceListProps={traceListProps}
116
113
  filterMode={filterMode}
@@ -119,53 +116,86 @@ export const TraceWorkspace = ({
119
116
  containerHeight={wideBodyHeight}
120
117
  bodyHeight={wideTraceListBodyHeight}
121
118
  padding={sectionPadding}
122
- scrollRef={traceListScrollRef}
123
119
  />
124
- ) : (
120
+ </box>
121
+ <SeparatorColumn height={wideBodyHeight} junctionChars={separatorJunctionChars} />
122
+ <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
125
123
  <TraceDetailsPane
126
124
  trace={selectedTrace}
127
125
  traceSummary={selectedTraceSummary}
128
126
  traceStatus={traceDetailState.status}
129
127
  traceError={traceDetailState.error}
130
128
  traceLogsState={logState}
131
- contentWidth={leftContentWidth}
129
+ contentWidth={rightContentWidth}
132
130
  bodyLines={wideBodyLines}
133
- paneWidth={leftPaneWidth}
131
+ paneWidth={rightPaneWidth}
134
132
  selectedSpanIndex={selectedSpanIndex}
135
133
  collapsedSpanIds={collapsedSpanIds}
136
134
  focused={false}
137
135
  onSelectSpan={selectSpan}
138
136
  />
139
- )}
137
+ </box>
140
138
  </box>
141
- <SeparatorColumn height={wideBodyHeight} junctionChars={viewLevel === 2 ? separatorCrossChars : separatorJunctionChars} />
142
- <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
143
- {viewLevel <= 1 ? (
139
+ )
140
+ }
141
+
142
+ // L1: the user pressed enter on a trace — hide the list entirely and
143
+ // let the waterfall take the full width. `leftPaneWidth` is already
144
+ // `contentWidth` in this case (see useAppLayout), so one pane fills
145
+ // the row.
146
+ if (viewLevel === 1) {
147
+ return (
148
+ <box flexGrow={1} flexDirection="row">
149
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
144
150
  <TraceDetailsPane
145
151
  trace={selectedTrace}
146
152
  traceSummary={selectedTraceSummary}
147
153
  traceStatus={traceDetailState.status}
148
154
  traceError={traceDetailState.error}
149
155
  traceLogsState={logState}
150
- contentWidth={rightContentWidth}
156
+ contentWidth={Math.max(24, leftPaneWidth - sectionPadding * 2)}
151
157
  bodyLines={wideBodyLines}
152
- paneWidth={rightPaneWidth}
158
+ paneWidth={leftPaneWidth}
153
159
  selectedSpanIndex={selectedSpanIndex}
154
160
  collapsedSpanIds={collapsedSpanIds}
155
- focused={viewLevel === 1}
156
- onSelectSpan={selectSpan}
157
- />
158
- ) : (
159
- <SpanDetailPane
160
- span={selectedSpan}
161
- trace={selectedTrace}
162
- logs={selectedSpanLogs}
163
- contentWidth={rightContentWidth}
164
- bodyLines={wideBodyLines}
165
- paneWidth={rightPaneWidth}
166
161
  focused={true}
162
+ onSelectSpan={selectSpan}
167
163
  />
168
- )}
164
+ </box>
165
+ </box>
166
+ )
167
+ }
168
+
169
+ // L2: waterfall-left + span detail-right. Still no list.
170
+ return (
171
+ <box flexGrow={1} flexDirection="row">
172
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
173
+ <TraceDetailsPane
174
+ trace={selectedTrace}
175
+ traceSummary={selectedTraceSummary}
176
+ traceStatus={traceDetailState.status}
177
+ traceError={traceDetailState.error}
178
+ traceLogsState={logState}
179
+ contentWidth={leftContentWidth}
180
+ bodyLines={wideBodyLines}
181
+ paneWidth={leftPaneWidth}
182
+ selectedSpanIndex={selectedSpanIndex}
183
+ collapsedSpanIds={collapsedSpanIds}
184
+ focused={false}
185
+ onSelectSpan={selectSpan}
186
+ />
187
+ </box>
188
+ <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
189
+ <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
190
+ <SpanDetailPane
191
+ span={selectedSpan}
192
+ trace={selectedTrace}
193
+ logs={selectedSpanLogs}
194
+ contentWidth={rightContentWidth}
195
+ bodyLines={wideBodyLines}
196
+ paneWidth={rightPaneWidth}
197
+ focused={true}
198
+ />
169
199
  </box>
170
200
  </box>
171
201
  )
@@ -182,7 +212,6 @@ export const TraceWorkspace = ({
182
212
  containerHeight={narrowListHeight}
183
213
  bodyHeight={narrowTraceListBodyHeight}
184
214
  padding={sectionPadding}
185
- scrollRef={traceListScrollRef}
186
215
  />
187
216
  <Divider width={contentWidth} />
188
217
  <TraceDetailsPane
@@ -25,10 +25,28 @@ export const useAppLayout = ({ width, height, notice, detailView, selectedSpanIn
25
25
  detailView === "span-detail" ? 2 :
26
26
  selectedSpanIndex !== null ? 1 :
27
27
  0
28
+ // At L0 we show list + preview side-by-side. Once drilled in (L1/L2) the
29
+ // trace list is hidden entirely and the detail pane(s) take the full
30
+ // width — either one pane (waterfall at L1) or a 50/50 split between
31
+ // waterfall and span detail at L2.
28
32
  const splitRatio = viewLevelForLayout === 2 ? 0.5 : 0.4
29
- const leftPaneWidth = isWideLayout ? Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio)) : contentWidth
30
- const rightPaneWidth = isWideLayout ? Math.max(28, contentWidth - leftPaneWidth - splitGap) : contentWidth
31
- const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 3) : Math.max(24, contentWidth - sectionPadding * 2)
33
+ const listHidden = viewLevelForLayout >= 1
34
+ const leftPaneWidth = !isWideLayout
35
+ ? contentWidth
36
+ : listHidden
37
+ ? (viewLevelForLayout === 2 ? Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio)) : contentWidth)
38
+ : Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio))
39
+ const rightPaneWidth = !isWideLayout
40
+ ? contentWidth
41
+ : listHidden && viewLevelForLayout !== 2
42
+ ? 0
43
+ : Math.max(28, contentWidth - leftPaneWidth - splitGap)
44
+ // Left pane: paddingLeft (1) + scrollbar column (1). No right padding —
45
+ // the vertical pane divider handles visual separation from the right pane.
46
+ const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 2) : Math.max(24, contentWidth - sectionPadding * 2)
47
+ // Right pane: both left and right padding. Trace details and span detail
48
+ // content needs a little breathing room on the right so long op names
49
+ // and the duration column don't butt against the pane border.
32
50
  const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
33
51
  const headerFooterWidth = Math.max(24, contentWidth - 2)
34
52
  const wideBodyHeight = availableContentHeight
@@ -56,6 +74,7 @@ export const useAppLayout = ({ width, height, notice, detailView, selectedSpanIn
56
74
  sectionPadding,
57
75
  availableContentHeight,
58
76
  viewLevel: viewLevelForLayout,
77
+ listHidden,
59
78
  footerNotice,
60
79
  footerHeight,
61
80
  leftPaneWidth,
@@ -3,6 +3,8 @@ import { useCallback, useEffect, useMemo, useRef } from "react"
3
3
  import { config } from "../../config.js"
4
4
  import type { LogItem, TraceItem } from "../../domain.ts"
5
5
  import {
6
+ activeAttrKeyAtom,
7
+ activeAttrValueAtom,
6
8
  autoRefreshAtom,
7
9
  collapsedSpanIdsAtom,
8
10
  detailViewAtom,
@@ -11,6 +13,7 @@ import {
11
13
  initialLogState,
12
14
  initialServiceLogState,
13
15
  initialTraceDetailState,
16
+ loadFilteredTraceSummaries,
14
17
  loadRecentTraceSummaries,
15
18
  loadServiceLogs,
16
19
  loadTraceDetail,
@@ -47,6 +50,8 @@ export const useTraceScreenData = () => {
47
50
  const [autoRefresh] = useAtom(autoRefreshAtom)
48
51
  const [filterMode] = useAtom(filterModeAtom)
49
52
  const [filterText] = useAtom(filterTextAtom)
53
+ const [activeAttrKey] = useAtom(activeAttrKeyAtom)
54
+ const [activeAttrValue] = useAtom(activeAttrValueAtom)
50
55
  const [traceSort] = useAtom(traceSortAtom)
51
56
 
52
57
  const selectedTraceRef = useRef<string | null>(null)
@@ -94,7 +99,11 @@ export const useTraceScreenData = () => {
94
99
  setSelectedTraceService(effectiveService)
95
100
  }
96
101
 
97
- const traces = effectiveService ? await loadRecentTraceSummaries(effectiveService) : []
102
+ const traces = effectiveService
103
+ ? (activeAttrKey && activeAttrValue
104
+ ? await loadFilteredTraceSummaries(effectiveService, { [activeAttrKey]: activeAttrValue })
105
+ : await loadRecentTraceSummaries(effectiveService))
106
+ : []
98
107
  if (cancelled) return
99
108
 
100
109
  const prevTraceId = selectedTraceRef.current
@@ -117,7 +126,7 @@ export const useTraceScreenData = () => {
117
126
  return () => {
118
127
  cancelled = true
119
128
  }
120
- }, [refreshNonce, selectedTraceService, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
129
+ }, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
121
130
 
122
131
  useEffect(() => {
123
132
  setSelectedTraceIndex((current) => {
@@ -402,6 +411,8 @@ export const useTraceScreenData = () => {
402
411
  autoRefresh,
403
412
  filterMode,
404
413
  filterText,
414
+ activeAttrKey,
415
+ activeAttrValue,
405
416
  traceSort,
406
417
  selectedTraceSummary,
407
418
  selectedTrace,
package/src/ui/format.ts CHANGED
@@ -19,13 +19,22 @@ export const formatShortDate = (date: Date) => date.toLocaleDateString("en-US",
19
19
  export const formatTimestamp = (date: Date) => date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase()
20
20
 
21
21
  export const formatDuration = (durationMs: number) => {
22
+ const { number, unit } = splitDuration(durationMs)
23
+ return `${number}${unit}`
24
+ }
25
+
26
+ /**
27
+ * Split a duration into its numeric and unit parts so the unit can render
28
+ * dimmer than the number (easier to visually parse a column of durations).
29
+ */
30
+ export const splitDuration = (durationMs: number): { number: string; unit: "s" | "ms" } => {
22
31
  const trimDecimal = (value: string) => value.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")
23
32
 
24
- if (durationMs >= 10_000) return `${Math.round(durationMs / 1000)}s`
25
- if (durationMs >= 1000) return `${trimDecimal((durationMs / 1000).toFixed(1))}s`
26
- if (durationMs >= 100) return `${Math.round(durationMs)}ms`
27
- if (durationMs >= 10) return `${trimDecimal(durationMs.toFixed(1))}ms`
28
- return `${trimDecimal(durationMs.toFixed(2))}ms`
33
+ if (durationMs >= 10_000) return { number: `${Math.round(durationMs / 1000)}`, unit: "s" }
34
+ if (durationMs >= 1000) return { number: trimDecimal((durationMs / 1000).toFixed(1)), unit: "s" }
35
+ if (durationMs >= 100) return { number: `${Math.round(durationMs)}`, unit: "ms" }
36
+ if (durationMs >= 10) return { number: trimDecimal(durationMs.toFixed(1)), unit: "ms" }
37
+ return { number: trimDecimal(durationMs.toFixed(2)), unit: "ms" }
29
38
  }
30
39
 
31
40
  export const lifecycleLabel = (value: { readonly isRunning: boolean }) => (value.isRunning ? "open" : "closed")
@@ -118,7 +118,8 @@ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: {
118
118
  {row("t", `cycle theme (${themeLabel})`)}
119
119
  {row("tab", "toggle service logs")}
120
120
  {row("[ ]", "switch service")}
121
- {row("/", "filter traces")}
121
+ {row("/", "filter by root operation")}
122
+ {row("f", "filter traces by span attribute")}
122
123
  {row("s", "cycle sort mode")}
123
124
  {row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
124
125
  {row("r", "refresh traces")}
@@ -149,6 +150,7 @@ export const FooterHints = ({ spanNavActive, detailView, autoRefresh, width: _wi
149
150
  ["t", "theme"],
150
151
  ["tab", "logs"],
151
152
  ["/", "filter"],
153
+ ["f", "attr"],
152
154
  ["s", "sort"],
153
155
  ["a", autoRefresh ? "live" : "paused"],
154
156
  ["?", "help"],