@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,90 @@
1
+ import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
2
+ import { formatDuration, lifecycleLabel } from "./format.ts"
3
+ import { AlignedHeaderLine, Divider, PlainLine, TextLine } from "./primitives.tsx"
4
+ import { SpanDetailView } from "./SpanDetail.tsx"
5
+ import { colors, SEPARATOR } from "./theme.ts"
6
+
7
+ /**
8
+ * Level-2 view: focused span. Renders a header matching
9
+ * `TraceDetailsPane` geometry (so wide-mode side-by-side layouts line up
10
+ * vertically) plus a body that can grow to fill the available height.
11
+ *
12
+ * Total height: `bodyLines + HEADER_ROWS`.
13
+ */
14
+ export const SPAN_DETAIL_HEADER_ROWS = 4
15
+
16
+ export const SpanDetailPane = ({
17
+ span,
18
+ trace,
19
+ logs,
20
+ contentWidth,
21
+ bodyLines,
22
+ paneWidth,
23
+ focused = false,
24
+ }: {
25
+ span: TraceSpanItem | null
26
+ trace: TraceItem | null
27
+ logs: readonly LogItem[]
28
+ contentWidth: number
29
+ bodyLines: number
30
+ paneWidth: number
31
+ focused?: boolean
32
+ }) => {
33
+ const headerTitle = "SPAN"
34
+ const headerRight = span
35
+ ? `${span.status} \u00b7 ${formatDuration(span.durationMs)}${logs.length > 0 ? ` \u00b7 ${logs.length} lg` : ""}`
36
+ : "no span selected"
37
+ const headerColor = span
38
+ ? span.isRunning
39
+ ? colors.warning
40
+ : span.status === "error"
41
+ ? colors.error
42
+ : colors.passing
43
+ : colors.muted
44
+
45
+ return (
46
+ <box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_DETAIL_HEADER_ROWS} overflow="hidden">
47
+ <box paddingLeft={1} paddingRight={1}>
48
+ <AlignedHeaderLine left={headerTitle} right={headerRight} width={contentWidth} rightFg={headerColor} />
49
+ </box>
50
+ {span && trace ? (
51
+ <>
52
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
53
+ <TextLine>
54
+ <span fg={colors.text}>{span.operationName}</span>
55
+ </TextLine>
56
+ <TextLine>
57
+ <span fg={colors.defaultService}>{span.serviceName}</span>
58
+ <span fg={colors.separator}>{SEPARATOR}</span>
59
+ <span fg={colors.muted}>{span.scopeName ?? "no scope"}</span>
60
+ <span fg={colors.separator}>{SEPARATOR}</span>
61
+ <span fg={span.isRunning ? colors.warning : colors.muted}>{lifecycleLabel(span)}</span>
62
+ <span fg={colors.separator}>{SEPARATOR}</span>
63
+ <span fg={colors.muted}>{span.spanId.slice(0, 16)}</span>
64
+ </TextLine>
65
+ </box>
66
+ <Divider width={paneWidth} />
67
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
68
+ <SpanDetailView
69
+ span={span}
70
+ logs={logs}
71
+ contentWidth={contentWidth}
72
+ bodyLines={bodyLines}
73
+ />
74
+ </box>
75
+ </>
76
+ ) : (
77
+ <>
78
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
79
+ <TextLine><span fg={colors.muted}>—</span></TextLine>
80
+ <TextLine><span fg={colors.muted}>—</span></TextLine>
81
+ </box>
82
+ <Divider width={paneWidth} />
83
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
84
+ <PlainLine text="Select a span in the waterfall to view its detail." fg={colors.muted} />
85
+ </box>
86
+ </>
87
+ )}
88
+ </box>
89
+ )
90
+ }
@@ -0,0 +1,175 @@
1
+ import { useMemo } from "react"
2
+ import type { TraceItem, TraceSummaryItem } from "../domain.ts"
3
+ import { formatDuration, formatShortDate, formatTimestamp } from "./format.ts"
4
+ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./primitives.tsx"
5
+ import { WaterfallTimeline } from "./Waterfall.tsx"
6
+ import { computeMatchingSpanIds } from "./waterfallFilter.ts"
7
+ import { getVisibleSpans } from "./waterfallModel.ts"
8
+ import type { LoadStatus } from "./atoms.ts"
9
+ import { colors, SEPARATOR } from "./theme.ts"
10
+
11
+ /**
12
+ * Level-1 view: trace header + waterfall timeline body.
13
+ *
14
+ * Does not try to render a span detail/preview — the App orchestrates
15
+ * Level-2 layout separately (either as a second horizontal pane in wide
16
+ * mode, or as a full-screen takeover in narrow mode).
17
+ *
18
+ * Total height: `bodyLines + HEADER_ROWS`.
19
+ */
20
+ export const TRACE_DETAILS_HEADER_ROWS = 4
21
+
22
+ export const TraceDetailsPane = ({
23
+ trace,
24
+ traceSummary,
25
+ traceStatus,
26
+ traceError,
27
+ traceLogCount,
28
+ contentWidth,
29
+ bodyLines,
30
+ paneWidth,
31
+ selectedSpanIndex,
32
+ collapsedSpanIds,
33
+ onSelectSpan,
34
+ waterfallFilterMode,
35
+ waterfallFilterText,
36
+ }: {
37
+ trace: TraceItem | null
38
+ traceSummary: TraceSummaryItem | null
39
+ traceStatus: LoadStatus
40
+ traceError: string | null
41
+ traceLogCount: number
42
+ contentWidth: number
43
+ bodyLines: number
44
+ paneWidth: number
45
+ selectedSpanIndex: number | null
46
+ collapsedSpanIds: ReadonlySet<string>
47
+ onSelectSpan: (index: number) => void
48
+ waterfallFilterMode: boolean
49
+ waterfallFilterText: string
50
+ }) => {
51
+ const filteredSpans = useMemo(
52
+ () => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
53
+ [trace, collapsedSpanIds],
54
+ )
55
+ const matchingSpanIds = useMemo(
56
+ () => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
57
+ [trace, waterfallFilterText],
58
+ )
59
+ const matchCount = matchingSpanIds?.size ?? 0
60
+ // Reserve 1 row for the filter bar when it's being shown so the
61
+ // waterfall doesn't spill into the footer.
62
+ const showFilterBar = waterfallFilterMode || waterfallFilterText.length > 0
63
+ const waterfallBodyLines = showFilterBar ? Math.max(1, bodyLines - 1) : bodyLines
64
+
65
+ const traceMeta = trace ?? traceSummary
66
+ const hasTraceSelection = traceSummary !== null
67
+ const isLoadingTrace = hasTraceSelection && trace === null && traceStatus !== "error"
68
+
69
+ const headerTitle = "TRACE DETAILS"
70
+ const headerRight = traceMeta
71
+ ? `${traceMeta.errorCount > 0 ? `${traceMeta.errorCount} errors` : traceMeta.isRunning ? "running" : isLoadingTrace ? "loading" : "healthy"} \u00b7 ${formatDuration(traceMeta.durationMs)}${traceLogCount > 0 ? ` \u00b7 ${traceLogCount} logs` : ""}`
72
+ : traceStatus === "error"
73
+ ? "trace unavailable"
74
+ : "waiting for trace"
75
+ const headerColor = isLoadingTrace
76
+ ? colors.count
77
+ : traceMeta?.isRunning
78
+ ? colors.warning
79
+ : traceMeta && traceMeta.errorCount > 0
80
+ ? colors.error
81
+ : colors.passing
82
+
83
+ const dateStr = traceMeta ? `${formatShortDate(traceMeta.startedAt)} ${formatTimestamp(traceMeta.startedAt)}` : ""
84
+ const opLeft = traceMeta?.rootOperationName ?? ""
85
+ const opGap = Math.max(2, contentWidth - opLeft.length - dateStr.length)
86
+ const warningCount = traceMeta?.warnings.length ?? 0
87
+
88
+ return (
89
+ <box flexDirection="column" width={paneWidth} height={bodyLines + TRACE_DETAILS_HEADER_ROWS} overflow="hidden">
90
+ <box paddingLeft={1} paddingRight={1}>
91
+ <AlignedHeaderLine left={headerTitle} right={headerRight} width={contentWidth} rightFg={headerColor} />
92
+ </box>
93
+ {trace ? (
94
+ <>
95
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
96
+ <TextLine>
97
+ <span>{opLeft}</span>
98
+ <span>{" ".repeat(opGap)}</span>
99
+ <span fg={colors.muted}>{dateStr}</span>
100
+ </TextLine>
101
+ <TextLine>
102
+ <span fg={colors.count}>{trace.spanCount} spans</span>
103
+ {warningCount > 0 ? (
104
+ <>
105
+ <span fg={colors.separator}>{SEPARATOR}</span>
106
+ <span fg={colors.error}>{warningCount} warning{warningCount === 1 ? "" : "s"}</span>
107
+ </>
108
+ ) : null}
109
+ <span fg={colors.separator}>{SEPARATOR}</span>
110
+ <span fg={colors.muted}>{trace.traceId}</span>
111
+ </TextLine>
112
+ </box>
113
+ <Divider width={paneWidth} />
114
+ {showFilterBar ? (
115
+ <box paddingLeft={1} paddingRight={1}>
116
+ {waterfallFilterMode ? (
117
+ <FilterBar text={waterfallFilterText} width={contentWidth} />
118
+ ) : (
119
+ <TextLine>
120
+ <span fg={colors.muted}>{"/"}</span>
121
+ <span fg={colors.text}>{waterfallFilterText}</span>
122
+ <span fg={colors.separator}>{SEPARATOR}</span>
123
+ <span fg={colors.count}>{matchCount} match{matchCount === 1 ? "" : "es"}</span>
124
+ <span fg={colors.separator}>{SEPARATOR}</span>
125
+ <span fg={colors.muted}>esc clear</span>
126
+ </TextLine>
127
+ )}
128
+ </box>
129
+ ) : null}
130
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
131
+ <WaterfallTimeline
132
+ trace={trace}
133
+ filteredSpans={filteredSpans}
134
+ contentWidth={contentWidth}
135
+ bodyLines={waterfallBodyLines}
136
+ selectedSpanIndex={selectedSpanIndex}
137
+ collapsedSpanIds={collapsedSpanIds}
138
+ matchingSpanIds={matchingSpanIds}
139
+ onSelectSpan={onSelectSpan}
140
+ />
141
+ </box>
142
+ </>
143
+ ) : isLoadingTrace && traceMeta ? (
144
+ <>
145
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
146
+ <TextLine>
147
+ <span>{opLeft}</span>
148
+ <span>{" ".repeat(opGap)}</span>
149
+ <span fg={colors.muted}>{dateStr}</span>
150
+ </TextLine>
151
+ <TextLine>
152
+ <span fg={colors.count}>{traceMeta.spanCount} spans</span>
153
+ <span fg={colors.separator}>{SEPARATOR}</span>
154
+ <span fg={colors.count}>warming adjacent trace...</span>
155
+ <span fg={colors.separator}>{SEPARATOR}</span>
156
+ <span fg={colors.muted}>{traceMeta.traceId}</span>
157
+ </TextLine>
158
+ </box>
159
+ <Divider width={paneWidth} />
160
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
161
+ <PlainLine text="Loading trace details..." fg={colors.count} />
162
+ </box>
163
+ </>
164
+ ) : hasTraceSelection && traceStatus === "error" ? (
165
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
166
+ <PlainLine text={traceError ?? "Could not load trace."} fg={colors.error} />
167
+ </box>
168
+ ) : (
169
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
170
+ <PlainLine text="No trace selected. Use j/k in the trace list." fg={colors.muted} />
171
+ </box>
172
+ )}
173
+ </box>
174
+ )
175
+ }
@@ -0,0 +1,254 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { useLayoutEffect, useRef, useState } from "react"
3
+ import type { TraceSummaryItem } from "../domain.ts"
4
+ import { fitCell, formatDuration, lifecycleLabel, relativeTime, traceIndicator, traceIndicatorColor, traceRowId } from "./format.ts"
5
+ import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
6
+ import type { LoadStatus } from "./atoms.ts"
7
+ import { colors } from "./theme.ts"
8
+
9
+ const getTraceRowLayout = (contentWidth: number) => {
10
+ const stateWidth = 1
11
+ const durationWidth = 8
12
+ const countWidth = 6
13
+ const ageWidth = 4
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)
19
+ return { stateWidth, durationWidth, countWidth, ageWidth, titleWidth }
20
+ }
21
+
22
+ const fitTraceTitle = (text: string, width: number) => {
23
+ if (width <= 0) return ""
24
+ return text.length <= width ? text.padEnd(width, " ") : text.slice(0, width)
25
+ }
26
+
27
+ const TraceRow = ({
28
+ trace,
29
+ selected,
30
+ contentWidth,
31
+ onSelect,
32
+ }: {
33
+ trace: TraceSummaryItem
34
+ selected: boolean
35
+ contentWidth: number
36
+ onSelect: () => void
37
+ }) => {
38
+ const { stateWidth, durationWidth, countWidth, ageWidth, titleWidth } = getTraceRowLayout(contentWidth)
39
+ const title = trace.isRunning
40
+ ? `${trace.rootOperationName} [${lifecycleLabel(trace)}]`
41
+ : trace.rootOperationName
42
+ const titleColor = selected ? colors.selectedText : trace.isRunning ? colors.warning : colors.text
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
+
48
+ return (
49
+ <box id={traceRowId(trace.traceId)} height={1} onMouseDown={onSelect}>
50
+ <TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
51
+ <span fg={traceIndicatorColor(trace)}>{fitCell(traceIndicator(trace), stateWidth)}</span>
52
+ <span> </span>
53
+ <span fg={titleColor}>{fitTraceTitle(title, titleWidth)}</span>
54
+ <span fg={colors.muted}>{fitCell(durationText, durationWidth, "right")}</span>
55
+ <span> </span>
56
+ <span fg={colors.muted}>{fitCell(`${trace.spanCount}sp`, countWidth, "right")}</span>
57
+ <span> </span>
58
+ <span fg={colors.muted}>{fitCell(relativeTime(trace.startedAt), ageWidth, "right")}</span>
59
+ </TextLine>
60
+ </box>
61
+ )
62
+ }
63
+
64
+ export interface TraceListProps {
65
+ readonly traces: readonly TraceSummaryItem[]
66
+ readonly selectedTraceId: string | null
67
+ readonly status: LoadStatus
68
+ readonly error: string | null
69
+ readonly contentWidth: number
70
+ readonly services: readonly string[]
71
+ readonly selectedService: string | null
72
+ readonly focused?: boolean
73
+ readonly filterText?: string
74
+ readonly sortMode?: string
75
+ readonly totalCount?: number
76
+ readonly onSelectTrace: (traceId: string) => void
77
+ }
78
+
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 = ({
126
+ traces,
127
+ selectedTraceId,
128
+ status,
129
+ error,
130
+ contentWidth,
131
+ services,
132
+ selectedService,
133
+ viewportRows,
134
+ onSelectTrace,
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?.()
208
+ }
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
+
227
+ return (
228
+ <box flexDirection="column" onMouseScroll={handleWheel}>
229
+ {windowTraces.map((trace) => (
230
+ <TraceRow
231
+ key={trace.traceId}
232
+ trace={trace}
233
+ selected={trace.traceId === selectedTraceId}
234
+ contentWidth={contentWidth}
235
+ onSelect={() => onSelectTrace(trace.traceId)}
236
+ />
237
+ ))}
238
+ {Array.from({ length: blanks }, (_, i) => (
239
+ <BlankRow key={`trace-blank-${i}`} />
240
+ ))}
241
+ </box>
242
+ )
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
+ }