@kitlangton/motel 0.2.0 → 0.2.4

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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +7 -5
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +213 -6
  5. package/src/daemon.ts +174 -38
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +114 -128
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +68 -0
  15. package/src/services/TelemetryStore.ts +262 -119
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +244 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +308 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +11 -28
  32. package/src/ui/Waterfall.tsx +43 -148
  33. package/src/ui/aiChatModel.test.ts +391 -0
  34. package/src/ui/aiChatModel.ts +773 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +288 -124
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +174 -40
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
@@ -2,9 +2,10 @@ import { useMemo } from "react"
2
2
  import type { TraceItem, TraceSummaryItem } from "../domain.ts"
3
3
  import { formatDuration, formatShortDate, formatTimestamp } from "./format.ts"
4
4
  import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./primitives.tsx"
5
- import { getVisibleSpans, WaterfallTimeline } from "./Waterfall.tsx"
5
+ import { WaterfallTimeline } from "./Waterfall.tsx"
6
6
  import { computeMatchingSpanIds } from "./waterfallFilter.ts"
7
- import type { LoadStatus, LogState } from "./state.ts"
7
+ import { getVisibleSpans } from "./waterfallModel.ts"
8
+ import type { LoadStatus } from "./state.ts"
8
9
  import { colors, SEPARATOR } from "./theme.ts"
9
10
 
10
11
  /**
@@ -23,13 +24,12 @@ export const TraceDetailsPane = ({
23
24
  traceSummary,
24
25
  traceStatus,
25
26
  traceError,
26
- traceLogsState,
27
+ traceLogCount,
27
28
  contentWidth,
28
29
  bodyLines,
29
30
  paneWidth,
30
31
  selectedSpanIndex,
31
32
  collapsedSpanIds,
32
- focused = false,
33
33
  onSelectSpan,
34
34
  waterfallFilterMode,
35
35
  waterfallFilterText,
@@ -38,13 +38,12 @@ export const TraceDetailsPane = ({
38
38
  traceSummary: TraceSummaryItem | null
39
39
  traceStatus: LoadStatus
40
40
  traceError: string | null
41
- traceLogsState: LogState
41
+ traceLogCount: number
42
42
  contentWidth: number
43
43
  bodyLines: number
44
44
  paneWidth: number
45
45
  selectedSpanIndex: number | null
46
46
  collapsedSpanIds: ReadonlySet<string>
47
- focused?: boolean
48
47
  onSelectSpan: (index: number) => void
49
48
  waterfallFilterMode: boolean
50
49
  waterfallFilterText: string
@@ -53,20 +52,6 @@ export const TraceDetailsPane = ({
53
52
  () => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
54
53
  [trace, collapsedSpanIds],
55
54
  )
56
- const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
57
- const traceLogCount = traceLogsState.data.length
58
- const spanLogCounts = useMemo(() => {
59
- const counts = new Map<string, number>()
60
- for (const log of traceLogsState.data) {
61
- if (!log.spanId) continue
62
- counts.set(log.spanId, (counts.get(log.spanId) ?? 0) + 1)
63
- }
64
- return counts
65
- }, [traceLogsState.data])
66
- const selectedSpanLogs = useMemo(
67
- () => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
68
- [selectedSpan, traceLogsState.data],
69
- )
70
55
  const matchingSpanIds = useMemo(
71
56
  () => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
72
57
  [trace, waterfallFilterText],
@@ -143,14 +128,12 @@ export const TraceDetailsPane = ({
143
128
  </box>
144
129
  ) : null}
145
130
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
146
- <WaterfallTimeline
147
- trace={trace}
148
- filteredSpans={filteredSpans}
149
- spanLogCounts={spanLogCounts}
150
- selectedSpanLogs={selectedSpanLogs}
151
- contentWidth={contentWidth}
152
- bodyLines={waterfallBodyLines}
153
- selectedSpanIndex={selectedSpanIndex}
131
+ <WaterfallTimeline
132
+ trace={trace}
133
+ filteredSpans={filteredSpans}
134
+ contentWidth={contentWidth}
135
+ bodyLines={waterfallBodyLines}
136
+ selectedSpanIndex={selectedSpanIndex}
154
137
  collapsedSpanIds={collapsedSpanIds}
155
138
  matchingSpanIds={matchingSpanIds}
156
139
  onSelectSpan={onSelectSpan}
@@ -1,81 +1,17 @@
1
- import { memo, useLayoutEffect, useRef, useState } from "react"
2
- import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
1
+ import { memo, useLayoutEffect, useState } from "react"
2
+ import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
3
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
-
7
- /** Filter spans to only those visible given a set of collapsed span IDs. */
8
- export const getVisibleSpans = (spans: readonly TraceSpanItem[], collapsedIds: ReadonlySet<string>): readonly TraceSpanItem[] => {
9
- if (collapsedIds.size === 0) return spans
10
- const result: TraceSpanItem[] = []
11
- let skipDepth = -1
12
- for (const span of spans) {
13
- if (skipDepth >= 0 && span.depth > skipDepth) continue
14
- skipDepth = -1
15
- result.push(span)
16
- if (collapsedIds.has(span.spanId)) {
17
- skipDepth = span.depth
18
- }
19
- }
20
- return result
21
- }
22
-
23
- /** Find the index of a span's parent in the visible list. */
24
- export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
25
- const span = spans[index]
26
- if (!span || span.depth === 0) return null
27
- for (let i = index - 1; i >= 0; i--) {
28
- if (spans[i]!.depth < span.depth) return i
29
- }
30
- return null
31
- }
32
-
33
- /** Find the index of a span's first child in the visible list. */
34
- export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
35
- const span = spans[index]
36
- const next = spans[index + 1]
37
- if (span && next && next.depth > span.depth) return index + 1
38
- return null
39
- }
40
-
41
- const INTERESTING_TAGS = [
42
- "http.method", "http.url", "http.status_code", "http.route",
43
- "db.system", "db.statement", "db.name",
44
- "messaging.system", "messaging.destination",
45
- "error", "error.message",
46
- "net.peer.name", "net.peer.port",
47
- ] as const
48
-
49
- const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
50
- const span = spans[index]
51
- if (span.depth === 0) return ""
52
-
53
- const parts: string[] = []
54
-
55
- const isLastChild = (spanIndex: number, depth: number): boolean => {
56
- for (let i = spanIndex + 1; i < spans.length; i++) {
57
- if (spans[i].depth < depth) return true
58
- if (spans[i].depth === depth) return false
59
- }
60
- return true
61
- }
62
-
63
- parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
64
-
65
- for (let d = span.depth - 1; d >= 1; d--) {
66
- let parentIndex = index
67
- for (let i = index - 1; i >= 0; i--) {
68
- if (spans[i].depth === d) {
69
- parentIndex = i
70
- break
71
- }
72
- if (spans[i].depth < d) break
73
- }
74
- parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
75
- }
76
-
77
- return parts.reverse().join("")
78
- }
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"
79
15
 
80
16
  const PARTIAL_BLOCKS = ["", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"] as const
81
17
  const ULTRA_SHORT_MARKERS = ["\u258f", "\u258e", "\u258d", "\u258c"] as const
@@ -180,66 +116,6 @@ const durationColor = (durationMs: number) => {
180
116
  return colors.muted
181
117
  }
182
118
 
183
- export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
184
- const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
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
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
219
- }
220
-
221
- export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
222
- const entries = Object.entries(span.tags)
223
- const interesting = entries.filter(([key]) =>
224
- INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
225
- )
226
- const rest = entries.filter(([key]) =>
227
- !INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
228
- )
229
- const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
230
- if (logs.length > 0) {
231
- tagResults.push({ key: "logs", value: `${logs.length} correlated` })
232
- tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
233
- }
234
-
235
- tagResults.push(...[...interesting, ...rest]
236
- .slice(0, maxEntries - span.warnings.length)
237
- .map(([key, value]) => ({ key, value })))
238
- for (const warning of span.warnings) {
239
- tagResults.push({ key: "warning", value: warning, isWarning: true })
240
- }
241
- return tagResults.slice(0, maxEntries)
242
- }
243
119
 
244
120
  const WaterfallRow = memo(({
245
121
  span,
@@ -267,14 +143,33 @@ const WaterfallRow = memo(({
267
143
  onSelect: () => void
268
144
  }) => {
269
145
  const prefix = buildTreePrefix(spans, index)
270
- // Match the trace list indicator: `!` on error, chevron on collapsible parents, `·` on leaves.
271
- const indicator = span.status === "error" ? "!" : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be") : "\u00b7"
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"
272
156
  const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
273
157
 
274
158
  const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
275
159
 
276
- const opMaxWidth = Math.max(4, labelMaxWidth - prefix.length - 2)
277
- const opTruncated = opName.length > opMaxWidth ? `${opName.slice(0, opMaxWidth - 1)}\u2026` : opName
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
278
173
  const labelLen = prefix.length + 2 + opTruncated.length
279
174
  const labelPad = " ".repeat(Math.max(0, labelMaxWidth - labelLen))
280
175
 
@@ -292,6 +187,11 @@ const WaterfallRow = memo(({
292
187
  const indicatorColor = selected ? colors.selectedText
293
188
  : dimmed ? colors.separator
294
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
295
195
  : hasChildSpans ? colors.muted
296
196
  : colors.passing
297
197
  const opColor = selected ? colors.selectedText
@@ -380,8 +280,6 @@ export const SpanPreview = ({
380
280
  export const WaterfallTimeline = ({
381
281
  trace,
382
282
  filteredSpans,
383
- spanLogCounts,
384
- selectedSpanLogs,
385
283
  contentWidth,
386
284
  bodyLines,
387
285
  selectedSpanIndex,
@@ -391,8 +289,6 @@ export const WaterfallTimeline = ({
391
289
  }: {
392
290
  trace: TraceItem
393
291
  filteredSpans: readonly TraceSpanItem[]
394
- spanLogCounts: ReadonlyMap<string, number>
395
- selectedSpanLogs: readonly LogItem[]
396
292
  contentWidth: number
397
293
  bodyLines: number
398
294
  selectedSpanIndex: number | null
@@ -420,13 +316,12 @@ export const WaterfallTimeline = ({
420
316
  const viewportSize = Math.max(1, bodyLines)
421
317
  const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
422
318
  const [scrollOffset, setScrollOffset] = useState(0)
423
- const lastTraceIdRef = useRef<string | null>(null)
424
319
 
425
- // Reset scroll offset when the trace changes.
426
- if (trace.traceId !== lastTraceIdRef.current) {
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(() => {
427
323
  setScrollOffset(0)
428
- lastTraceIdRef.current = trace.traceId
429
- }
324
+ }, [trace.traceId])
430
325
 
431
326
  // Auto-follow selection: only if the selected span would be hidden
432
327
  // by the current window, shift just enough to bring it back. Runs in