@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
@@ -0,0 +1,120 @@
1
+ import { Effect } from "effect"
2
+ import { config } from "../config.ts"
3
+ import { queryRuntime } from "../runtime.ts"
4
+ import { LogQueryService } from "../services/LogQueryService.ts"
5
+ import { TraceQueryService } from "../services/TraceQueryService.ts"
6
+
7
+ export const loadTraceServices = () =>
8
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
9
+
10
+ export const loadRecentTraceSummaries = (serviceName: string) =>
11
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
12
+
13
+ /**
14
+ * Server-side trace summary search. Accepts any combination of:
15
+ *
16
+ * - `attributeFilters` — exact-match span attributes (from the `f` picker)
17
+ * - `aiText` — FTS5-backed search across LLM prompt/response
18
+ * content (AI_FTS_KEYS), from the `:ai <query>`
19
+ * modifier in the `/` filter
20
+ *
21
+ * Both filters compose: when both are set, a trace must match both. When
22
+ * neither is set, callers should prefer `loadRecentTraceSummaries` so
23
+ * the server can skip the search path entirely.
24
+ */
25
+ export const loadFilteredTraceSummaries = (
26
+ serviceName: string,
27
+ options: {
28
+ readonly attributeFilters?: Readonly<Record<string, string>>
29
+ readonly aiText?: string | null
30
+ },
31
+ ) =>
32
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
33
+ serviceName,
34
+ attributeFilters: options.attributeFilters,
35
+ aiText: options.aiText ?? null,
36
+ limit: config.otel.traceFetchLimit,
37
+ })))
38
+
39
+ export const loadTraceAttributeKeys = (serviceName: string) =>
40
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
41
+
42
+ export const loadTraceAttributeValues = (serviceName: string, key: string) =>
43
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Facet cache (drives the `f` attribute filter picker)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export interface FacetRow {
50
+ readonly value: string
51
+ readonly count: number
52
+ }
53
+
54
+ export interface FacetCacheEntry {
55
+ readonly data: readonly FacetRow[]
56
+ readonly fetchedAt: Date
57
+ }
58
+
59
+ const facetKeysCache = new Map<string, FacetCacheEntry>()
60
+ const facetValuesCache = new Map<string, FacetCacheEntry>()
61
+ const facetKeysInflight = new Map<string, Promise<FacetCacheEntry>>()
62
+ const facetValuesInflight = new Map<string, Promise<FacetCacheEntry>>()
63
+
64
+ const valuesKey = (service: string, key: string) => `${service}\u0000${key}`
65
+
66
+ export const getCachedFacetKeys = (service: string): FacetCacheEntry | null =>
67
+ facetKeysCache.get(service) ?? null
68
+
69
+ export const getCachedFacetValues = (service: string, key: string): FacetCacheEntry | null =>
70
+ facetValuesCache.get(valuesKey(service, key)) ?? null
71
+
72
+ export const ensureTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> => {
73
+ const existing = facetKeysInflight.get(service)
74
+ if (existing) return existing
75
+ const request = loadTraceAttributeKeys(service)
76
+ .then((data) => {
77
+ const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
78
+ facetKeysCache.set(service, entry)
79
+ return entry
80
+ })
81
+ .finally(() => {
82
+ facetKeysInflight.delete(service)
83
+ })
84
+ facetKeysInflight.set(service, request)
85
+ return request
86
+ }
87
+
88
+ export const ensureTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> => {
89
+ const cacheKey = valuesKey(service, key)
90
+ const existing = facetValuesInflight.get(cacheKey)
91
+ if (existing) return existing
92
+ const request = loadTraceAttributeValues(service, key)
93
+ .then((data) => {
94
+ const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
95
+ facetValuesCache.set(cacheKey, entry)
96
+ return entry
97
+ })
98
+ .finally(() => {
99
+ facetValuesInflight.delete(cacheKey)
100
+ })
101
+ facetValuesInflight.set(cacheKey, request)
102
+ return request
103
+ }
104
+
105
+ /** Called from the refreshNonce effect alongside the trace / log cache clears. */
106
+ export const invalidateFacetCaches = () => {
107
+ facetKeysCache.clear()
108
+ facetValuesCache.clear()
109
+ facetKeysInflight.clear()
110
+ facetValuesInflight.clear()
111
+ }
112
+
113
+ export const loadTraceDetail = (traceId: string) =>
114
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
115
+
116
+ export const loadTraceLogs = (traceId: string) =>
117
+ queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
118
+
119
+ export const loadServiceLogs = (serviceName: string) =>
120
+ queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+ import { config } from "../config.ts"
4
+ import type { ThemeName } from "./theme.ts"
5
+
6
+ const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
7
+
8
+ export const readLastService = (): string | null => {
9
+ try {
10
+ return readFileSync(lastServicePath, "utf-8").trim() || null
11
+ } catch {
12
+ return null
13
+ }
14
+ }
15
+
16
+ let lastPersistedService = readLastService()
17
+
18
+ export const persistSelectedService = (service: string) => {
19
+ if (service === lastPersistedService) return
20
+ lastPersistedService = service
21
+ Bun.write(lastServicePath, service).catch(() => {})
22
+ }
23
+
24
+ const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
25
+
26
+ export const readLastTheme = (): ThemeName => {
27
+ try {
28
+ const raw = readFileSync(lastThemePath, "utf-8").trim()
29
+ return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
30
+ } catch {
31
+ return "motel-default"
32
+ }
33
+ }
34
+
35
+ let lastPersistedTheme = readLastTheme()
36
+
37
+ export const persistSelectedTheme = (theme: ThemeName) => {
38
+ if (theme === lastPersistedTheme) return
39
+ lastPersistedTheme = theme
40
+ Bun.write(lastThemePath, theme).catch(() => {})
41
+ }
@@ -1,4 +1,5 @@
1
1
  import { RGBA, TextAttributes } from "@opentui/core"
2
+ import { Children } from "react"
2
3
  import { colors } from "./theme.ts"
3
4
  import { fitCell, truncateText } from "./format.ts"
4
5
  import type { DetailView } from "./state.ts"
@@ -19,19 +20,32 @@ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: stri
19
20
  </box>
20
21
  )
21
22
 
22
- export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
23
- <box height={1}>
24
- {bg ? (
25
- <text wrapMode="none" truncate fg={fg} bg={bg}>
26
- {children}
27
- </text>
28
- ) : (
29
- <text wrapMode="none" truncate fg={fg}>
30
- {children}
31
- </text>
32
- )}
33
- </box>
34
- )
23
+ const collapseFormattingWhitespace = (children: React.ReactNode) => Children.toArray(children).filter((child) => {
24
+ if (typeof child !== "string") return true
25
+ if (child.trim().length > 0) return true
26
+ // OpenTUI preserves JSX indentation/newline text nodes, so strip the
27
+ // formatting-only whitespace between inline spans while keeping any
28
+ // intentional in-band spaces that callers render explicitly.
29
+ return !/[\r\n\t]/.test(child)
30
+ })
31
+
32
+ export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => {
33
+ const inlineChildren = collapseFormattingWhitespace(children)
34
+
35
+ return (
36
+ <box height={1}>
37
+ {bg ? (
38
+ <text wrapMode="none" truncate fg={fg} bg={bg}>
39
+ {inlineChildren}
40
+ </text>
41
+ ) : (
42
+ <text wrapMode="none" truncate fg={fg}>
43
+ {inlineChildren}
44
+ </text>
45
+ )}
46
+ </box>
47
+ )
48
+ }
35
49
 
36
50
  export const AlignedHeaderLine = ({ left, right, width, rightFg = colors.muted }: { left: string; right: string; width: number; rightFg?: string }) => {
37
51
  const availableRightWidth = Math.max(8, width - left.length - 2)
@@ -80,13 +94,25 @@ export const SeparatorColumn = ({ height, junctionChars }: { height: number; jun
80
94
  )
81
95
  }
82
96
 
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
- )
97
+ export const FilterBar = ({ text, width }: { text: string; width: number }) => {
98
+ // Layout: "/<text>█ op name · :error · :ai <query>"
99
+ // Cursor block sits immediately after the typed text (no padding —
100
+ // fitCell would pad the empty string to fill the available width and
101
+ // push the cursor to the far right, which looked like a bug). The
102
+ // hint only renders while the input is empty so real typing never
103
+ // collides with it.
104
+ const hint = text.length === 0 ? " op name · :error · :ai <query>" : ""
105
+ const textMaxWidth = Math.max(1, width - 2 - hint.length)
106
+ const displayText = text.length > textMaxWidth ? text.slice(text.length - textMaxWidth) : text
107
+ return (
108
+ <TextLine fg={colors.accent}>
109
+ <span fg={colors.muted}>{"/"}</span>
110
+ <span fg={colors.text}>{displayText}</span>
111
+ <span fg={colors.accent}>{"\u2588"}</span>
112
+ {hint ? <span fg={colors.muted}>{hint}</span> : null}
113
+ </TextLine>
114
+ )
115
+ }
90
116
 
91
117
  const FooterKey = ({ label }: { label: string }) => <span fg={colors.count} attributes={TextAttributes.BOLD}>{label}</span>
92
118
 
@@ -118,7 +144,7 @@ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: {
118
144
  {row("t", `cycle theme (${themeLabel})`)}
119
145
  {row("tab", "toggle service logs")}
120
146
  {row("[ ]", "switch service")}
121
- {row("/", "filter by root operation")}
147
+ {row("/", "filter by root operation (\u2003:error, :ai <query>)")}
122
148
  {row("f", "filter traces by span attribute")}
123
149
  {row("s", "cycle sort mode")}
124
150
  {row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
package/src/ui/state.ts CHANGED
@@ -1,169 +1,4 @@
1
- import { Effect } from "effect"
2
- import * as Atom from "effect/unstable/reactivity/Atom"
3
- import { readFileSync } from "node:fs"
4
- import { dirname } from "node:path"
5
- import { config } from "../config.ts"
6
- import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
7
- import { queryRuntime } from "../runtime.ts"
8
- import { LogQueryService } from "../services/LogQueryService.ts"
9
- import { TraceQueryService } from "../services/TraceQueryService.ts"
10
- import type { ThemeName } from "./theme.ts"
11
-
12
- export type LoadStatus = "loading" | "ready" | "error"
13
- export type DetailView = "waterfall" | "span-detail" | "service-logs"
14
-
15
- export interface TraceState {
16
- readonly status: LoadStatus
17
- readonly services: readonly string[]
18
- readonly data: readonly TraceSummaryItem[]
19
- readonly error: string | null
20
- readonly fetchedAt: Date | null
21
- }
22
-
23
- export interface TraceDetailState {
24
- readonly status: LoadStatus
25
- readonly traceId: string | null
26
- readonly data: TraceItem | null
27
- readonly error: string | null
28
- readonly fetchedAt: Date | null
29
- }
30
-
31
- export interface LogState {
32
- readonly status: LoadStatus
33
- readonly traceId: string | null
34
- readonly data: readonly LogItem[]
35
- readonly error: string | null
36
- readonly fetchedAt: Date | null
37
- }
38
-
39
- export interface ServiceLogState {
40
- readonly status: LoadStatus
41
- readonly serviceName: string | null
42
- readonly data: readonly LogItem[]
43
- readonly error: string | null
44
- readonly fetchedAt: Date | null
45
- }
46
-
47
- export const initialTraceState: TraceState = {
48
- status: "loading",
49
- services: [],
50
- data: [],
51
- error: null,
52
- fetchedAt: null,
53
- }
54
-
55
- export const initialLogState: LogState = {
56
- status: "ready",
57
- traceId: null,
58
- data: [],
59
- error: null,
60
- fetchedAt: null,
61
- }
62
-
63
- export const initialTraceDetailState: TraceDetailState = {
64
- status: "ready",
65
- traceId: null,
66
- data: null,
67
- error: null,
68
- fetchedAt: null,
69
- }
70
-
71
- export const initialServiceLogState: ServiceLogState = {
72
- status: "ready",
73
- serviceName: null,
74
- data: [],
75
- error: null,
76
- fetchedAt: null,
77
- }
78
-
79
- export const traceStateAtom = Atom.make(initialTraceState).pipe(Atom.keepAlive)
80
- export const traceDetailStateAtom = Atom.make(initialTraceDetailState).pipe(Atom.keepAlive)
81
- export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
82
- export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
83
- export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
84
- export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
85
- const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
86
- const readLastService = (): string | null => {
87
- try { return readFileSync(lastServicePath, "utf-8").trim() || null }
88
- catch { return null }
89
- }
90
-
91
- let lastPersistedService = readLastService()
92
-
93
- export const persistSelectedService = (service: string) => {
94
- if (service === lastPersistedService) return
95
- lastPersistedService = service
96
- Bun.write(lastServicePath, service).catch(() => {})
97
- }
98
-
99
- export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
100
- export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
101
- export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
102
- export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
103
- export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
104
- export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
105
- export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
106
- export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
107
- export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
108
-
109
- // Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
110
- export type AttrPickerMode = "off" | "keys" | "values"
111
- export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
112
- export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
113
- export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
114
-
115
- export interface AttrFacetState {
116
- readonly status: LoadStatus
117
- readonly key: string | null // null when loading keys; set when loading values
118
- readonly data: readonly { readonly value: string; readonly count: number }[]
119
- readonly error: string | null
120
- }
121
-
122
- export const initialAttrFacetState: AttrFacetState = {
123
- status: "ready",
124
- key: null,
125
- data: [],
126
- error: null,
127
- }
128
-
129
- export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
130
-
131
- // Applied filter (drives trace list query)
132
- export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
133
- export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
134
-
135
- const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
136
- const readLastTheme = (): ThemeName => {
137
- try {
138
- const raw = readFileSync(lastThemePath, "utf-8").trim()
139
- return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
140
- } catch {
141
- return "motel-default"
142
- }
143
- }
144
-
145
- let lastPersistedTheme = readLastTheme()
146
-
147
- export const persistSelectedTheme = (theme: ThemeName) => {
148
- if (theme === lastPersistedTheme) return
149
- lastPersistedTheme = theme
150
- Bun.write(lastThemePath, theme).catch(() => {})
151
- }
152
-
153
- export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
154
-
155
- export type TraceSortMode = "recent" | "slowest" | "errors"
156
- export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
157
- export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
158
-
159
- export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
160
- 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 })))
163
- export const loadTraceAttributeKeys = (serviceName: string) =>
164
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
165
- export const loadTraceAttributeValues = (serviceName: string, key: string) =>
166
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
167
- export const loadTraceDetail = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
168
- export const loadTraceLogs = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
169
- export const loadServiceLogs = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
1
+ export * from "./atoms.ts"
2
+ export * from "./persistence.ts"
3
+ export * from "./loaders.ts"
4
+ export * from "./aiState.ts"
@@ -3,16 +3,22 @@ import { useEffect } from "react"
3
3
  import {
4
4
  attrFacetStateAtom,
5
5
  attrPickerModeAtom,
6
+ ensureTraceAttributeKeys,
7
+ ensureTraceAttributeValues,
8
+ getCachedFacetKeys,
9
+ getCachedFacetValues,
6
10
  initialAttrFacetState,
7
- loadTraceAttributeKeys,
8
- loadTraceAttributeValues,
9
11
  selectedTraceServiceAtom,
10
12
  } from "./state.ts"
11
13
 
12
- // When the picker is open, load the current facet page (keys, or values for
13
- // a specific key) and keep it in sync with the selected service. We key the
14
- // effect off picker mode + service + target key so refetches happen on drill
15
- // in/out and when the user switches services mid-pick.
14
+ // Drive the picker's data state from (pickerMode, service, selectedKey).
15
+ //
16
+ // Strategy: stale-while-revalidate. On reopen we publish whatever the
17
+ // module-level cache has instantly (no "loading…" flash), then kick off a
18
+ // background revalidation. The first time we see a (service, key) tuple
19
+ // we still show `loading` so the UI has something to say. The module-level
20
+ // caches in `state.ts` mean a service-change pre-warm can fill the cache
21
+ // before the user ever presses `f`.
16
22
  export const useAttrFilterPicker = (selectedKey: string | null) => {
17
23
  const [pickerMode] = useAtom(attrPickerModeAtom)
18
24
  const [service] = useAtom(selectedTraceServiceAtom)
@@ -24,24 +30,58 @@ export const useAttrFilterPicker = (selectedKey: string | null) => {
24
30
  return
25
31
  }
26
32
  let cancelled = false
27
- setFacetState({ status: "loading", key: pickerMode === "values" ? selectedKey : null, data: [], error: null })
28
- const load = async () => {
29
- try {
30
- const rows = pickerMode === "keys"
31
- ? await loadTraceAttributeKeys(service)
32
- : selectedKey
33
- ? await loadTraceAttributeValues(service, selectedKey)
34
- : []
35
- if (cancelled) return
36
- setFacetState({ status: "ready", key: pickerMode === "values" ? selectedKey : null, data: rows, error: null })
37
- } catch (err) {
38
- if (cancelled) return
39
- setFacetState({ status: "error", key: pickerMode === "values" ? selectedKey : null, data: [], error: err instanceof Error ? err.message : String(err) })
40
- }
33
+ const publishReady = (key: string | null, data: readonly { readonly value: string; readonly count: number }[]) => {
34
+ setFacetState({ status: "ready", key, data, error: null })
35
+ }
36
+ const publishLoading = (key: string | null, previous: readonly { readonly value: string; readonly count: number }[] = []) => {
37
+ setFacetState({ status: "loading", key, data: previous, error: null })
41
38
  }
42
- void load()
43
- return () => {
44
- cancelled = true
39
+ const publishError = (key: string | null, previous: readonly { readonly value: string; readonly count: number }[], err: unknown) => {
40
+ setFacetState({
41
+ status: "error",
42
+ key,
43
+ data: previous,
44
+ error: err instanceof Error ? err.message : String(err),
45
+ })
45
46
  }
47
+
48
+ if (pickerMode === "keys") {
49
+ const cached = getCachedFacetKeys(service)
50
+ if (cached) {
51
+ publishReady(null, cached.data)
52
+ } else {
53
+ publishLoading(null)
54
+ }
55
+ ensureTraceAttributeKeys(service)
56
+ .then((entry) => {
57
+ if (cancelled) return
58
+ publishReady(null, entry.data)
59
+ })
60
+ .catch((err) => {
61
+ if (cancelled) return
62
+ publishError(null, cached?.data ?? [], err)
63
+ })
64
+ } else if (selectedKey) {
65
+ const cached = getCachedFacetValues(service, selectedKey)
66
+ if (cached) {
67
+ publishReady(selectedKey, cached.data)
68
+ } else {
69
+ publishLoading(selectedKey)
70
+ }
71
+ ensureTraceAttributeValues(service, selectedKey)
72
+ .then((entry) => {
73
+ if (cancelled) return
74
+ publishReady(selectedKey, entry.data)
75
+ })
76
+ .catch((err) => {
77
+ if (cancelled) return
78
+ publishError(selectedKey, cached?.data ?? [], err)
79
+ })
80
+ } else {
81
+ // values mode with no key yet — just show empty state.
82
+ publishReady(null, [])
83
+ }
84
+
85
+ return () => { cancelled = true }
46
86
  }, [pickerMode, service, selectedKey, setFacetState])
47
87
  }