@kitlangton/motel 0.1.2 → 0.2.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.
@@ -251,6 +251,7 @@ const WaterfallRow = memo(({
251
251
  collapsed,
252
252
  hasChildSpans,
253
253
  suffixMetrics,
254
+ dimmed,
254
255
  onSelect,
255
256
  }: {
256
257
  span: TraceSpanItem
@@ -262,6 +263,7 @@ const WaterfallRow = memo(({
262
263
  collapsed: boolean
263
264
  hasChildSpans: boolean
264
265
  suffixMetrics: WaterfallSuffixMetrics
266
+ dimmed: boolean
265
267
  onSelect: () => void
266
268
  }) => {
267
269
  const prefix = buildTreePrefix(spans, index)
@@ -282,12 +284,23 @@ const WaterfallRow = memo(({
282
284
  const rowBg = selected ? colors.selectedBg : colors.screenBg
283
285
  const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
284
286
  const bg = selected ? colors.selectedBg : undefined
285
- const treeColor = selected ? colors.separator : colors.treeLine
286
- const indicatorColor = isError ? colors.error : hasChildSpans ? (selected ? colors.selectedText : colors.muted) : colors.passing
287
- const opColor = selected ? colors.selectedText : span.isRunning ? colors.warning : colors.text
288
-
289
- const durationFg = durationColor(span.durationMs)
290
- const unitFg = colors.muted
287
+ // Dimmed rows (non-matching under an active waterfall filter) collapse
288
+ // their palette to the muted separator color so matches stand out.
289
+ // Selection always wins the selected row keeps its full brightness
290
+ // so you can still see where the cursor is while scanning.
291
+ const treeColor = selected ? colors.separator : dimmed ? colors.separator : colors.treeLine
292
+ const indicatorColor = selected ? colors.selectedText
293
+ : dimmed ? colors.separator
294
+ : isError ? colors.error
295
+ : hasChildSpans ? colors.muted
296
+ : colors.passing
297
+ const opColor = selected ? colors.selectedText
298
+ : dimmed ? colors.separator
299
+ : span.isRunning ? colors.warning
300
+ : colors.text
301
+
302
+ const durationFg = selected ? colors.selectedText : dimmed ? colors.separator : durationColor(span.durationMs)
303
+ const unitFg = dimmed && !selected ? colors.separator : colors.muted
291
304
 
292
305
  // Split the duration so the unit (s/ms) renders dimmer than the number.
293
306
  const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
@@ -373,6 +386,7 @@ export const WaterfallTimeline = ({
373
386
  bodyLines,
374
387
  selectedSpanIndex,
375
388
  collapsedSpanIds,
389
+ matchingSpanIds,
376
390
  onSelectSpan,
377
391
  }: {
378
392
  trace: TraceItem
@@ -383,6 +397,11 @@ export const WaterfallTimeline = ({
383
397
  bodyLines: number
384
398
  selectedSpanIndex: number | null
385
399
  collapsedSpanIds: ReadonlySet<string>
400
+ /**
401
+ * When set, spans whose spanId is NOT in this set are dimmed. Null
402
+ * means no filter active — skip the per-row lookup entirely.
403
+ */
404
+ matchingSpanIds?: ReadonlySet<string> | null
386
405
  onSelectSpan: (index: number) => void
387
406
  }) => {
388
407
  const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
@@ -392,29 +411,37 @@ export const WaterfallTimeline = ({
392
411
  spanIndexById.set(trace.spans[i].spanId, i)
393
412
  }
394
413
 
395
- // Virtual windowing: only render visible rows, shift window only when
396
- // the selection would go out of view (no jerkiness).
414
+ // Virtual windowing: only render visible rows. We track scroll offset
415
+ // as state so the mouse wheel can scroll the window INDEPENDENTLY of
416
+ // the selected span (mirrors TraceList behavior). Selection still
417
+ // follows: if the user moves selection off-screen via j/k, we nudge
418
+ // the window to keep it visible — but wheel-scrolling never changes
419
+ // selection, only clicking a row does.
397
420
  const viewportSize = Math.max(1, bodyLines)
398
- const scrollOffsetRef = useRef(0)
421
+ const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
422
+ const [scrollOffset, setScrollOffset] = useState(0)
399
423
  const lastTraceIdRef = useRef<string | null>(null)
400
424
 
401
- // Reset scroll offset when the trace changes
425
+ // Reset scroll offset when the trace changes.
402
426
  if (trace.traceId !== lastTraceIdRef.current) {
403
- scrollOffsetRef.current = 0
427
+ setScrollOffset(0)
404
428
  lastTraceIdRef.current = trace.traceId
405
429
  }
406
430
 
407
- // Only shift the window when the selection would be outside it
408
- if (selectedSpanIndex !== null) {
409
- if (selectedSpanIndex < scrollOffsetRef.current) {
410
- scrollOffsetRef.current = selectedSpanIndex
411
- } else if (selectedSpanIndex >= scrollOffsetRef.current + viewportSize) {
412
- scrollOffsetRef.current = selectedSpanIndex - viewportSize + 1
413
- }
414
- }
415
- scrollOffsetRef.current = Math.max(0, Math.min(scrollOffsetRef.current, Math.max(0, filteredSpans.length - viewportSize)))
416
-
417
- const windowStart = scrollOffsetRef.current
431
+ // Auto-follow selection: only if the selected span would be hidden
432
+ // by the current window, shift just enough to bring it back. Runs in
433
+ // layout effect so the visible window is accurate on the same paint
434
+ // that the selection changed.
435
+ useLayoutEffect(() => {
436
+ if (selectedSpanIndex === null) return
437
+ setScrollOffset((current) => {
438
+ if (selectedSpanIndex < current) return selectedSpanIndex
439
+ if (selectedSpanIndex >= current + viewportSize) return selectedSpanIndex - viewportSize + 1
440
+ return current
441
+ })
442
+ }, [selectedSpanIndex, viewportSize])
443
+
444
+ const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
418
445
  const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
419
446
  const blankCount = Math.max(0, viewportSize - windowSpans.length)
420
447
 
@@ -422,19 +449,17 @@ export const WaterfallTimeline = ({
422
449
  // row's duration cell lines up on the same right-edge column.
423
450
  const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
424
451
 
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.
452
+ // Mouse wheel scrolls the window without touching selection matches
453
+ // the trace list, so the user can browse ahead of their cursor freely
454
+ // and click a row to commit. Delta is scaled 1:1 with opentui's wheel
455
+ // reporting (1 notch 3 rows on most terminals).
429
456
  const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
430
457
  const info = event.scroll
431
458
  if (!info || filteredSpans.length === 0) return
432
459
  const magnitude = Math.max(1, Math.round(info.delta))
433
460
  const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
434
461
  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)
462
+ setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
438
463
  event.stopPropagation?.()
439
464
  }
440
465
 
@@ -443,6 +468,7 @@ export const WaterfallTimeline = ({
443
468
  {windowSpans.map((span, index) => {
444
469
  const actualIndex = windowStart + index
445
470
  const fullIndex = spanIndexById.get(span.spanId) ?? -1
471
+ const dimmed = matchingSpanIds != null && !matchingSpanIds.has(span.spanId)
446
472
  return (
447
473
  <WaterfallRow
448
474
  key={`${trace.traceId}-${span.spanId}`}
@@ -455,6 +481,7 @@ export const WaterfallTimeline = ({
455
481
  collapsed={collapsedSpanIds.has(span.spanId)}
456
482
  hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
457
483
  suffixMetrics={suffixMetrics}
484
+ dimmed={dimmed}
458
485
  onSelect={() => onSelectSpan(actualIndex)}
459
486
  />
460
487
  )
@@ -18,6 +18,8 @@ interface TraceWorkspaceProps {
18
18
  readonly detailView: DetailView
19
19
  readonly filterMode: boolean
20
20
  readonly filterText: string
21
+ readonly waterfallFilterMode: boolean
22
+ readonly waterfallFilterText: string
21
23
  readonly traceListProps: TraceListProps
22
24
  readonly selectedTraceService: string | null
23
25
  readonly serviceLogState: ServiceLogState
@@ -40,6 +42,8 @@ export const TraceWorkspace = ({
40
42
  detailView,
41
43
  filterMode,
42
44
  filterText,
45
+ waterfallFilterMode,
46
+ waterfallFilterText,
43
47
  traceListProps,
44
48
  selectedTraceService,
45
49
  serviceLogState,
@@ -132,7 +136,7 @@ export const TraceWorkspace = ({
132
136
  selectedSpanIndex={selectedSpanIndex}
133
137
  collapsedSpanIds={collapsedSpanIds}
134
138
  focused={false}
135
- onSelectSpan={selectSpan}
139
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
136
140
  />
137
141
  </box>
138
142
  </box>
@@ -159,7 +163,7 @@ export const TraceWorkspace = ({
159
163
  selectedSpanIndex={selectedSpanIndex}
160
164
  collapsedSpanIds={collapsedSpanIds}
161
165
  focused={true}
162
- onSelectSpan={selectSpan}
166
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
163
167
  />
164
168
  </box>
165
169
  </box>
@@ -182,7 +186,7 @@ export const TraceWorkspace = ({
182
186
  selectedSpanIndex={selectedSpanIndex}
183
187
  collapsedSpanIds={collapsedSpanIds}
184
188
  focused={false}
185
- onSelectSpan={selectSpan}
189
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
186
190
  />
187
191
  </box>
188
192
  <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
@@ -226,7 +230,7 @@ export const TraceWorkspace = ({
226
230
  selectedSpanIndex={selectedSpanIndex}
227
231
  collapsedSpanIds={collapsedSpanIds}
228
232
  focused={false}
229
- onSelectSpan={selectSpan}
233
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
230
234
  />
231
235
  </>
232
236
  )
@@ -265,7 +269,7 @@ export const TraceWorkspace = ({
265
269
  selectedSpanIndex={selectedSpanIndex}
266
270
  collapsedSpanIds={collapsedSpanIds}
267
271
  focused={true}
268
- onSelectSpan={selectSpan}
272
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
269
273
  />
270
274
  ) : (
271
275
  <SpanDetailPane
@@ -1,5 +1,5 @@
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
4
  import type { LogItem, TraceItem } from "../../domain.ts"
5
5
  import {
@@ -32,6 +32,7 @@ import {
32
32
  traceSortAtom,
33
33
  traceStateAtom,
34
34
  } from "../state.ts"
35
+ import { parseFilterText } from "../filterParser.ts"
35
36
  import { getVisibleSpans } from "../Waterfall.tsx"
36
37
 
37
38
  export const useTraceScreenData = () => {
@@ -54,6 +55,17 @@ export const useTraceScreenData = () => {
54
55
  const [activeAttrValue] = useAtom(activeAttrValueAtom)
55
56
  const [traceSort] = useAtom(traceSortAtom)
56
57
 
58
+ // `:ai <query>` is parsed out of the filter text and debounced so
59
+ // typing doesn't hammer FTS. The other modifiers (:error, operation
60
+ // needle) stay client-side since we already have those on trace
61
+ // summaries. 250ms feels responsive without firing on every keystroke.
62
+ const parsedFilter = useMemo(() => parseFilterText(filterText), [filterText])
63
+ const [debouncedAiText, setDebouncedAiText] = useState<string | null>(parsedFilter.aiText)
64
+ useEffect(() => {
65
+ const handle = setTimeout(() => setDebouncedAiText(parsedFilter.aiText), 250)
66
+ return () => clearTimeout(handle)
67
+ }, [parsedFilter.aiText])
68
+
57
69
  const selectedTraceRef = useRef<string | null>(null)
58
70
  const cacheEpochRef = useRef(0)
59
71
  const traceDetailCacheRef = useRef(new Map<string, { data: TraceItem | null; fetchedAt: Date }>())
@@ -99,9 +111,18 @@ export const useTraceScreenData = () => {
99
111
  setSelectedTraceService(effectiveService)
100
112
  }
101
113
 
114
+ // Branch on whether any server-side filter is active. `:ai`
115
+ // (debouncedAiText) and the attr picker compose; either
116
+ // alone also uses the filtered loader. Unfiltered falls
117
+ // back to the fast recent-summaries path.
118
+ const hasAttrFilter = Boolean(activeAttrKey && activeAttrValue)
119
+ const hasAiFilter = Boolean(debouncedAiText)
102
120
  const traces = effectiveService
103
- ? (activeAttrKey && activeAttrValue
104
- ? await loadFilteredTraceSummaries(effectiveService, { [activeAttrKey]: activeAttrValue })
121
+ ? (hasAttrFilter || hasAiFilter
122
+ ? await loadFilteredTraceSummaries(effectiveService, {
123
+ attributeFilters: hasAttrFilter ? { [activeAttrKey as string]: activeAttrValue as string } : undefined,
124
+ aiText: hasAiFilter ? debouncedAiText : null,
125
+ })
105
126
  : await loadRecentTraceSummaries(effectiveService))
106
127
  : []
107
128
  if (cancelled) return
@@ -126,7 +147,7 @@ export const useTraceScreenData = () => {
126
147
  return () => {
127
148
  cancelled = true
128
149
  }
129
- }, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
150
+ }, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, debouncedAiText, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
130
151
 
131
152
  useEffect(() => {
132
153
  setSelectedTraceIndex((current) => {
@@ -359,13 +380,14 @@ export const useTraceScreenData = () => {
359
380
  })
360
381
  }, [serviceLogState.data.length, setSelectedServiceLogIndex])
361
382
 
383
+ // Client-side filters: `:error` + operation-name needle both run
384
+ // against already-loaded summaries (no server round-trip). The `:ai`
385
+ // query, by contrast, is applied server-side in the load effect
386
+ // above so we don't need to re-filter it here.
362
387
  const preFilterTraces = filterText
363
388
  ? 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
389
+ if (parsedFilter.errorOnly && trace.errorCount === 0) return false
390
+ if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
369
391
  return true
370
392
  })
371
393
  : traceState.data
@@ -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
+ }
@@ -80,13 +80,25 @@ export const SeparatorColumn = ({ height, junctionChars }: { height: number; jun
80
80
  )
81
81
  }
82
82
 
83
- export const FilterBar = ({ text, width }: { text: string; width: number }) => (
84
- <TextLine fg={colors.accent}>
85
- <span fg={colors.muted}>{"/"}</span>
86
- <span fg={colors.text}>{fitCell(text, width - 2)}</span>
87
- <span fg={colors.accent}>{"\u2588"}</span>
88
- </TextLine>
89
- )
83
+ export const FilterBar = ({ text, width }: { text: string; width: number }) => {
84
+ // Layout: "/<text>█ op name · :error · :ai <query>"
85
+ // Cursor block sits immediately after the typed text (no padding —
86
+ // fitCell would pad the empty string to fill the available width and
87
+ // push the cursor to the far right, which looked like a bug). The
88
+ // hint only renders while the input is empty so real typing never
89
+ // collides with it.
90
+ const hint = text.length === 0 ? " op name · :error · :ai <query>" : ""
91
+ const textMaxWidth = Math.max(1, width - 2 - hint.length)
92
+ const displayText = text.length > textMaxWidth ? text.slice(text.length - textMaxWidth) : text
93
+ return (
94
+ <TextLine fg={colors.accent}>
95
+ <span fg={colors.muted}>{"/"}</span>
96
+ <span fg={colors.text}>{displayText}</span>
97
+ <span fg={colors.accent}>{"\u2588"}</span>
98
+ {hint ? <span fg={colors.muted}>{hint}</span> : null}
99
+ </TextLine>
100
+ )
101
+ }
90
102
 
91
103
  const FooterKey = ({ label }: { label: string }) => <span fg={colors.count} attributes={TextAttributes.BOLD}>{label}</span>
92
104
 
@@ -118,7 +130,7 @@ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: {
118
130
  {row("t", `cycle theme (${themeLabel})`)}
119
131
  {row("tab", "toggle service logs")}
120
132
  {row("[ ]", "switch service")}
121
- {row("/", "filter by root operation")}
133
+ {row("/", "filter by root operation (\u2003:error, :ai <query>)")}
122
134
  {row("f", "filter traces by span attribute")}
123
135
  {row("s", "cycle sort mode")}
124
136
  {row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
package/src/ui/state.ts CHANGED
@@ -106,6 +106,13 @@ export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
106
106
  export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
107
107
  export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
108
108
 
109
+ // Waterfall-scoped filter: the `/` key while drilled into a trace
110
+ // (viewLevel >= 1) opens this filter instead of the trace-list one.
111
+ // Purely client-side — dims spans whose operation name and attribute
112
+ // values don't contain the needle.
113
+ export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
114
+ export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
115
+
109
116
  // Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
110
117
  export type AttrPickerMode = "off" | "keys" | "values"
111
118
  export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
@@ -158,8 +165,31 @@ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<s
158
165
 
159
166
  export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
160
167
  export const loadRecentTraceSummaries = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
161
- export const loadFilteredTraceSummaries = (serviceName: string, attributeFilters: Readonly<Record<string, string>>) =>
162
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({ serviceName, attributeFilters, limit: config.otel.traceFetchLimit })))
168
+ /**
169
+ * Server-side trace summary search. Accepts any combination of:
170
+ *
171
+ * - `attributeFilters` — exact-match span attributes (from the `f` picker)
172
+ * - `aiText` — FTS5-backed search across LLM prompt/response
173
+ * content (AI_FTS_KEYS), from the `:ai <query>`
174
+ * modifier in the `/` filter
175
+ *
176
+ * Both filters compose: when both are set, a trace must match both. When
177
+ * neither is set, callers should prefer `loadRecentTraceSummaries` so
178
+ * the server can skip the search path entirely.
179
+ */
180
+ export const loadFilteredTraceSummaries = (
181
+ serviceName: string,
182
+ options: {
183
+ readonly attributeFilters?: Readonly<Record<string, string>>
184
+ readonly aiText?: string | null
185
+ },
186
+ ) =>
187
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
188
+ serviceName,
189
+ attributeFilters: options.attributeFilters,
190
+ aiText: options.aiText ?? null,
191
+ limit: config.otel.traceFetchLimit,
192
+ })))
163
193
  export const loadTraceAttributeKeys = (serviceName: string) =>
164
194
  queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
165
195
  export const loadTraceAttributeValues = (serviceName: string, key: string) =>