@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,590 @@
1
+ import { useAtom } from "@effect/atom-react"
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
+ import { config } from "../../config.js"
4
+ import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
5
+ import {
6
+ activeAttrKeyAtom,
7
+ activeAttrValueAtom,
8
+ autoRefreshAtom,
9
+ collapsedSpanIdsAtom,
10
+ detailViewAtom,
11
+ filterModeAtom,
12
+ filterTextAtom,
13
+ initialLogState,
14
+ initialServiceLogState,
15
+ initialTraceDetailState,
16
+ logStateAtom,
17
+ refreshNonceAtom,
18
+ selectedAttrIndexAtom,
19
+ selectedServiceLogIndexAtom,
20
+ selectedSpanIndexAtom,
21
+ selectedTraceIndexAtom,
22
+ selectedTraceServiceAtom,
23
+ serviceLogStateAtom,
24
+ showHelpAtom,
25
+ traceDetailStateAtom,
26
+ type TraceSortMode,
27
+ traceSortAtom,
28
+ traceStateAtom,
29
+ } from "../atoms.ts"
30
+ import {
31
+ aiCallDetailStateAtom,
32
+ chatDetailChunkIdAtom,
33
+ chatDetailScrollOffsetAtom,
34
+ ensureAiCallDetail,
35
+ getCachedAiCallDetail,
36
+ initialAiCallDetailState,
37
+ invalidateAiCallDetailCache,
38
+ selectedChatChunkIdAtom,
39
+ } from "../aiState.ts"
40
+ import {
41
+ ensureTraceAttributeKeys,
42
+ ensureTraceAttributeValues,
43
+ loadFilteredTraceSummaries,
44
+ loadRecentTraceSummaries,
45
+ loadServiceLogs,
46
+ loadTraceDetail,
47
+ loadTraceLogs,
48
+ loadTraceServices,
49
+ } from "../loaders.ts"
50
+ import { persistSelectedService } from "../persistence.ts"
51
+ import { isAiSpan } from "../../domain.ts"
52
+ import { buildChunks, type Chunk } from "../aiChatModel.ts"
53
+ import { parseFilterText } from "../filterParser.ts"
54
+ import { getVisibleSpans } from "../waterfallModel.ts"
55
+
56
+ const clampSelectionIndex = (index: number, length: number) => {
57
+ if (length === 0) return 0
58
+ return Math.max(0, Math.min(index, length - 1))
59
+ }
60
+
61
+ const resolveEffectiveService = (
62
+ services: readonly string[],
63
+ selectedTraceService: string | null,
64
+ ) => services.includes(selectedTraceService ?? "")
65
+ ? selectedTraceService
66
+ : selectedTraceService ?? services[0] ?? config.otel.serviceName
67
+
68
+ const loadTraceSummariesForService = (
69
+ serviceName: string | null,
70
+ filters: {
71
+ readonly activeAttrKey: string | null
72
+ readonly activeAttrValue: string | null
73
+ readonly debouncedAiText: string | null
74
+ },
75
+ ) => {
76
+ if (!serviceName) return Promise.resolve([] as readonly TraceSummaryItem[])
77
+ const hasAttrFilter = Boolean(filters.activeAttrKey && filters.activeAttrValue)
78
+ const hasAiFilter = Boolean(filters.debouncedAiText)
79
+ if (!hasAttrFilter && !hasAiFilter) return loadRecentTraceSummaries(serviceName)
80
+ return loadFilteredTraceSummaries(serviceName, {
81
+ attributeFilters: hasAttrFilter ? { [filters.activeAttrKey as string]: filters.activeAttrValue as string } : undefined,
82
+ aiText: hasAiFilter ? filters.debouncedAiText : null,
83
+ })
84
+ }
85
+
86
+ const applyClientTraceFilters = (
87
+ traces: readonly TraceSummaryItem[],
88
+ filterText: string,
89
+ parsedFilter: ReturnType<typeof parseFilterText>,
90
+ ) => filterText
91
+ ? traces.filter((trace) => {
92
+ if (parsedFilter.errorOnly && trace.errorCount === 0) return false
93
+ if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
94
+ return true
95
+ })
96
+ : traces
97
+
98
+ const sortTraceSummaries = (traces: readonly TraceSummaryItem[], traceSort: TraceSortMode) => {
99
+ if (traceSort === "recent") return traces
100
+ return [...traces].sort((a, b) => {
101
+ if (traceSort === "slowest") return b.durationMs - a.durationMs
102
+ if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
103
+ return 0
104
+ })
105
+ }
106
+
107
+ const getSelectedVisibleSpan = (
108
+ spans: readonly TraceItem["spans"][number][],
109
+ selectedSpanIndex: number | null,
110
+ ) => selectedSpanIndex === null ? null : spans[selectedSpanIndex] ?? null
111
+
112
+ export const useTraceScreenData = () => {
113
+ const [traceState, setTraceState] = useAtom(traceStateAtom)
114
+ const [traceDetailState, setTraceDetailState] = useAtom(traceDetailStateAtom)
115
+ const [logState, setLogState] = useAtom(logStateAtom)
116
+ const [serviceLogState, setServiceLogState] = useAtom(serviceLogStateAtom)
117
+ const [selectedServiceLogIndex, setSelectedServiceLogIndex] = useAtom(selectedServiceLogIndexAtom)
118
+ const [selectedTraceIndex, setSelectedTraceIndex] = useAtom(selectedTraceIndexAtom)
119
+ const [selectedTraceService, setSelectedTraceService] = useAtom(selectedTraceServiceAtom)
120
+ const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
121
+ const [selectedSpanIndex, setSelectedSpanIndex] = useAtom(selectedSpanIndexAtom)
122
+ const [, setSelectedAttrIndex] = useAtom(selectedAttrIndexAtom)
123
+ const [, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
124
+ const [, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
125
+ const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
126
+ const [aiCallDetailState, setAiCallDetailState] = useAtom(aiCallDetailStateAtom)
127
+ const [detailView, setDetailView] = useAtom(detailViewAtom)
128
+ const [showHelp, setShowHelp] = useAtom(showHelpAtom)
129
+ const [collapsedSpanIds, setCollapsedSpanIds] = useAtom(collapsedSpanIdsAtom)
130
+ const [autoRefresh] = useAtom(autoRefreshAtom)
131
+ const [filterMode] = useAtom(filterModeAtom)
132
+ const [filterText] = useAtom(filterTextAtom)
133
+ const [activeAttrKey] = useAtom(activeAttrKeyAtom)
134
+ const [activeAttrValue] = useAtom(activeAttrValueAtom)
135
+ const [traceSort] = useAtom(traceSortAtom)
136
+
137
+ // `:ai <query>` is parsed out of the filter text and debounced so
138
+ // typing doesn't hammer FTS. The other modifiers (:error, operation
139
+ // needle) stay client-side since we already have those on trace
140
+ // summaries. 250ms feels responsive without firing on every keystroke.
141
+ const parsedFilter = useMemo(() => parseFilterText(filterText), [filterText])
142
+ const [debouncedAiText, setDebouncedAiText] = useState<string | null>(parsedFilter.aiText)
143
+ useEffect(() => {
144
+ const handle = setTimeout(() => setDebouncedAiText(parsedFilter.aiText), 250)
145
+ return () => clearTimeout(handle)
146
+ }, [parsedFilter.aiText])
147
+
148
+ const selectedTraceRef = useRef<string | null>(null)
149
+ const cacheEpochRef = useRef(0)
150
+ const traceDetailCacheRef = useRef(new Map<string, { data: TraceItem | null; fetchedAt: Date }>())
151
+ const traceLogCacheRef = useRef(new Map<string, { data: readonly LogItem[]; fetchedAt: Date }>())
152
+ const serviceLogCacheRef = useRef(new Map<string, { data: readonly LogItem[]; fetchedAt: Date }>())
153
+ const traceDetailInflightRef = useRef(new Map<string, Promise<{ readonly error: string | null }>>())
154
+ const traceLogInflightRef = useRef(new Map<string, Promise<{ readonly error: string | null }>>())
155
+
156
+ useEffect(() => {
157
+ if (selectedTraceService) persistSelectedService(selectedTraceService)
158
+ }, [selectedTraceService])
159
+
160
+ useEffect(() => {
161
+ if (!autoRefresh) return
162
+ const id = setInterval(() => setRefreshNonce((n) => n + 1), 5000)
163
+ return () => clearInterval(id)
164
+ }, [autoRefresh, setRefreshNonce])
165
+
166
+ useEffect(() => {
167
+ cacheEpochRef.current += 1
168
+ traceDetailCacheRef.current.clear()
169
+ traceLogCacheRef.current.clear()
170
+ serviceLogCacheRef.current.clear()
171
+ traceDetailInflightRef.current.clear()
172
+ traceLogInflightRef.current.clear()
173
+ invalidateAiCallDetailCache()
174
+ }, [refreshNonce])
175
+
176
+ // Pre-warm the attribute picker facet keys for the currently-selected
177
+ // service so pressing `f` feels instant. Once keys are known, also
178
+ // prefetch the value lists for the first few visible keys so the common
179
+ // path of opening `f`, picking a top key, and reopening again stays
180
+ // near-instant. Fire-and-forget; errors are surfaced when the user
181
+ // actually opens the picker.
182
+ useEffect(() => {
183
+ if (!selectedTraceService) return
184
+ void ensureTraceAttributeKeys(selectedTraceService)
185
+ .then((entry) => Promise.allSettled(
186
+ entry.data
187
+ .slice(0, 6)
188
+ .map((row) => ensureTraceAttributeValues(selectedTraceService, row.value)),
189
+ ))
190
+ .catch(() => {})
191
+ }, [selectedTraceService])
192
+
193
+ useEffect(() => {
194
+ let cancelled = false
195
+
196
+ const load = async () => {
197
+ setTraceState((current) => ({ ...current, status: current.fetchedAt === null ? "loading" : "ready", error: null }))
198
+
199
+ try {
200
+ const services = await loadTraceServices()
201
+ if (cancelled) return
202
+
203
+ const effectiveService = resolveEffectiveService(services, selectedTraceService)
204
+
205
+ if (effectiveService !== selectedTraceService) {
206
+ setSelectedTraceService(effectiveService)
207
+ }
208
+
209
+ const traces = await loadTraceSummariesForService(effectiveService, {
210
+ activeAttrKey,
211
+ activeAttrValue,
212
+ debouncedAiText,
213
+ })
214
+ if (cancelled) return
215
+
216
+ const prevTraceId = selectedTraceRef.current
217
+ setTraceState({ status: "ready", services, data: traces, error: null, fetchedAt: new Date() })
218
+ if (prevTraceId) {
219
+ const newIndex = traces.findIndex((t) => t.traceId === prevTraceId)
220
+ if (newIndex >= 0) setSelectedTraceIndex(newIndex)
221
+ }
222
+ } catch (error) {
223
+ if (cancelled) return
224
+ setTraceState((current) => ({
225
+ ...current,
226
+ status: "error",
227
+ error: error instanceof Error ? error.message : String(error),
228
+ }))
229
+ }
230
+ }
231
+
232
+ void load()
233
+ return () => {
234
+ cancelled = true
235
+ }
236
+ }, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, debouncedAiText, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
237
+
238
+ useEffect(() => {
239
+ setSelectedTraceIndex((current) => {
240
+ return clampSelectionIndex(current, traceState.data.length)
241
+ })
242
+ }, [traceState.data.length, setSelectedTraceIndex])
243
+
244
+ const selectedTraceSummary = traceState.data[selectedTraceIndex] ?? null
245
+ const selectedTraceId = selectedTraceSummary?.traceId ?? null
246
+ const selectedTrace = traceDetailState.traceId === selectedTraceId ? traceDetailState.data : null
247
+ const selectedVisibleSpans = useMemo(
248
+ () => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
249
+ [selectedTrace, collapsedSpanIds],
250
+ )
251
+ const selectedVisibleSpan = getSelectedVisibleSpan(selectedVisibleSpans, selectedSpanIndex)
252
+ selectedTraceRef.current = selectedTraceId
253
+
254
+ const warmTraceDetail = useCallback((traceId: string, hydrateSelection: boolean) => {
255
+ const cached = traceDetailCacheRef.current.get(traceId)
256
+ if (cached) {
257
+ if (hydrateSelection && selectedTraceRef.current === traceId) {
258
+ setTraceDetailState({ status: "ready", traceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
259
+ }
260
+ return Promise.resolve({ error: null })
261
+ }
262
+
263
+ const existing = traceDetailInflightRef.current.get(traceId)
264
+ if (existing) {
265
+ if (hydrateSelection) {
266
+ void existing.then(({ error }) => {
267
+ if (selectedTraceRef.current !== traceId) return
268
+ const ready = traceDetailCacheRef.current.get(traceId)
269
+ if (ready) {
270
+ setTraceDetailState({ status: "ready", traceId, data: ready.data, error: null, fetchedAt: ready.fetchedAt })
271
+ return
272
+ }
273
+ if (error) {
274
+ setTraceDetailState({ status: "error", traceId, data: null, error, fetchedAt: null })
275
+ }
276
+ })
277
+ }
278
+ return existing
279
+ }
280
+
281
+ const epoch = cacheEpochRef.current
282
+ const request = loadTraceDetail(traceId)
283
+ .then((trace) => {
284
+ if (cacheEpochRef.current !== epoch) return { error: null }
285
+ const fetchedAt = new Date()
286
+ traceDetailCacheRef.current.set(traceId, { data: trace, fetchedAt })
287
+ if (hydrateSelection && selectedTraceRef.current === traceId) {
288
+ setTraceDetailState({ status: "ready", traceId, data: trace, error: null, fetchedAt })
289
+ }
290
+ return { error: null }
291
+ })
292
+ .catch((error) => {
293
+ const message = error instanceof Error ? error.message : String(error)
294
+ if (cacheEpochRef.current === epoch && hydrateSelection && selectedTraceRef.current === traceId) {
295
+ setTraceDetailState({ status: "error", traceId, data: null, error: message, fetchedAt: null })
296
+ }
297
+ return { error: message }
298
+ })
299
+ .finally(() => {
300
+ traceDetailInflightRef.current.delete(traceId)
301
+ })
302
+
303
+ traceDetailInflightRef.current.set(traceId, request)
304
+ return request
305
+ }, [setTraceDetailState])
306
+
307
+ const warmTraceLogs = useCallback((traceId: string, hydrateSelection: boolean) => {
308
+ const cached = traceLogCacheRef.current.get(traceId)
309
+ if (cached) {
310
+ if (hydrateSelection && selectedTraceRef.current === traceId) {
311
+ setLogState({ status: "ready", traceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
312
+ }
313
+ return Promise.resolve({ error: null })
314
+ }
315
+
316
+ const existing = traceLogInflightRef.current.get(traceId)
317
+ if (existing) {
318
+ if (hydrateSelection) {
319
+ void existing.then(({ error }) => {
320
+ if (selectedTraceRef.current !== traceId) return
321
+ const ready = traceLogCacheRef.current.get(traceId)
322
+ if (ready) {
323
+ setLogState({ status: "ready", traceId, data: ready.data, error: null, fetchedAt: ready.fetchedAt })
324
+ return
325
+ }
326
+ if (error) {
327
+ setLogState({ status: "error", traceId, data: [], error, fetchedAt: null })
328
+ }
329
+ })
330
+ }
331
+ return existing
332
+ }
333
+
334
+ const epoch = cacheEpochRef.current
335
+ const request = loadTraceLogs(traceId)
336
+ .then((logs) => {
337
+ if (cacheEpochRef.current !== epoch) return { error: null }
338
+ const fetchedAt = new Date()
339
+ traceLogCacheRef.current.set(traceId, { data: logs, fetchedAt })
340
+ if (hydrateSelection && selectedTraceRef.current === traceId) {
341
+ setLogState({ status: "ready", traceId, data: logs, error: null, fetchedAt })
342
+ }
343
+ return { error: null }
344
+ })
345
+ .catch((error) => {
346
+ const message = error instanceof Error ? error.message : String(error)
347
+ if (cacheEpochRef.current === epoch && hydrateSelection && selectedTraceRef.current === traceId) {
348
+ setLogState({ status: "error", traceId, data: [], error: message, fetchedAt: null })
349
+ }
350
+ return { error: message }
351
+ })
352
+ .finally(() => {
353
+ traceLogInflightRef.current.delete(traceId)
354
+ })
355
+
356
+ traceLogInflightRef.current.set(traceId, request)
357
+ return request
358
+ }, [setLogState])
359
+
360
+ useEffect(() => {
361
+ if (!selectedTraceId) {
362
+ setTraceDetailState(initialTraceDetailState)
363
+ return
364
+ }
365
+
366
+ const cached = traceDetailCacheRef.current.get(selectedTraceId)
367
+ if (cached) {
368
+ setTraceDetailState({ status: "ready", traceId: selectedTraceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
369
+ return
370
+ }
371
+
372
+ setTraceDetailState((current) => ({
373
+ status: current.traceId === selectedTraceId && current.fetchedAt !== null ? "ready" : "loading",
374
+ traceId: selectedTraceId,
375
+ data: current.traceId === selectedTraceId ? current.data : null,
376
+ error: null,
377
+ fetchedAt: current.traceId === selectedTraceId ? current.fetchedAt : null,
378
+ }))
379
+
380
+ void warmTraceDetail(selectedTraceId, true)
381
+ }, [refreshNonce, selectedTraceId, setTraceDetailState, warmTraceDetail])
382
+
383
+ useEffect(() => {
384
+ setCollapsedSpanIds(new Set())
385
+ setSelectedSpanIndex(null)
386
+ }, [selectedTraceId, setCollapsedSpanIds, setSelectedSpanIndex])
387
+
388
+ // Reset the attribute cursor whenever the span selection moves. Without
389
+ // this, drilling from span A (with 34 tags) to span B (with 3 tags)
390
+ // would leave the cursor pointing past the end of B's tag list until
391
+ // the user hit `j`/`k` again.
392
+ useEffect(() => {
393
+ setSelectedAttrIndex(0)
394
+ setChatDetailChunkId(null)
395
+ setChatDetailScrollOffset(0)
396
+ // New span → drop chunk selection and any open detail modal.
397
+ // The effect below will re-select the first chunk once the
398
+ // detail loads.
399
+ setSelectedChatChunkId(null)
400
+ }, [selectedSpanIndex, selectedTraceId, setSelectedAttrIndex, setChatDetailChunkId, setChatDetailScrollOffset, setSelectedChatChunkId])
401
+
402
+ // Load the parsed AI call detail for the currently-selected span when
403
+ // it's an AI span and the user is drilled into L2. Cached module-level
404
+ // so re-entering the chat view for a span we already loaded is free.
405
+ const selectedSpanId = selectedVisibleSpan?.spanId ?? null
406
+ const shouldLoadAiDetail = detailView === "span-detail" && selectedVisibleSpan !== null && isAiSpan(selectedVisibleSpan.tags)
407
+
408
+ useEffect(() => {
409
+ if (!shouldLoadAiDetail || !selectedSpanId) {
410
+ setAiCallDetailState(initialAiCallDetailState)
411
+ return
412
+ }
413
+ const cached = getCachedAiCallDetail(selectedSpanId)
414
+ if (cached !== undefined) {
415
+ setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data: cached, error: null })
416
+ return
417
+ }
418
+ setAiCallDetailState({ status: "loading", spanId: selectedSpanId, data: null, error: null })
419
+ let cancelled = false
420
+ ensureAiCallDetail(selectedSpanId)
421
+ .then((data) => {
422
+ if (cancelled) return
423
+ setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data, error: null })
424
+ })
425
+ .catch((err) => {
426
+ if (cancelled) return
427
+ setAiCallDetailState({
428
+ status: "error",
429
+ spanId: selectedSpanId,
430
+ data: null,
431
+ error: err instanceof Error ? err.message : String(err),
432
+ })
433
+ })
434
+ return () => { cancelled = true }
435
+ }, [shouldLoadAiDetail, selectedSpanId, setAiCallDetailState])
436
+
437
+ // Chunk model — rebuilt whenever the detail payload changes.
438
+ // Width-independent, so this lives here instead of in the view.
439
+ const aiChatChunks = useMemo<readonly Chunk[]>(() => {
440
+ if (!shouldLoadAiDetail || !aiCallDetailState.data) return []
441
+ return buildChunks(aiCallDetailState.data)
442
+ }, [shouldLoadAiDetail, aiCallDetailState.data])
443
+
444
+ // Once chunks are available, pin selection to the first chunk unless
445
+ // the user has already chosen one. Also handles the "chunk list
446
+ // changed and the previous selection disappeared" case.
447
+ useEffect(() => {
448
+ if (aiChatChunks.length === 0) return
449
+ const stillValid = selectedChatChunkId !== null
450
+ && aiChatChunks.some((c) => c.id === selectedChatChunkId)
451
+ if (!stillValid) setSelectedChatChunkId(aiChatChunks[0]!.id)
452
+ }, [aiChatChunks, selectedChatChunkId, setSelectedChatChunkId])
453
+
454
+ useEffect(() => {
455
+ if (selectedSpanIndex === null) return
456
+ if (!selectedTrace || selectedTrace.spans.length === 0) {
457
+ setSelectedSpanIndex(null)
458
+ setDetailView("waterfall")
459
+ return
460
+ }
461
+ const visibleCount = selectedVisibleSpans.length
462
+ if (selectedSpanIndex >= visibleCount) {
463
+ setSelectedSpanIndex(visibleCount - 1)
464
+ }
465
+ }, [selectedTrace, selectedSpanIndex, selectedVisibleSpans.length, setDetailView, setSelectedSpanIndex])
466
+
467
+ useEffect(() => {
468
+ const traceId = selectedTraceId
469
+ if (!traceId) {
470
+ setLogState(initialLogState)
471
+ return
472
+ }
473
+
474
+ const cached = traceLogCacheRef.current.get(traceId)
475
+ if (cached) {
476
+ setLogState({ status: "ready", traceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
477
+ return
478
+ }
479
+
480
+ setLogState((current) => ({
481
+ status: current.traceId === traceId && current.fetchedAt !== null ? "ready" : "loading",
482
+ traceId,
483
+ data: current.traceId === traceId ? current.data : [],
484
+ error: null,
485
+ fetchedAt: current.traceId === traceId ? current.fetchedAt : null,
486
+ }))
487
+
488
+ void warmTraceLogs(traceId, true)
489
+ }, [refreshNonce, selectedTraceId, setLogState, warmTraceLogs])
490
+
491
+ useEffect(() => {
492
+ if (detailView !== "service-logs") return
493
+ const serviceName = selectedTraceService
494
+ if (!serviceName) {
495
+ setServiceLogState(initialServiceLogState)
496
+ return
497
+ }
498
+
499
+ const cached = serviceLogCacheRef.current.get(serviceName)
500
+ if (cached) {
501
+ setServiceLogState({ status: "ready", serviceName, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
502
+ return
503
+ }
504
+
505
+ let cancelled = false
506
+ setServiceLogState((current) => ({
507
+ status: current.serviceName === serviceName && current.fetchedAt !== null ? "ready" : "loading",
508
+ serviceName,
509
+ data: current.serviceName === serviceName ? current.data : [],
510
+ error: null,
511
+ fetchedAt: current.serviceName === serviceName ? current.fetchedAt : null,
512
+ }))
513
+
514
+ void (async () => {
515
+ try {
516
+ const logs = await loadServiceLogs(serviceName)
517
+ const fetchedAt = new Date()
518
+ serviceLogCacheRef.current.set(serviceName, { data: logs, fetchedAt })
519
+ if (cancelled) return
520
+ setServiceLogState({ status: "ready", serviceName, data: logs, error: null, fetchedAt })
521
+ } catch (error) {
522
+ if (cancelled) return
523
+ setServiceLogState({ status: "error", serviceName, data: [], error: error instanceof Error ? error.message : String(error), fetchedAt: null })
524
+ }
525
+ })()
526
+
527
+ return () => {
528
+ cancelled = true
529
+ }
530
+ }, [detailView, refreshNonce, selectedTraceService, setServiceLogState])
531
+
532
+ useEffect(() => {
533
+ setSelectedServiceLogIndex((current) => {
534
+ return clampSelectionIndex(current, serviceLogState.data.length)
535
+ })
536
+ }, [serviceLogState.data.length, setSelectedServiceLogIndex])
537
+
538
+ // Client-side filters: `:error` + operation-name needle both run
539
+ // against already-loaded summaries (no server round-trip). The `:ai`
540
+ // query, by contrast, is applied server-side in the load effect
541
+ // above so we don't need to re-filter it here.
542
+ const filteredTraces = useMemo(() => {
543
+ const preFiltered = applyClientTraceFilters(traceState.data, filterText, parsedFilter)
544
+ return sortTraceSummaries(preFiltered, traceSort)
545
+ }, [filterText, parsedFilter, traceSort, traceState.data])
546
+
547
+ useEffect(() => {
548
+ if (!selectedTraceId || filteredTraces.length === 0) return
549
+ const currentIndex = filteredTraces.findIndex((trace) => trace.traceId === selectedTraceId)
550
+ if (currentIndex < 0) return
551
+
552
+ for (const offset of [-1, 1] as const) {
553
+ const neighborId = filteredTraces[currentIndex + offset]?.traceId
554
+ if (!neighborId) continue
555
+ void warmTraceDetail(neighborId, false)
556
+ void warmTraceLogs(neighborId, false)
557
+ }
558
+ }, [filteredTraces, selectedTraceId, warmTraceDetail, warmTraceLogs])
559
+
560
+ return {
561
+ traceState,
562
+ traceDetailState,
563
+ logState,
564
+ serviceLogState,
565
+ selectedServiceLogIndex,
566
+ setSelectedServiceLogIndex,
567
+ selectedTraceIndex,
568
+ setSelectedTraceIndex,
569
+ selectedTraceService,
570
+ selectedSpanIndex,
571
+ setSelectedSpanIndex,
572
+ detailView,
573
+ setDetailView,
574
+ showHelp,
575
+ setShowHelp,
576
+ collapsedSpanIds,
577
+ autoRefresh,
578
+ filterMode,
579
+ filterText,
580
+ activeAttrKey,
581
+ activeAttrValue,
582
+ traceSort,
583
+ selectedTraceSummary,
584
+ selectedTrace,
585
+ selectedTraceId,
586
+ filteredTraces,
587
+ aiCallDetailState,
588
+ aiChatChunks,
589
+ } as const
590
+ }