@kitlangton/motel 0.1.3 → 0.2.1

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 (55) hide show
  1. package/AGENTS.md +11 -1
  2. package/package.json +5 -3
  3. package/src/App.tsx +239 -59
  4. package/src/daemon.test.ts +144 -7
  5. package/src/daemon.ts +113 -8
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +62 -4
  8. package/src/httpApi.ts +4 -1
  9. package/src/localServer.ts +112 -121
  10. package/src/mcp.ts +172 -0
  11. package/src/motelClient.ts +166 -14
  12. package/src/registry.ts +26 -23
  13. package/src/runtime.ts +8 -2
  14. package/src/server.ts +10 -9
  15. package/src/services/AsyncIngest.ts +52 -0
  16. package/src/services/TelemetryStore.ts +285 -27
  17. package/src/services/TraceQueryService.ts +4 -2
  18. package/src/services/ingestRpc.ts +41 -0
  19. package/src/services/telemetryWorker.ts +62 -0
  20. package/src/storybook/aiChatStory.tsx +243 -0
  21. package/src/storybook/fixtures/errorState.ts +44 -0
  22. package/src/storybook/fixtures/imagePaste.ts +34 -0
  23. package/src/storybook/fixtures/index.ts +62 -0
  24. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  25. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  26. package/src/storybook/fixtures/short.ts +27 -0
  27. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  28. package/src/telemetry.test.ts +61 -0
  29. package/src/ui/AiChatView.tsx +292 -0
  30. package/src/ui/SpanContentView.tsx +181 -0
  31. package/src/ui/SpanDetail.tsx +98 -17
  32. package/src/ui/TraceDetailsPane.tsx +35 -3
  33. package/src/ui/Waterfall.tsx +94 -167
  34. package/src/ui/aiChatModel.test.ts +347 -0
  35. package/src/ui/aiChatModel.ts +736 -0
  36. package/src/ui/aiState.ts +71 -0
  37. package/src/ui/app/TraceWorkspace.tsx +295 -120
  38. package/src/ui/app/useAppLayout.ts +14 -11
  39. package/src/ui/app/useTraceScreenData.ts +191 -35
  40. package/src/ui/atoms.ts +131 -0
  41. package/src/ui/filterParser.test.ts +56 -0
  42. package/src/ui/filterParser.ts +45 -0
  43. package/src/ui/loaders.ts +120 -0
  44. package/src/ui/persistence.ts +41 -0
  45. package/src/ui/primitives.tsx +47 -21
  46. package/src/ui/state.ts +4 -169
  47. package/src/ui/useAttrFilterPicker.ts +63 -23
  48. package/src/ui/useKeyboardNav.ts +576 -300
  49. package/src/ui/waterfallFilter.test.ts +84 -0
  50. package/src/ui/waterfallFilter.ts +59 -0
  51. package/src/ui/waterfallModel.ts +130 -0
  52. package/src/ui/waterfallNav.test.ts +17 -1
  53. package/src/ui/waterfallNav.ts +1 -1
  54. package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
  55. package/web/dist/index.html +1 -1
@@ -1,18 +1,27 @@
1
1
  import { useAtom } from "@effect/atom-react"
2
- import { useCallback, useEffect, useMemo, useRef } from "react"
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
3
  import { config } from "../../config.js"
4
- import type { LogItem, TraceItem } from "../../domain.ts"
4
+ import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
5
5
  import {
6
6
  activeAttrKeyAtom,
7
7
  activeAttrValueAtom,
8
+ aiCallDetailStateAtom,
8
9
  autoRefreshAtom,
10
+ chatDetailChunkIdAtom,
11
+ chatDetailScrollOffsetAtom,
9
12
  collapsedSpanIdsAtom,
10
13
  detailViewAtom,
14
+ ensureAiCallDetail,
15
+ ensureTraceAttributeKeys,
11
16
  filterModeAtom,
12
17
  filterTextAtom,
18
+ getCachedAiCallDetail,
19
+ initialAiCallDetailState,
13
20
  initialLogState,
14
21
  initialServiceLogState,
15
22
  initialTraceDetailState,
23
+ invalidateAiCallDetailCache,
24
+ invalidateFacetCaches,
16
25
  loadFilteredTraceSummaries,
17
26
  loadRecentTraceSummaries,
18
27
  loadServiceLogs,
@@ -22,6 +31,8 @@ import {
22
31
  logStateAtom,
23
32
  persistSelectedService,
24
33
  refreshNonceAtom,
34
+ selectedAttrIndexAtom,
35
+ selectedChatChunkIdAtom,
25
36
  selectedServiceLogIndexAtom,
26
37
  selectedSpanIndexAtom,
27
38
  selectedTraceIndexAtom,
@@ -29,11 +40,71 @@ import {
29
40
  serviceLogStateAtom,
30
41
  showHelpAtom,
31
42
  traceDetailStateAtom,
43
+ type TraceSortMode,
32
44
  traceSortAtom,
33
45
  traceStateAtom,
34
46
  } from "../state.ts"
47
+ import { isAiSpan } from "../../domain.ts"
48
+ import { buildChunks, type Chunk } from "../aiChatModel.ts"
49
+ import { parseFilterText } from "../filterParser.ts"
35
50
  import { getVisibleSpans } from "../Waterfall.tsx"
36
51
 
52
+ const clampSelectionIndex = (index: number, length: number) => {
53
+ if (length === 0) return 0
54
+ return Math.max(0, Math.min(index, length - 1))
55
+ }
56
+
57
+ const resolveEffectiveService = (
58
+ services: readonly string[],
59
+ selectedTraceService: string | null,
60
+ ) => services.includes(selectedTraceService ?? "")
61
+ ? selectedTraceService
62
+ : selectedTraceService ?? services[0] ?? config.otel.serviceName
63
+
64
+ const loadTraceSummariesForService = (
65
+ serviceName: string | null,
66
+ filters: {
67
+ readonly activeAttrKey: string | null
68
+ readonly activeAttrValue: string | null
69
+ readonly debouncedAiText: string | null
70
+ },
71
+ ) => {
72
+ if (!serviceName) return Promise.resolve([] as readonly TraceSummaryItem[])
73
+ const hasAttrFilter = Boolean(filters.activeAttrKey && filters.activeAttrValue)
74
+ const hasAiFilter = Boolean(filters.debouncedAiText)
75
+ if (!hasAttrFilter && !hasAiFilter) return loadRecentTraceSummaries(serviceName)
76
+ return loadFilteredTraceSummaries(serviceName, {
77
+ attributeFilters: hasAttrFilter ? { [filters.activeAttrKey as string]: filters.activeAttrValue as string } : undefined,
78
+ aiText: hasAiFilter ? filters.debouncedAiText : null,
79
+ })
80
+ }
81
+
82
+ const applyClientTraceFilters = (
83
+ traces: readonly TraceSummaryItem[],
84
+ filterText: string,
85
+ parsedFilter: ReturnType<typeof parseFilterText>,
86
+ ) => filterText
87
+ ? traces.filter((trace) => {
88
+ if (parsedFilter.errorOnly && trace.errorCount === 0) return false
89
+ if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
90
+ return true
91
+ })
92
+ : traces
93
+
94
+ const sortTraceSummaries = (traces: readonly TraceSummaryItem[], traceSort: TraceSortMode) => {
95
+ if (traceSort === "recent") return traces
96
+ return [...traces].sort((a, b) => {
97
+ if (traceSort === "slowest") return b.durationMs - a.durationMs
98
+ if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
99
+ return 0
100
+ })
101
+ }
102
+
103
+ const getSelectedVisibleSpan = (
104
+ spans: readonly TraceItem["spans"][number][],
105
+ selectedSpanIndex: number | null,
106
+ ) => selectedSpanIndex === null ? null : spans[selectedSpanIndex] ?? null
107
+
37
108
  export const useTraceScreenData = () => {
38
109
  const [traceState, setTraceState] = useAtom(traceStateAtom)
39
110
  const [traceDetailState, setTraceDetailState] = useAtom(traceDetailStateAtom)
@@ -44,6 +115,11 @@ export const useTraceScreenData = () => {
44
115
  const [selectedTraceService, setSelectedTraceService] = useAtom(selectedTraceServiceAtom)
45
116
  const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
46
117
  const [selectedSpanIndex, setSelectedSpanIndex] = useAtom(selectedSpanIndexAtom)
118
+ const [, setSelectedAttrIndex] = useAtom(selectedAttrIndexAtom)
119
+ const [, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
120
+ const [, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
121
+ const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
122
+ const [aiCallDetailState, setAiCallDetailState] = useAtom(aiCallDetailStateAtom)
47
123
  const [detailView, setDetailView] = useAtom(detailViewAtom)
48
124
  const [showHelp, setShowHelp] = useAtom(showHelpAtom)
49
125
  const [collapsedSpanIds, setCollapsedSpanIds] = useAtom(collapsedSpanIdsAtom)
@@ -54,6 +130,17 @@ export const useTraceScreenData = () => {
54
130
  const [activeAttrValue] = useAtom(activeAttrValueAtom)
55
131
  const [traceSort] = useAtom(traceSortAtom)
56
132
 
133
+ // `:ai <query>` is parsed out of the filter text and debounced so
134
+ // typing doesn't hammer FTS. The other modifiers (:error, operation
135
+ // needle) stay client-side since we already have those on trace
136
+ // summaries. 250ms feels responsive without firing on every keystroke.
137
+ const parsedFilter = useMemo(() => parseFilterText(filterText), [filterText])
138
+ const [debouncedAiText, setDebouncedAiText] = useState<string | null>(parsedFilter.aiText)
139
+ useEffect(() => {
140
+ const handle = setTimeout(() => setDebouncedAiText(parsedFilter.aiText), 250)
141
+ return () => clearTimeout(handle)
142
+ }, [parsedFilter.aiText])
143
+
57
144
  const selectedTraceRef = useRef<string | null>(null)
58
145
  const cacheEpochRef = useRef(0)
59
146
  const traceDetailCacheRef = useRef(new Map<string, { data: TraceItem | null; fetchedAt: Date }>())
@@ -79,8 +166,18 @@ export const useTraceScreenData = () => {
79
166
  serviceLogCacheRef.current.clear()
80
167
  traceDetailInflightRef.current.clear()
81
168
  traceLogInflightRef.current.clear()
169
+ invalidateFacetCaches()
170
+ invalidateAiCallDetailCache()
82
171
  }, [refreshNonce])
83
172
 
173
+ // Pre-warm the attribute picker facet keys for the currently-selected
174
+ // service so pressing `f` feels instant. Fire-and-forget; errors are
175
+ // surfaced when the user actually opens the picker.
176
+ useEffect(() => {
177
+ if (!selectedTraceService) return
178
+ void ensureTraceAttributeKeys(selectedTraceService).catch(() => {})
179
+ }, [selectedTraceService, refreshNonce])
180
+
84
181
  useEffect(() => {
85
182
  let cancelled = false
86
183
 
@@ -91,19 +188,17 @@ export const useTraceScreenData = () => {
91
188
  const services = await loadTraceServices()
92
189
  if (cancelled) return
93
190
 
94
- const effectiveService = services.includes(selectedTraceService ?? "")
95
- ? selectedTraceService
96
- : selectedTraceService ?? services[0] ?? config.otel.serviceName
191
+ const effectiveService = resolveEffectiveService(services, selectedTraceService)
97
192
 
98
193
  if (effectiveService !== selectedTraceService) {
99
194
  setSelectedTraceService(effectiveService)
100
195
  }
101
196
 
102
- const traces = effectiveService
103
- ? (activeAttrKey && activeAttrValue
104
- ? await loadFilteredTraceSummaries(effectiveService, { [activeAttrKey]: activeAttrValue })
105
- : await loadRecentTraceSummaries(effectiveService))
106
- : []
197
+ const traces = await loadTraceSummariesForService(effectiveService, {
198
+ activeAttrKey,
199
+ activeAttrValue,
200
+ debouncedAiText,
201
+ })
107
202
  if (cancelled) return
108
203
 
109
204
  const prevTraceId = selectedTraceRef.current
@@ -126,18 +221,22 @@ export const useTraceScreenData = () => {
126
221
  return () => {
127
222
  cancelled = true
128
223
  }
129
- }, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
224
+ }, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, debouncedAiText, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
130
225
 
131
226
  useEffect(() => {
132
227
  setSelectedTraceIndex((current) => {
133
- if (traceState.data.length === 0) return 0
134
- return Math.max(0, Math.min(current, traceState.data.length - 1))
228
+ return clampSelectionIndex(current, traceState.data.length)
135
229
  })
136
230
  }, [traceState.data.length, setSelectedTraceIndex])
137
231
 
138
232
  const selectedTraceSummary = traceState.data[selectedTraceIndex] ?? null
139
233
  const selectedTraceId = selectedTraceSummary?.traceId ?? null
140
234
  const selectedTrace = traceDetailState.traceId === selectedTraceId ? traceDetailState.data : null
235
+ const selectedVisibleSpans = useMemo(
236
+ () => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
237
+ [selectedTrace, collapsedSpanIds],
238
+ )
239
+ const selectedVisibleSpan = getSelectedVisibleSpan(selectedVisibleSpans, selectedSpanIndex)
141
240
  selectedTraceRef.current = selectedTraceId
142
241
 
143
242
  const warmTraceDetail = useCallback((traceId: string, hydrateSelection: boolean) => {
@@ -274,6 +373,72 @@ export const useTraceScreenData = () => {
274
373
  setSelectedSpanIndex(null)
275
374
  }, [selectedTraceId, setCollapsedSpanIds, setSelectedSpanIndex])
276
375
 
376
+ // Reset the attribute cursor whenever the span selection moves. Without
377
+ // this, drilling from span A (with 34 tags) to span B (with 3 tags)
378
+ // would leave the cursor pointing past the end of B's tag list until
379
+ // the user hit `j`/`k` again.
380
+ useEffect(() => {
381
+ setSelectedAttrIndex(0)
382
+ setChatDetailChunkId(null)
383
+ setChatDetailScrollOffset(0)
384
+ // New span → drop chunk selection and any open detail modal.
385
+ // The effect below will re-select the first chunk once the
386
+ // detail loads.
387
+ setSelectedChatChunkId(null)
388
+ }, [selectedSpanIndex, selectedTraceId, setSelectedAttrIndex, setChatDetailChunkId, setChatDetailScrollOffset, setSelectedChatChunkId])
389
+
390
+ // Load the parsed AI call detail for the currently-selected span when
391
+ // it's an AI span and the user is drilled into L2. Cached module-level
392
+ // so re-entering the chat view for a span we already loaded is free.
393
+ const selectedSpanId = selectedVisibleSpan?.spanId ?? null
394
+ const shouldLoadAiDetail = detailView === "span-detail" && selectedVisibleSpan !== null && isAiSpan(selectedVisibleSpan.tags)
395
+
396
+ useEffect(() => {
397
+ if (!shouldLoadAiDetail || !selectedSpanId) {
398
+ setAiCallDetailState(initialAiCallDetailState)
399
+ return
400
+ }
401
+ const cached = getCachedAiCallDetail(selectedSpanId)
402
+ if (cached !== undefined) {
403
+ setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data: cached, error: null })
404
+ return
405
+ }
406
+ setAiCallDetailState({ status: "loading", spanId: selectedSpanId, data: null, error: null })
407
+ let cancelled = false
408
+ ensureAiCallDetail(selectedSpanId)
409
+ .then((data) => {
410
+ if (cancelled) return
411
+ setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data, error: null })
412
+ })
413
+ .catch((err) => {
414
+ if (cancelled) return
415
+ setAiCallDetailState({
416
+ status: "error",
417
+ spanId: selectedSpanId,
418
+ data: null,
419
+ error: err instanceof Error ? err.message : String(err),
420
+ })
421
+ })
422
+ return () => { cancelled = true }
423
+ }, [shouldLoadAiDetail, selectedSpanId, setAiCallDetailState])
424
+
425
+ // Chunk model — rebuilt whenever the detail payload changes.
426
+ // Width-independent, so this lives here instead of in the view.
427
+ const aiChatChunks = useMemo<readonly Chunk[]>(() => {
428
+ if (!shouldLoadAiDetail || !aiCallDetailState.data) return []
429
+ return buildChunks(aiCallDetailState.data)
430
+ }, [shouldLoadAiDetail, aiCallDetailState.data])
431
+
432
+ // Once chunks are available, pin selection to the first chunk unless
433
+ // the user has already chosen one. Also handles the "chunk list
434
+ // changed and the previous selection disappeared" case.
435
+ useEffect(() => {
436
+ if (aiChatChunks.length === 0) return
437
+ const stillValid = selectedChatChunkId !== null
438
+ && aiChatChunks.some((c) => c.id === selectedChatChunkId)
439
+ if (!stillValid) setSelectedChatChunkId(aiChatChunks[0]!.id)
440
+ }, [aiChatChunks, selectedChatChunkId, setSelectedChatChunkId])
441
+
277
442
  useEffect(() => {
278
443
  if (selectedSpanIndex === null) return
279
444
  if (!selectedTrace || selectedTrace.spans.length === 0) {
@@ -281,11 +446,11 @@ export const useTraceScreenData = () => {
281
446
  setDetailView("waterfall")
282
447
  return
283
448
  }
284
- const visibleCount = getVisibleSpans(selectedTrace.spans, collapsedSpanIds).length
449
+ const visibleCount = selectedVisibleSpans.length
285
450
  if (selectedSpanIndex >= visibleCount) {
286
451
  setSelectedSpanIndex(visibleCount - 1)
287
452
  }
288
- }, [selectedTrace, selectedSpanIndex, collapsedSpanIds, setDetailView, setSelectedSpanIndex])
453
+ }, [selectedTrace, selectedSpanIndex, selectedVisibleSpans.length, setDetailView, setSelectedSpanIndex])
289
454
 
290
455
  useEffect(() => {
291
456
  const traceId = selectedTraceId
@@ -354,29 +519,18 @@ export const useTraceScreenData = () => {
354
519
 
355
520
  useEffect(() => {
356
521
  setSelectedServiceLogIndex((current) => {
357
- if (serviceLogState.data.length === 0) return 0
358
- return Math.max(0, Math.min(current, serviceLogState.data.length - 1))
522
+ return clampSelectionIndex(current, serviceLogState.data.length)
359
523
  })
360
524
  }, [serviceLogState.data.length, setSelectedServiceLogIndex])
361
525
 
362
- const preFilterTraces = filterText
363
- ? traceState.data.filter((trace) => {
364
- const needle = filterText.toLowerCase()
365
- const errorOnly = needle.includes(":error")
366
- const textNeedle = needle.replace(":error", "").trim()
367
- if (errorOnly && trace.errorCount === 0) return false
368
- if (textNeedle && !trace.rootOperationName.toLowerCase().includes(textNeedle)) return false
369
- return true
370
- })
371
- : traceState.data
372
-
373
- const filteredTraces = traceSort === "recent"
374
- ? preFilterTraces
375
- : [...preFilterTraces].sort((a, b) => {
376
- if (traceSort === "slowest") return b.durationMs - a.durationMs
377
- if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
378
- return 0
379
- })
526
+ // Client-side filters: `:error` + operation-name needle both run
527
+ // against already-loaded summaries (no server round-trip). The `:ai`
528
+ // query, by contrast, is applied server-side in the load effect
529
+ // above so we don't need to re-filter it here.
530
+ const filteredTraces = useMemo(() => {
531
+ const preFiltered = applyClientTraceFilters(traceState.data, filterText, parsedFilter)
532
+ return sortTraceSummaries(preFiltered, traceSort)
533
+ }, [filterText, parsedFilter, traceSort, traceState.data])
380
534
 
381
535
  useEffect(() => {
382
536
  if (!selectedTraceId || filteredTraces.length === 0) return
@@ -418,5 +572,7 @@ export const useTraceScreenData = () => {
418
572
  selectedTrace,
419
573
  selectedTraceId,
420
574
  filteredTraces,
575
+ aiCallDetailState,
576
+ aiChatChunks,
421
577
  } as const
422
578
  }
@@ -0,0 +1,131 @@
1
+ import * as Atom from "effect/unstable/reactivity/Atom"
2
+ import { config } from "../config.ts"
3
+ import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
4
+ import type { ThemeName } from "./theme.ts"
5
+ import { readLastService, readLastTheme } from "./persistence.ts"
6
+
7
+ export type LoadStatus = "loading" | "ready" | "error"
8
+ export type DetailView = "waterfall" | "span-detail" | "service-logs"
9
+
10
+ export interface TraceState {
11
+ readonly status: LoadStatus
12
+ readonly services: readonly string[]
13
+ readonly data: readonly TraceSummaryItem[]
14
+ readonly error: string | null
15
+ readonly fetchedAt: Date | null
16
+ }
17
+
18
+ export interface TraceDetailState {
19
+ readonly status: LoadStatus
20
+ readonly traceId: string | null
21
+ readonly data: TraceItem | null
22
+ readonly error: string | null
23
+ readonly fetchedAt: Date | null
24
+ }
25
+
26
+ export interface LogState {
27
+ readonly status: LoadStatus
28
+ readonly traceId: string | null
29
+ readonly data: readonly LogItem[]
30
+ readonly error: string | null
31
+ readonly fetchedAt: Date | null
32
+ }
33
+
34
+ export interface ServiceLogState {
35
+ readonly status: LoadStatus
36
+ readonly serviceName: string | null
37
+ readonly data: readonly LogItem[]
38
+ readonly error: string | null
39
+ readonly fetchedAt: Date | null
40
+ }
41
+
42
+ export const initialTraceState: TraceState = {
43
+ status: "loading",
44
+ services: [],
45
+ data: [],
46
+ error: null,
47
+ fetchedAt: null,
48
+ }
49
+
50
+ export const initialLogState: LogState = {
51
+ status: "ready",
52
+ traceId: null,
53
+ data: [],
54
+ error: null,
55
+ fetchedAt: null,
56
+ }
57
+
58
+ export const initialTraceDetailState: TraceDetailState = {
59
+ status: "ready",
60
+ traceId: null,
61
+ data: null,
62
+ error: null,
63
+ fetchedAt: null,
64
+ }
65
+
66
+ export const initialServiceLogState: ServiceLogState = {
67
+ status: "ready",
68
+ serviceName: null,
69
+ data: [],
70
+ error: null,
71
+ fetchedAt: null,
72
+ }
73
+
74
+ export const traceStateAtom = Atom.make(initialTraceState).pipe(Atom.keepAlive)
75
+ export const traceDetailStateAtom = Atom.make(initialTraceDetailState).pipe(Atom.keepAlive)
76
+ export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
77
+ export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
78
+ export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
79
+ export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
80
+ export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
81
+ export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
82
+ export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
83
+ export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
84
+ // Cursor inside the full-screen span content view (detailView === "span-detail").
85
+ // Tracks which span tag is currently selected for copy / drill-in. Reset to 0
86
+ // on each new span so the cursor doesn't point past a shorter tag list.
87
+ export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
88
+ export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
89
+ export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
90
+ export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
91
+ export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
92
+ export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
93
+
94
+ // Waterfall-scoped filter: the `/` key while drilled into a trace
95
+ // (viewLevel >= 1) opens this filter instead of the trace-list one.
96
+ // Purely client-side — dims spans whose operation name and attribute
97
+ // values don't contain the needle.
98
+ export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
99
+ export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
100
+
101
+ // Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
102
+ export type AttrPickerMode = "off" | "keys" | "values"
103
+ export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
104
+ export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
105
+ export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
106
+
107
+ export interface AttrFacetState {
108
+ readonly status: LoadStatus
109
+ readonly key: string | null
110
+ readonly data: readonly { readonly value: string; readonly count: number }[]
111
+ readonly error: string | null
112
+ }
113
+
114
+ export const initialAttrFacetState: AttrFacetState = {
115
+ status: "ready",
116
+ key: null,
117
+ data: [],
118
+ error: null,
119
+ }
120
+
121
+ export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
122
+
123
+ // Applied filter (drives trace list query)
124
+ export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
125
+ export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
126
+
127
+ export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
128
+
129
+ export type TraceSortMode = "recent" | "slowest" | "errors"
130
+ export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
131
+ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { parseFilterText } from "./filterParser.ts"
3
+
4
+ describe("parseFilterText", () => {
5
+ it("returns empty state for empty input", () => {
6
+ expect(parseFilterText("")).toEqual({ aiText: null, errorOnly: false, operationNeedle: "" })
7
+ })
8
+
9
+ it("extracts a bare operation-name needle", () => {
10
+ expect(parseFilterText("streamText")).toEqual({
11
+ aiText: null, errorOnly: false, operationNeedle: "streamtext",
12
+ })
13
+ })
14
+
15
+ it("recognizes :error modifier", () => {
16
+ expect(parseFilterText(":error")).toEqual({
17
+ aiText: null, errorOnly: true, operationNeedle: "",
18
+ })
19
+ })
20
+
21
+ it("composes :error with an operation needle", () => {
22
+ expect(parseFilterText("llm :error")).toEqual({
23
+ aiText: null, errorOnly: true, operationNeedle: "llm",
24
+ })
25
+ })
26
+
27
+ it("extracts :ai query up to end of string", () => {
28
+ expect(parseFilterText(":ai rate limit")).toEqual({
29
+ aiText: "rate limit", errorOnly: false, operationNeedle: "",
30
+ })
31
+ })
32
+
33
+ it("extracts :ai query stopping at the next modifier", () => {
34
+ expect(parseFilterText(":ai tool_use :error")).toEqual({
35
+ aiText: "tool_use", errorOnly: true, operationNeedle: "",
36
+ })
37
+ })
38
+
39
+ it("composes operation needle + :ai + :error", () => {
40
+ expect(parseFilterText("stream :ai rate :error")).toEqual({
41
+ aiText: "rate", errorOnly: true, operationNeedle: "stream",
42
+ })
43
+ })
44
+
45
+ it("ignores :ai with empty query", () => {
46
+ expect(parseFilterText(":ai ")).toEqual({
47
+ aiText: null, errorOnly: false, operationNeedle: "",
48
+ })
49
+ })
50
+
51
+ it("is case-insensitive for modifiers", () => {
52
+ expect(parseFilterText(":AI foo :ERROR")).toEqual({
53
+ aiText: "foo", errorOnly: true, operationNeedle: "",
54
+ })
55
+ })
56
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Parses the `/` filter input in the trace list into its component
3
+ * modifiers. Supports composable tokens:
4
+ *
5
+ * - `:error` — restricts to traces with at least one failed span
6
+ * - `:ai <query...>` — FTS-backed search against LLM prompt/response
7
+ * content (AI_FTS_KEYS) across every span in the
8
+ * trace. The query runs up to the next `:modifier`
9
+ * or end of input, so `"/ :ai rate limit :error"`
10
+ * passes `"rate limit"` as the aiText and also
11
+ * sets errorOnly.
12
+ * - bare text — case-insensitive substring match against the
13
+ * trace's root operation name (client-side)
14
+ *
15
+ * Keep this a pure function: it's unit-tested separately and the React
16
+ * hook just calls it per render.
17
+ */
18
+ export interface ParsedFilter {
19
+ readonly aiText: string | null
20
+ readonly errorOnly: boolean
21
+ readonly operationNeedle: string
22
+ }
23
+
24
+ export const parseFilterText = (raw: string): ParsedFilter => {
25
+ const text = raw ?? ""
26
+
27
+ // `:ai <query>` — greedy up to the next `:` modifier or end. The
28
+ // leading `(^|\s)` guard prevents matching a stray `:ai` glued to
29
+ // non-space characters (shouldn't happen in practice but cheap).
30
+ const aiMatch = text.match(/(?:^|\s):ai\s+([^:]*?)(?=\s:|$)/i)
31
+ const aiText = aiMatch?.[1]?.trim() || null
32
+
33
+ const errorOnly = /(?:^|\s):error(?=\s|$)/i.test(text)
34
+
35
+ // Whatever remains after removing the recognized modifiers becomes the
36
+ // operation-name needle. Lowercased here so the call site can do a
37
+ // plain includes() without re-lowercasing.
38
+ const operationNeedle = text
39
+ .replace(/(?:^|\s):ai\s+[^:]*?(?=\s:|$)/i, " ")
40
+ .replace(/(?:^|\s):error(?=\s|$)/i, " ")
41
+ .trim()
42
+ .toLowerCase()
43
+
44
+ return { aiText, errorOnly, operationNeedle }
45
+ }