@three-flatland/devtools 0.1.0-alpha.3

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 (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -0
  3. package/dist/buffers-modal.cjs +491 -0
  4. package/dist/buffers-modal.cjs.map +1 -0
  5. package/dist/buffers-modal.d.cts +62 -0
  6. package/dist/buffers-modal.d.ts +62 -0
  7. package/dist/buffers-modal.js +467 -0
  8. package/dist/buffers-modal.js.map +1 -0
  9. package/dist/buffers-view.cjs +269 -0
  10. package/dist/buffers-view.cjs.map +1 -0
  11. package/dist/buffers-view.d.cts +39 -0
  12. package/dist/buffers-view.d.ts +39 -0
  13. package/dist/buffers-view.js +245 -0
  14. package/dist/buffers-view.js.map +1 -0
  15. package/dist/create-pane.cjs +203 -0
  16. package/dist/create-pane.cjs.map +1 -0
  17. package/dist/create-pane.d.cts +66 -0
  18. package/dist/create-pane.d.ts +66 -0
  19. package/dist/create-pane.js +178 -0
  20. package/dist/create-pane.js.map +1 -0
  21. package/dist/dashboard/app.tsx +42 -0
  22. package/dist/dashboard/client.ts +21 -0
  23. package/dist/dashboard/export.ts +84 -0
  24. package/dist/dashboard/hooks.ts +75 -0
  25. package/dist/dashboard/index.html +1149 -0
  26. package/dist/dashboard/index.tsx +18 -0
  27. package/dist/dashboard/panels/batches.tsx +282 -0
  28. package/dist/dashboard/panels/buffers.tsx +425 -0
  29. package/dist/dashboard/panels/env.tsx +85 -0
  30. package/dist/dashboard/panels/header-stats.tsx +30 -0
  31. package/dist/dashboard/panels/producer-select.tsx +81 -0
  32. package/dist/dashboard/panels/protocol-log.tsx +743 -0
  33. package/dist/dashboard/panels/registry.tsx +642 -0
  34. package/dist/dashboard/panels/stats.tsx +431 -0
  35. package/dist/dashboard/protocol-store.ts +312 -0
  36. package/dist/dashboard/tsconfig.json +19 -0
  37. package/dist/dashboard/vendor/hooks.module.js +1 -0
  38. package/dist/dashboard/vendor/jsx-runtime.js +22 -0
  39. package/dist/dashboard/vendor/preact.module.js +12 -0
  40. package/dist/devtools-client.cjs +870 -0
  41. package/dist/devtools-client.cjs.map +1 -0
  42. package/dist/devtools-client.d.cts +362 -0
  43. package/dist/devtools-client.d.ts +362 -0
  44. package/dist/devtools-client.js +852 -0
  45. package/dist/devtools-client.js.map +1 -0
  46. package/dist/index.cjs +42 -0
  47. package/dist/index.cjs.map +1 -0
  48. package/dist/index.d.cts +8 -0
  49. package/dist/index.d.ts +8 -0
  50. package/dist/index.js +13 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/perf-trace.cjs +103 -0
  53. package/dist/perf-trace.cjs.map +1 -0
  54. package/dist/perf-trace.d.cts +27 -0
  55. package/dist/perf-trace.d.ts +27 -0
  56. package/dist/perf-trace.js +79 -0
  57. package/dist/perf-trace.js.map +1 -0
  58. package/dist/plugins.cjs +44 -0
  59. package/dist/plugins.cjs.map +1 -0
  60. package/dist/plugins.d.cts +7 -0
  61. package/dist/plugins.d.ts +7 -0
  62. package/dist/plugins.js +9 -0
  63. package/dist/plugins.js.map +1 -0
  64. package/dist/provider-switcher.cjs +95 -0
  65. package/dist/provider-switcher.cjs.map +1 -0
  66. package/dist/provider-switcher.d.cts +18 -0
  67. package/dist/provider-switcher.d.ts +18 -0
  68. package/dist/provider-switcher.js +71 -0
  69. package/dist/provider-switcher.js.map +1 -0
  70. package/dist/react/devtools-provider.cjs +59 -0
  71. package/dist/react/devtools-provider.cjs.map +1 -0
  72. package/dist/react/devtools-provider.d.cts +39 -0
  73. package/dist/react/devtools-provider.d.ts +39 -0
  74. package/dist/react/devtools-provider.js +38 -0
  75. package/dist/react/devtools-provider.js.map +1 -0
  76. package/dist/react/use-fps-graph.cjs +49 -0
  77. package/dist/react/use-fps-graph.cjs.map +1 -0
  78. package/dist/react/use-fps-graph.d.cts +15 -0
  79. package/dist/react/use-fps-graph.d.ts +15 -0
  80. package/dist/react/use-fps-graph.js +25 -0
  81. package/dist/react/use-fps-graph.js.map +1 -0
  82. package/dist/react/use-pane-button.cjs +58 -0
  83. package/dist/react/use-pane-button.cjs.map +1 -0
  84. package/dist/react/use-pane-button.d.cts +10 -0
  85. package/dist/react/use-pane-button.d.ts +10 -0
  86. package/dist/react/use-pane-button.js +34 -0
  87. package/dist/react/use-pane-button.js.map +1 -0
  88. package/dist/react/use-pane-folder.cjs +48 -0
  89. package/dist/react/use-pane-folder.cjs.map +1 -0
  90. package/dist/react/use-pane-folder.d.cts +15 -0
  91. package/dist/react/use-pane-folder.d.ts +15 -0
  92. package/dist/react/use-pane-folder.js +24 -0
  93. package/dist/react/use-pane-folder.js.map +1 -0
  94. package/dist/react/use-pane-input.cjs +70 -0
  95. package/dist/react/use-pane-input.cjs.map +1 -0
  96. package/dist/react/use-pane-input.d.cts +44 -0
  97. package/dist/react/use-pane-input.d.ts +44 -0
  98. package/dist/react/use-pane-input.js +46 -0
  99. package/dist/react/use-pane-input.js.map +1 -0
  100. package/dist/react/use-pane-radio-grid.cjs +78 -0
  101. package/dist/react/use-pane-radio-grid.cjs.map +1 -0
  102. package/dist/react/use-pane-radio-grid.d.cts +35 -0
  103. package/dist/react/use-pane-radio-grid.d.ts +35 -0
  104. package/dist/react/use-pane-radio-grid.js +54 -0
  105. package/dist/react/use-pane-radio-grid.js.map +1 -0
  106. package/dist/react/use-pane.cjs +45 -0
  107. package/dist/react/use-pane.cjs.map +1 -0
  108. package/dist/react/use-pane.d.cts +40 -0
  109. package/dist/react/use-pane.d.ts +40 -0
  110. package/dist/react/use-pane.js +21 -0
  111. package/dist/react/use-pane.js.map +1 -0
  112. package/dist/react.cjs +50 -0
  113. package/dist/react.cjs.map +1 -0
  114. package/dist/react.d.cts +12 -0
  115. package/dist/react.d.ts +12 -0
  116. package/dist/react.js +19 -0
  117. package/dist/react.js.map +1 -0
  118. package/dist/registry-view.cjs +371 -0
  119. package/dist/registry-view.cjs.map +1 -0
  120. package/dist/registry-view.d.cts +24 -0
  121. package/dist/registry-view.d.ts +24 -0
  122. package/dist/registry-view.js +347 -0
  123. package/dist/registry-view.js.map +1 -0
  124. package/dist/stats-graph.cjs +297 -0
  125. package/dist/stats-graph.cjs.map +1 -0
  126. package/dist/stats-graph.d.cts +40 -0
  127. package/dist/stats-graph.d.ts +40 -0
  128. package/dist/stats-graph.js +273 -0
  129. package/dist/stats-graph.js.map +1 -0
  130. package/dist/stats-row.cjs +153 -0
  131. package/dist/stats-row.cjs.map +1 -0
  132. package/dist/stats-row.d.cts +24 -0
  133. package/dist/stats-row.d.ts +24 -0
  134. package/dist/stats-row.js +129 -0
  135. package/dist/stats-row.js.map +1 -0
  136. package/dist/theme.cjs +260 -0
  137. package/dist/theme.cjs.map +1 -0
  138. package/dist/theme.d.cts +10 -0
  139. package/dist/theme.d.ts +10 -0
  140. package/dist/theme.js +235 -0
  141. package/dist/theme.js.map +1 -0
  142. package/dist/vite-plugin.cjs +113 -0
  143. package/dist/vite-plugin.cjs.map +1 -0
  144. package/dist/vite-plugin.d.cts +9 -0
  145. package/dist/vite-plugin.d.ts +9 -0
  146. package/dist/vite-plugin.js +88 -0
  147. package/dist/vite-plugin.js.map +1 -0
  148. package/package.json +124 -0
@@ -0,0 +1,743 @@
1
+ /** @jsxImportSource preact */
2
+ /**
3
+ * Protocol log panel — virtualized, fixed-row, tail-pinned.
4
+ *
5
+ * The client mutates its `state` in place, so a time-series of *messages*
6
+ * isn't available from state alone. A local ring buffer here is fed by
7
+ * `addRawMessageListener`. Because messages arrive at up to ~4Hz × many
8
+ * streams, we render a windowed slice of the ring — only rows in the
9
+ * visible viewport mount to the DOM.
10
+ *
11
+ * Scroll model:
12
+ * - Newest entry at top (visual index 0), oldest at the bottom.
13
+ * - Tail on: scrollTop = 0 after every update; the viewport tracks
14
+ * the head of the stream.
15
+ * - Tail off: scrollTop stays put in terms of *content*, not pixels —
16
+ * each newly prepended entry bumps scrollTop by exactly ROW_HEIGHT
17
+ * so the rows the user is reading don't drift downward on screen.
18
+ * - User scrolls to within TAIL_RESUME_PIX of the top and holds for
19
+ * RESUME_TAIL_MS → auto-resume.
20
+ *
21
+ * Row detail:
22
+ * - Click a row → opens the side detail pane with the full message
23
+ * JSON. List height stays uniform; expanding/collapsing doesn't
24
+ * reflow the virtualized rows.
25
+ */
26
+ import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'
27
+ import type { DebugMessage } from 'three-flatland/debug-protocol'
28
+ import { getClient } from '../client.js'
29
+ import { exportSession } from '../export.js'
30
+ import { useDevtoolsState } from '../hooks.js'
31
+ import { getProtocolStore, type LogEntry } from '../protocol-store.js'
32
+
33
+ const ROW_HEIGHT = 22
34
+ const OVERSCAN = 6
35
+ const TAIL_RESUME_PIX = 4
36
+ const RESUME_TAIL_MS = 600
37
+
38
+ export function ProtocolLog() {
39
+ const client = getClient()
40
+ const store = getProtocolStore()
41
+ const state = useDevtoolsState()
42
+ const activeProviderId = state.selectedProviderId
43
+ const [paused, setPaused] = useState(false)
44
+ const pausedRef = useRef(false)
45
+ pausedRef.current = paused
46
+ const [filter, setFilter] = useState('')
47
+ // Optional direction gate — `null` = both, 'in' / 'out' = pin to one.
48
+ const [dirFilter, setDirFilter] = useState<'in' | 'out' | null>(null)
49
+ // Bytes threshold — hide messages smaller than this. Useful for
50
+ // spotting fat buffer:chunk frames while ignoring stats heartbeats.
51
+ const [minBytes, setMinBytes] = useState(0)
52
+ // Type multiselect — empty set = show all; otherwise only these.
53
+ const [excludedTypes, setExcludedTypes] = useState<Set<string>>(() => new Set())
54
+ const [filterMenuOpen, setFilterMenuOpen] = useState(false)
55
+ const filterMenuRef = useRef<HTMLDivElement | null>(null)
56
+
57
+ const [selectedId, setSelectedId] = useState<number | null>(null)
58
+ const [selectedEntry, setSelectedEntry] = useState<LogEntry | null>(null)
59
+ // Hover tracked by entry id, not DOM slot — slots get recycled as new
60
+ // messages arrive, which means CSS `:hover` applied to a slot would
61
+ // land on whichever entry happens to occupy the slot a frame later.
62
+ // Tracking by id means the highlight stays on the entry the user is
63
+ // actually pointing at.
64
+ const [hoveredId, setHoveredId] = useState<number | null>(null)
65
+
66
+ const [tail, setTail] = useState(true)
67
+ const tailRef = useRef(true)
68
+ tailRef.current = tail
69
+
70
+ const scrollerRef = useRef<HTMLDivElement | null>(null)
71
+ const filterInputRef = useRef<HTMLInputElement | null>(null)
72
+ const resumeTimerRef = useRef<number | null>(null)
73
+ // Count of entries that arrived since the last render — used by the
74
+ // post-render effect to compensate scrollTop when tail is off.
75
+ const pendingBumpRef = useRef(0)
76
+ const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
77
+
78
+ // Wire the raw-message tap → store. The store owns IDB persistence,
79
+ // counters, cache + eviction; this listener translates raw messages
80
+ // to log rows AND tags each with whichever provider was selected at
81
+ // the moment it arrived, so per-provider scoping stays honest even
82
+ // when the user switches mid-stream.
83
+ useEffect(() => {
84
+ return client.addRawMessageListener((msg, direction) => {
85
+ if (pausedRef.current) return
86
+ const providerId = client.state.selectedProviderId
87
+ if (providerId === null) return
88
+ store.push(providerId, {
89
+ at: Date.now(),
90
+ direction,
91
+ type: msg.type,
92
+ tag: extractTag(msg),
93
+ frame: extractFrame(msg),
94
+ bytes: estimateBytes(msg),
95
+ msg,
96
+ })
97
+ if (providerId === activeProviderId) pendingBumpRef.current += 1
98
+ })
99
+ }, [client, store, activeProviderId])
100
+
101
+ // The shared `useDevtoolsState` hook + store-driven listener both
102
+ // tick through the dashboard-wide rAF scheduler, so we don't need our
103
+ // own rAF here — the `state` read above is enough to re-render each
104
+ // frame the store fires.
105
+
106
+ // After each render but BEFORE paint: tail ON pins to top; tail OFF
107
+ // bumps scrollTop by exactly the number of new rows prepended since
108
+ // the last paint. Must be `useLayoutEffect` — if this ran in a regular
109
+ // effect the browser would paint the grown-but-unscrolled state for
110
+ // one frame, which reads as rows jumping down a row each update.
111
+ // `> TAIL_RESUME_PIX` (not `> 0`) — when the user is actively
112
+ // scrolling back toward the tail, we don't want automatic bumps
113
+ // fighting their input. Within the band the resume timer handles it.
114
+ useLayoutEffect(() => {
115
+ const el = scrollerRef.current
116
+ if (el === null) return
117
+ if (tailRef.current) {
118
+ el.scrollTop = 0
119
+ } else if (pendingBumpRef.current > 0 && el.scrollTop > TAIL_RESUME_PIX) {
120
+ el.scrollTop += pendingBumpRef.current * ROW_HEIGHT
121
+ }
122
+ pendingBumpRef.current = 0
123
+ })
124
+
125
+ // Capture scroller height for virtualization math. Updates on first
126
+ // mount + any resize.
127
+ useEffect(() => {
128
+ const el = scrollerRef.current
129
+ if (el === null) return
130
+ setViewport((v) => ({ ...v, height: el.clientHeight }))
131
+ const obs = new ResizeObserver(() => {
132
+ const h = scrollerRef.current?.clientHeight ?? 0
133
+ setViewport((v) => (v.height === h ? v : { ...v, height: h }))
134
+ })
135
+ obs.observe(el)
136
+ return () => obs.disconnect()
137
+ }, [])
138
+
139
+ const onScroll = (e: Event): void => {
140
+ const el = e.currentTarget as HTMLDivElement
141
+ setViewport((v) => (v.scrollTop === el.scrollTop ? v : { ...v, scrollTop: el.scrollTop }))
142
+ const atTail = el.scrollTop <= TAIL_RESUME_PIX
143
+ if (atTail) {
144
+ if (!tailRef.current) {
145
+ if (resumeTimerRef.current !== null) window.clearTimeout(resumeTimerRef.current)
146
+ resumeTimerRef.current = window.setTimeout(() => {
147
+ if (scrollerRef.current !== null && scrollerRef.current.scrollTop <= TAIL_RESUME_PIX) {
148
+ setTail(true)
149
+ }
150
+ }, RESUME_TAIL_MS)
151
+ }
152
+ } else if (tailRef.current) {
153
+ if (resumeTimerRef.current !== null) {
154
+ window.clearTimeout(resumeTimerRef.current)
155
+ resumeTimerRef.current = null
156
+ }
157
+ setTail(false)
158
+ }
159
+ }
160
+
161
+ useEffect(() => {
162
+ if (!filterMenuOpen) return
163
+ const onDocClick = (e: MouseEvent) => {
164
+ const root = filterMenuRef.current
165
+ if (root !== null && !root.contains(e.target as Node)) setFilterMenuOpen(false)
166
+ }
167
+ document.addEventListener('mousedown', onDocClick)
168
+ return () => document.removeEventListener('mousedown', onDocClick)
169
+ }, [filterMenuOpen])
170
+
171
+ const toggleTail = (): void => {
172
+ const next = !tail
173
+ setTail(next)
174
+ if (next) {
175
+ const el = scrollerRef.current
176
+ if (el !== null) el.scrollTop = 0
177
+ }
178
+ }
179
+
180
+ // Virtualization math, scoped to the active provider. Per-provider
181
+ // ids array is dense (append-only) so visual index i maps to
182
+ // ids[total-1-i].
183
+ const stats = store.statsFor(activeProviderId)
184
+ const allIds = stats.ids
185
+ const allTotal = stats.total
186
+
187
+ // Filter currently walks the hot cache only. Fine for live tailing;
188
+ // proper "filter across entire session" belongs on an indexed IDB
189
+ // query (future work — hook up when filter is non-empty by querying
190
+ // ranges and collecting matches).
191
+ const needle = filter.trim().toLowerCase()
192
+ const filterSpec: FilterSpec = {
193
+ needle,
194
+ dir: dirFilter,
195
+ minBytes,
196
+ excluded: excludedTypes,
197
+ }
198
+ const hasActiveFilter = needle.length > 0
199
+ || dirFilter !== null
200
+ || minBytes > 0
201
+ || excludedTypes.size > 0
202
+
203
+ // Filter pipeline is authoritative — when a filter is active, we
204
+ // query IDB directly via the store's `queryFiltered` cursor. Query
205
+ // is debounced so typing doesn't thrash the cursor. While a query is
206
+ // in-flight, render the previous result to avoid empty-state flashes.
207
+ // New arrivals append their id to `filteredIds` synchronously in the
208
+ // raw-message listener when they match, so live tail keeps working
209
+ // without a re-query.
210
+ const [filteredIds, setFilteredIds] = useState<number[]>([])
211
+ const [filterLoading, setFilterLoading] = useState(false)
212
+ // Ref to the current filter predicate so the raw-message listener
213
+ // can test new arrivals without capturing stale filter state.
214
+ const filterPredicateRef = useRef<((e: LogEntry) => boolean) | null>(null)
215
+ filterPredicateRef.current = hasActiveFilter ? (e: LogEntry) => matchesFilter(e, filterSpec) : null
216
+
217
+ useEffect(() => {
218
+ if (!hasActiveFilter || activeProviderId === null) {
219
+ setFilteredIds([])
220
+ setFilterLoading(false)
221
+ return
222
+ }
223
+ const signal = { aborted: false }
224
+ const predicate = filterPredicateRef.current!
225
+ setFilterLoading(true)
226
+ const handle = (globalThis.setTimeout as unknown as (cb: () => void, ms: number) => number)(
227
+ () => {
228
+ void store.queryFiltered(activeProviderId, predicate, signal).then((result) => {
229
+ if (signal.aborted) return
230
+ setFilteredIds(result)
231
+ setFilterLoading(false)
232
+ })
233
+ },
234
+ 150,
235
+ )
236
+ return () => {
237
+ signal.aborted = true
238
+ clearTimeout(handle)
239
+ }
240
+ // Filter primitives as deps; `filterPredicateRef.current` is read
241
+ // inside but kept stable against renders via the ref.
242
+
243
+ }, [store, activeProviderId, hasActiveFilter, needle, dirFilter, minBytes, excludedTypes])
244
+
245
+ // Live-append: when a new message matches the active filter, extend
246
+ // `filteredIds` without re-querying. Subscribes to raw messages
247
+ // independently of the append-to-store listener higher up.
248
+ useEffect(() => {
249
+ if (!hasActiveFilter || activeProviderId === null) return
250
+ return client.addRawMessageListener(() => {
251
+ const pred = filterPredicateRef.current
252
+ if (pred === null) return
253
+ // The entry was just pushed into the store above us in the
254
+ // listener chain. Look it up by maxId.
255
+ const s = store.statsFor(activeProviderId)
256
+ const latest = s.ids[s.ids.length - 1]
257
+ if (latest === undefined) return
258
+ const entry = store.peek(activeProviderId, latest)
259
+ if (entry === null) return
260
+ if (!pred(entry)) return
261
+ setFilteredIds((prev) => {
262
+ if (prev.length > 0 && prev[prev.length - 1] === latest) return prev
263
+ return [...prev, latest]
264
+ })
265
+ })
266
+ }, [client, store, activeProviderId, hasActiveFilter])
267
+
268
+ const ids = hasActiveFilter ? filteredIds : allIds
269
+ const total = ids.length
270
+
271
+ // Move selection by `delta` positions (newest-first ordering). Used
272
+ // by j/k shortcuts below.
273
+ const moveSelection = (delta: number): void => {
274
+ if (total === 0 || activeProviderId === null) return
275
+ let visualIdx = 0
276
+ if (selectedId !== null) {
277
+ for (let i = 0; i < total; i++) {
278
+ if (ids[total - 1 - i] === selectedId) { visualIdx = i; break }
279
+ }
280
+ }
281
+ const nextIdx = Math.max(0, Math.min(total - 1, visualIdx + delta))
282
+ const nextId = ids[total - 1 - nextIdx]
283
+ if (nextId !== undefined) setSelectedId(nextId)
284
+ }
285
+
286
+ // Keyboard shortcuts. Active whenever the panel is mounted; typing
287
+ // inside inputs passes through so `/`-to-focus doesn't steal regular
288
+ // filter input.
289
+ useEffect(() => {
290
+ const isTypingTarget = (t: EventTarget | null): boolean => {
291
+ if (!(t instanceof HTMLElement)) return false
292
+ const tag = t.tagName
293
+ return tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable
294
+ }
295
+ const onKey = (e: KeyboardEvent) => {
296
+ if (e.key === 'Escape') {
297
+ if (selectedId !== null) { setSelectedId(null); e.preventDefault() }
298
+ return
299
+ }
300
+ if (isTypingTarget(e.target)) return
301
+ if (e.metaKey || e.ctrlKey || e.altKey) return
302
+ if (e.key === '/') {
303
+ filterInputRef.current?.focus()
304
+ filterInputRef.current?.select()
305
+ e.preventDefault()
306
+ } else if (e.key === 'j' || e.key === 'J') {
307
+ moveSelection(1)
308
+ e.preventDefault()
309
+ } else if (e.key === 'k' || e.key === 'K') {
310
+ moveSelection(-1)
311
+ e.preventDefault()
312
+ } else if (e.key === 'Enter' || e.key === ' ') {
313
+ toggleTail()
314
+ e.preventDefault()
315
+ }
316
+ }
317
+ document.addEventListener('keydown', onKey)
318
+ return () => document.removeEventListener('keydown', onKey)
319
+ // eslint-disable-next-line react-hooks/exhaustive-deps
320
+ }, [selectedId, tail, activeProviderId, total])
321
+
322
+ const start = Math.max(0, Math.floor(viewport.scrollTop / ROW_HEIGHT) - OVERSCAN)
323
+ const visibleCount = Math.max(1, Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2)
324
+ const end = Math.min(total, start + visibleCount)
325
+
326
+ // Window-scope prefetch (unfiltered case). When filter is active the
327
+ // bulk prefetch above covers it.
328
+ const startId = end > 0 && total > 0 ? ids[Math.max(0, total - end)] ?? 0 : 0
329
+ const endId = total > 0 ? ids[Math.max(0, total - 1 - start)] ?? 0 : 0
330
+ useEffect(() => {
331
+ if (hasActiveFilter) return
332
+ if (activeProviderId === null || startId === 0 || startId > endId) return
333
+ let needsFetch = false
334
+ for (let i = start; i < end; i++) {
335
+ const id = ids[total - 1 - i]
336
+ if (id === undefined) continue
337
+ if (store.peek(activeProviderId, id) === null) { needsFetch = true; break }
338
+ }
339
+ if (needsFetch) void store.prefetchRange(activeProviderId, startId, endId)
340
+ }, [store, activeProviderId, hasActiveFilter, startId, endId, start, end, total, ids])
341
+
342
+ type Row = LogEntry | { kind: 'loading'; id: number }
343
+ const rows: Row[] = []
344
+ for (let i = start; i < end; i++) {
345
+ const id = ids[total - 1 - i] ?? 0
346
+ const entry = activeProviderId !== null ? store.peek(activeProviderId, id) : null
347
+ if (entry === null) rows.push({ kind: 'loading', id })
348
+ else rows.push(entry)
349
+ }
350
+
351
+ // Selection follows the active provider. Swapping providers clears
352
+ // any cross-provider selection.
353
+ useEffect(() => {
354
+ if (selectedId === null || activeProviderId === null) { setSelectedEntry(null); return }
355
+ const cached = store.peek(activeProviderId, selectedId)
356
+ if (cached !== null) { setSelectedEntry(cached); return }
357
+ let cancelled = false
358
+ void store.prefetchRange(activeProviderId, selectedId, selectedId).then(() => {
359
+ if (cancelled) return
360
+ const fetched = store.peek(activeProviderId, selectedId)
361
+ if (fetched !== null) setSelectedEntry(fetched)
362
+ })
363
+ return () => { cancelled = true }
364
+ }, [store, activeProviderId, selectedId])
365
+ const selected = selectedEntry
366
+
367
+ const backlog = 0
368
+
369
+ return (
370
+ <section class="panel protocol-panel">
371
+ <header class="panel-header protocol-header">
372
+ <span>Protocol</span>
373
+ <input
374
+ type="text"
375
+ class="protocol-filter"
376
+ placeholder="filter… (/)"
377
+ value={filter}
378
+ ref={filterInputRef}
379
+ onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
380
+ />
381
+ {hasActiveFilter && (
382
+ <span class="protocol-match-count" title="Matches / total">
383
+ {filterLoading ? '…' : `${total}/${allTotal}`}
384
+ </span>
385
+ )}
386
+ <button
387
+ type="button"
388
+ class="protocol-btn"
389
+ onClick={() => {
390
+ setFilter('')
391
+ setDirFilter(null)
392
+ setMinBytes(0)
393
+ setExcludedTypes(new Set())
394
+ }}
395
+ disabled={!hasActiveFilter}
396
+ title="Reset all filters"
397
+ >
398
+ ✕ Reset
399
+ </button>
400
+ <div class="protocol-dir-toggle" role="group" aria-label="Direction filter">
401
+ <button
402
+ type="button"
403
+ class={dirFilter === 'in' ? 'protocol-btn protocol-btn-on' : 'protocol-btn'}
404
+ onClick={() => setDirFilter((d) => (d === 'in' ? null : 'in'))}
405
+ title="Show inbound messages only"
406
+ >
407
+ <span class="header-arrow-in">↓</span>
408
+ </button>
409
+ <button
410
+ type="button"
411
+ class={dirFilter === 'out' ? 'protocol-btn protocol-btn-on' : 'protocol-btn'}
412
+ onClick={() => setDirFilter((d) => (d === 'out' ? null : 'out'))}
413
+ title="Show outbound messages only"
414
+ >
415
+ <span class="header-arrow-out">↑</span>
416
+ </button>
417
+ </div>
418
+ <div class="protocol-filter-menu-wrap" ref={filterMenuRef}>
419
+ <button
420
+ type="button"
421
+ class={
422
+ (minBytes > 0 || excludedTypes.size > 0
423
+ ? 'protocol-btn protocol-btn-on'
424
+ : 'protocol-btn')
425
+ }
426
+ onClick={() => setFilterMenuOpen((o) => !o)}
427
+ title="Type + size filters"
428
+ >
429
+ ▼ Filter
430
+ </button>
431
+ {filterMenuOpen && (
432
+ <FilterMenu
433
+ store={store}
434
+ minBytes={minBytes}
435
+ setMinBytes={setMinBytes}
436
+ excludedTypes={excludedTypes}
437
+ setExcludedTypes={setExcludedTypes}
438
+ onClose={() => setFilterMenuOpen(false)}
439
+ />
440
+ )}
441
+ </div>
442
+ <button
443
+ type="button"
444
+ class={tail ? 'protocol-btn protocol-btn-on' : 'protocol-btn'}
445
+ onClick={toggleTail}
446
+ title="Toggle live tail. When off, the viewport anchors to whatever you're currently reading; new entries slide in above."
447
+ >
448
+ {tail ? '◉ Tail' : backlog > 0 ? `○ Tail (+${backlog})` : '○ Tail'}
449
+ </button>
450
+ <button
451
+ type="button"
452
+ class={paused ? 'protocol-btn protocol-btn-on' : 'protocol-btn'}
453
+ onClick={() => setPaused((p) => !p)}
454
+ >
455
+ {paused ? '▶ Resume' : '⏸ Pause'}
456
+ </button>
457
+ <button
458
+ type="button"
459
+ class="protocol-btn"
460
+ onClick={() => void exportSession(activeProviderId)}
461
+ title="Download the session log as JSON"
462
+ disabled={activeProviderId === null}
463
+ >
464
+ ⬇ Export
465
+ </button>
466
+ <button
467
+ type="button"
468
+ class="protocol-btn"
469
+ onClick={() => {
470
+ store.clear()
471
+ setSelectedId(null)
472
+ }}
473
+ title="Drop all entries from memory and IndexedDB."
474
+ >
475
+ Clear
476
+ </button>
477
+ </header>
478
+ <div class="protocol-layout">
479
+ <div class="protocol-body" ref={scrollerRef} onScroll={onScroll}>
480
+ {total === 0 ? (
481
+ <div class="panel-empty">No messages{needle.length > 0 ? ' match' : ' yet'}.</div>
482
+ ) : (
483
+ <div class="protocol-spacer" style={{ height: `${total * ROW_HEIGHT}px` }}>
484
+ {rows.map((r, i) => {
485
+ // Key by virtual slot index so the same DOM element is
486
+ // re-used as ids scroll past — content + top style
487
+ // update in place instead of Preact inserting / moving
488
+ // keyed nodes, which would cause a 1-frame flash when a
489
+ // new entry takes over the top slot. Always render as
490
+ // `<button>` (placeholder just dims + disables click)
491
+ // so slot tag never switches, keeping Preact's in-place
492
+ // update path.
493
+ const offsetTop = (start + i) * ROW_HEIGHT
494
+ const isLoading = 'kind' in r
495
+ const isPlaceholder = isLoading
496
+ const e = isPlaceholder ? null : r
497
+ const id = isPlaceholder ? (r as { id: number }).id : e!.id
498
+ const isHover = !isPlaceholder && hoveredId === id
499
+ const isSelected = !isPlaceholder && selectedId === id
500
+ let cls = 'protocol-row'
501
+ if (isLoading) cls += ' protocol-row-placeholder'
502
+ else {
503
+ cls += ` protocol-row-${e!.direction}`
504
+ if (isSelected) cls += ' protocol-row-selected'
505
+ if (isHover) cls += ' protocol-row-hover'
506
+ }
507
+ return (
508
+ <button
509
+ key={i}
510
+ type="button"
511
+ class={cls}
512
+ style={{ transform: `translateY(${offsetTop}px)`, height: `${ROW_HEIGHT}px` }}
513
+ onClick={isPlaceholder ? undefined : () => setSelectedId((x) => (x === id ? null : id))}
514
+ onMouseEnter={isPlaceholder ? undefined : () => setHoveredId(id)}
515
+ onMouseLeave={isPlaceholder ? undefined : () => setHoveredId((h) => (h === id ? null : h))}
516
+ title={isPlaceholder ? undefined : new Date(e!.at).toISOString()}
517
+ disabled={isPlaceholder}
518
+ >
519
+ <span class="protocol-dir">{isPlaceholder ? '·' : e!.direction === 'in' ? '↓' : '↑'}</span>
520
+ <span class="protocol-time">{isLoading ? 'loading…' : formatTime(e!.at)}</span>
521
+ <span class="protocol-frame">{!isPlaceholder && e!.frame !== undefined ? `#${e!.frame}` : ''}</span>
522
+ <span class="protocol-type">{isPlaceholder ? '' : e!.type}</span>
523
+ <span class="protocol-tag">{isPlaceholder ? '' : e!.tag ?? ''}</span>
524
+ <span class="protocol-size">{isPlaceholder ? '' : formatBytes(e!.bytes)}</span>
525
+ </button>
526
+ )
527
+ })}
528
+ </div>
529
+ )}
530
+ </div>
531
+ {selected !== null && (
532
+ <aside class="protocol-detail">
533
+ <div class="protocol-detail-header">
534
+ <span class="protocol-detail-title">
535
+ {selected.direction === 'in' ? '↓ ' : '↑ '}
536
+ {selected.type}
537
+ {selected.tag !== undefined && <span class="protocol-tag"> · {selected.tag}</span>}
538
+ </span>
539
+ <button
540
+ type="button"
541
+ class="protocol-btn"
542
+ onClick={() => setSelectedId(null)}
543
+ aria-label="Close detail"
544
+ >×</button>
545
+ </div>
546
+ <div class="protocol-detail-meta">
547
+ <span>{formatTime(selected.at)}</span>
548
+ {selected.frame !== undefined && <span>frame #{selected.frame}</span>}
549
+ <span>{formatBytes(selected.bytes)}</span>
550
+ </div>
551
+ <pre
552
+ class="protocol-detail-body"
553
+ dangerouslySetInnerHTML={{ __html: highlightJson(stringify(selected.msg as DebugMessage)) }}
554
+ />
555
+ </aside>
556
+ )}
557
+ </div>
558
+ </section>
559
+ )
560
+ }
561
+
562
+ interface FilterSpec {
563
+ needle: string
564
+ dir: 'in' | 'out' | null
565
+ minBytes: number
566
+ excluded: Set<string>
567
+ }
568
+
569
+ function FilterMenu({
570
+ store,
571
+ minBytes,
572
+ setMinBytes,
573
+ excludedTypes,
574
+ setExcludedTypes,
575
+ onClose: _onClose,
576
+ }: {
577
+ store: ReturnType<typeof getProtocolStore>
578
+ minBytes: number
579
+ setMinBytes: (n: number) => void
580
+ excludedTypes: Set<string>
581
+ setExcludedTypes: (s: Set<string>) => void
582
+ onClose: () => void
583
+ }): preact.JSX.Element {
584
+ const types = Array.from(store.knownTypes).sort()
585
+ const toggle = (t: string): void => {
586
+ const next = new Set(excludedTypes)
587
+ if (next.has(t)) next.delete(t)
588
+ else next.add(t)
589
+ setExcludedTypes(next)
590
+ }
591
+ return (
592
+ <div class="protocol-filter-menu">
593
+ <div class="protocol-filter-menu-section">
594
+ <div class="protocol-filter-menu-title">Minimum size</div>
595
+ <input
596
+ type="range"
597
+ min={0}
598
+ max={65536}
599
+ step={64}
600
+ value={minBytes}
601
+ onInput={(e) => setMinBytes(Number((e.target as HTMLInputElement).value))}
602
+ />
603
+ <span class="protocol-filter-menu-value">
604
+ {minBytes === 0 ? 'any' : minBytes < 1024 ? `${minBytes} B` : `${(minBytes / 1024).toFixed(1)} KB`}
605
+ </span>
606
+ </div>
607
+ <div class="protocol-filter-menu-section">
608
+ <div class="protocol-filter-menu-title">
609
+ Hidden types
610
+ {excludedTypes.size > 0 && (
611
+ <button
612
+ type="button"
613
+ class="protocol-btn protocol-filter-menu-clear"
614
+ onClick={() => setExcludedTypes(new Set())}
615
+ >reset</button>
616
+ )}
617
+ </div>
618
+ <ul class="protocol-filter-types">
619
+ {types.length === 0 ? (
620
+ <li class="panel-empty">No types yet.</li>
621
+ ) : (
622
+ types.map((t) => (
623
+ <li key={t}>
624
+ <label class="protocol-filter-type">
625
+ <input
626
+ type="checkbox"
627
+ checked={!excludedTypes.has(t)}
628
+ onChange={() => toggle(t)}
629
+ />
630
+ <span>{t}</span>
631
+ </label>
632
+ </li>
633
+ ))
634
+ )}
635
+ </ul>
636
+ </div>
637
+ </div>
638
+ )
639
+ }
640
+
641
+ function matchesFilter(e: LogEntry, f: FilterSpec): boolean {
642
+ if (f.dir !== null && e.direction !== f.dir) return false
643
+ if (f.minBytes > 0 && e.bytes < f.minBytes) return false
644
+ if (f.excluded.has(e.type)) return false
645
+ if (f.needle.length === 0) return true
646
+ if (e.type.includes(f.needle)) return true
647
+ if (e.tag !== undefined && e.tag.toLowerCase().includes(f.needle)) return true
648
+ return false
649
+ }
650
+
651
+ function extractTag(msg: DebugMessage): string | undefined {
652
+ if (msg.type === 'data') {
653
+ const features = (msg as unknown as { payload?: { features?: Record<string, unknown> } }).payload?.features
654
+ if (features !== undefined) {
655
+ const keys = Object.keys(features).filter((k) => features[k] != null)
656
+ return keys.length > 0 ? keys.join(',') : 'empty'
657
+ }
658
+ }
659
+ const p = (msg as unknown as { payload?: { name?: string } }).payload
660
+ if (p !== undefined && typeof p === 'object' && typeof p.name === 'string') return p.name
661
+ return undefined
662
+ }
663
+
664
+ function extractFrame(msg: DebugMessage): number | undefined {
665
+ const f = (msg as unknown as { payload?: { frame?: number } }).payload?.frame
666
+ return typeof f === 'number' ? f : undefined
667
+ }
668
+
669
+ function estimateBytes(msg: DebugMessage): number {
670
+ let bytes = 0
671
+ const seen = new WeakSet<object>()
672
+ const walk = (v: unknown): void => {
673
+ if (v === null || v === undefined) return
674
+ if (typeof v === 'string') { bytes += v.length; return }
675
+ if (typeof v === 'number' || typeof v === 'boolean') { bytes += 8; return }
676
+ if (v instanceof ArrayBuffer) { bytes += v.byteLength; return }
677
+ if (ArrayBuffer.isView(v)) { bytes += v.byteLength; return }
678
+ if (typeof v === 'object') {
679
+ if (seen.has(v)) return
680
+ seen.add(v)
681
+ if (Array.isArray(v)) { for (const x of v) walk(x); return }
682
+ for (const k in v as Record<string, unknown>) {
683
+ bytes += k.length
684
+ walk((v as Record<string, unknown>)[k])
685
+ }
686
+ }
687
+ }
688
+ walk(msg)
689
+ return bytes
690
+ }
691
+
692
+ function formatBytes(n: number): string {
693
+ if (n < 1024) return `${n}B`
694
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`
695
+ return `${(n / (1024 * 1024)).toFixed(1)}M`
696
+ }
697
+
698
+ function formatTime(at: number): string {
699
+ const d = new Date(at)
700
+ const ms = d.getMilliseconds().toString().padStart(3, '0')
701
+ return `${d.toLocaleTimeString([], { hour12: false })}.${ms}`
702
+ }
703
+
704
+ function stringify(msg: DebugMessage): string {
705
+ try {
706
+ return JSON.stringify(msg, (_k, v) => {
707
+ if (v instanceof ArrayBuffer) return `[ArrayBuffer ${v.byteLength}B]`
708
+ if (ArrayBuffer.isView(v)) return `[${v.constructor.name} ${v.byteLength}B]`
709
+ return v as unknown
710
+ }, 2)
711
+ } catch {
712
+ return '[unserialisable]'
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Tiny JSON syntax highlighter. Takes a `JSON.stringify(obj, null, 2)`
718
+ * string and wraps tokens (keys, strings, numbers, booleans, null) in
719
+ * spans with `.json-*` classes. Not a real parser — the input always
720
+ * comes from `JSON.stringify` so the token grammar is well-formed and a
721
+ * single regex pass is enough. HTML-escapes first to avoid injecting
722
+ * arbitrary markup from message payloads.
723
+ */
724
+ function highlightJson(json: string): string {
725
+ const escaped = json
726
+ .replace(/&/g, '&amp;')
727
+ .replace(/</g, '&lt;')
728
+ .replace(/>/g, '&gt;')
729
+ return escaped.replace(
730
+ /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
731
+ (match) => {
732
+ let cls = 'json-num'
733
+ if (match.startsWith('"')) {
734
+ cls = match.endsWith(':') || match.endsWith(': ') ? 'json-key' : 'json-str'
735
+ } else if (match === 'true' || match === 'false') {
736
+ cls = 'json-bool'
737
+ } else if (match === 'null') {
738
+ cls = 'json-null'
739
+ }
740
+ return `<span class="${cls}">${match}</span>`
741
+ },
742
+ )
743
+ }