summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components/debug-bar.tsx
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-09 15:57:19 -0500
committerAdam <[email protected]>2026-03-09 15:57:24 -0500
commitee18c9976e00ebf0162fe1dcc47b209e0507b3e5 (patch)
tree7c11e23925ffa12d3a14bed69b8f9988820cf5a8 /packages/app/src/components/debug-bar.tsx
parent794532928f76c197181bc80944e8be1f6e5eda9a (diff)
downloadopencode-ee18c9976e00ebf0162fe1dcc47b209e0507b3e5.tar.gz
opencode-ee18c9976e00ebf0162fe1dcc47b209e0507b3e5.zip
chore(app): dev stats
Diffstat (limited to 'packages/app/src/components/debug-bar.tsx')
-rw-r--r--packages/app/src/components/debug-bar.tsx432
1 files changed, 432 insertions, 0 deletions
diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx
new file mode 100644
index 000000000..93a4654f8
--- /dev/null
+++ b/packages/app/src/components/debug-bar.tsx
@@ -0,0 +1,432 @@
+import { useIsRouting, useLocation } from "@solidjs/router"
+import { batch, createEffect, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+
+type Mem = Performance & {
+ memory?: {
+ usedJSHeapSize: number
+ jsHeapSizeLimit: number
+ }
+}
+
+type Evt = PerformanceEntry & {
+ interactionId?: number
+ processingStart?: number
+}
+
+type Shift = PerformanceEntry & {
+ hadRecentInput: boolean
+ value: number
+}
+
+type Obs = PerformanceObserverInit & {
+ durationThreshold?: number
+}
+
+const span = 5000
+
+const ms = (n?: number, d = 0) => {
+ if (n === undefined || Number.isNaN(n)) return "n/a"
+ return `${n.toFixed(d)}ms`
+}
+
+const time = (n?: number) => {
+ if (n === undefined || Number.isNaN(n)) return "n/a"
+ return `${Math.round(n)}`
+}
+
+const mb = (n?: number) => {
+ if (n === undefined || Number.isNaN(n)) return "n/a"
+ const v = n / 1024 / 1024
+ return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
+}
+
+const bad = (n: number | undefined, limit: number, low = false) => {
+ if (n === undefined || Number.isNaN(n)) return false
+ return low ? n < limit : n > limit
+}
+
+const session = (path: string) => path.includes("/session")
+
+function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string }) {
+ return (
+ <Tooltip value={props.tip} placement="left">
+ <div class="flex w-full flex-col items-center px-0.5 py-1 text-center">
+ <div class="text-[7px] font-black uppercase tracking-[0.04em] opacity-70 leading-none">{props.label}</div>
+ <div
+ classList={{
+ "text-[9px] font-semibold leading-none tabular-nums": true,
+ "text-text-on-critical-base": !!props.bad,
+ "opacity-70": !!props.dim,
+ }}
+ >
+ {props.value}
+ </div>
+ </div>
+ </Tooltip>
+ )
+}
+
+export function DebugBar() {
+ const location = useLocation()
+ const routing = useIsRouting()
+ const [state, setState] = createStore({
+ cls: undefined as number | undefined,
+ delay: undefined as number | undefined,
+ fps: undefined as number | undefined,
+ gap: undefined as number | undefined,
+ heap: {
+ limit: undefined as number | undefined,
+ used: undefined as number | undefined,
+ },
+ inp: undefined as number | undefined,
+ jank: undefined as number | undefined,
+ long: {
+ block: undefined as number | undefined,
+ count: undefined as number | undefined,
+ max: undefined as number | undefined,
+ },
+ nav: {
+ dur: undefined as number | undefined,
+ pending: false,
+ },
+ })
+
+ const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
+ const heapv = () => {
+ const value = heap()
+ if (value === undefined) return "n/a"
+ return `${Math.round(value * 100)}%`
+ }
+ const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
+ const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
+
+ let prev = ""
+ let start = 0
+ let init = false
+ let one = 0
+ let two = 0
+
+ createEffect(() => {
+ const busy = routing()
+ const next = `${location.pathname}${location.search}`
+
+ if (!init) {
+ init = true
+ prev = next
+ return
+ }
+
+ if (busy) {
+ if (one !== 0) cancelAnimationFrame(one)
+ if (two !== 0) cancelAnimationFrame(two)
+ one = 0
+ two = 0
+ if (start !== 0) return
+ start = performance.now()
+ if (session(prev)) setState("nav", { dur: undefined, pending: true })
+ return
+ }
+
+ if (start === 0) {
+ prev = next
+ return
+ }
+
+ const at = start
+ const from = prev
+ start = 0
+ prev = next
+
+ if (!(session(from) || session(next))) return
+
+ if (one !== 0) cancelAnimationFrame(one)
+ if (two !== 0) cancelAnimationFrame(two)
+ one = requestAnimationFrame(() => {
+ one = 0
+ two = requestAnimationFrame(() => {
+ two = 0
+ setState("nav", { dur: performance.now() - at, pending: false })
+ })
+ })
+ })
+
+ onMount(() => {
+ const obs: PerformanceObserver[] = []
+ const fps: Array<{ at: number; dur: number }> = []
+ const long: Array<{ at: number; dur: number }> = []
+ const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
+ let hasLong = false
+ let poll: number | undefined
+ let raf = 0
+ let last = 0
+ let snap = 0
+
+ const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
+ while (list[0] && at - list[0].at > span) list.shift()
+ }
+
+ const syncFrame = (at: number) => {
+ trim(fps, span, at)
+ const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
+ const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
+ const jank = fps.filter((entry) => entry.dur > 32).length
+ batch(() => {
+ setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
+ setState("gap", gap > 0 ? gap : undefined)
+ setState("jank", jank)
+ })
+ }
+
+ const syncLong = (at = performance.now()) => {
+ if (!hasLong) return
+ trim(long, span, at)
+ const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
+ const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
+ setState("long", { block, count: long.length, max })
+ }
+
+ const syncInp = (at = performance.now()) => {
+ for (const [key, entry] of seen) {
+ if (at - entry.at > span) seen.delete(key)
+ }
+ let delay = 0
+ let inp = 0
+ for (const entry of seen.values()) {
+ delay = Math.max(delay, entry.delay)
+ inp = Math.max(inp, entry.dur)
+ }
+ batch(() => {
+ setState("delay", delay > 0 ? delay : undefined)
+ setState("inp", inp > 0 ? inp : undefined)
+ })
+ }
+
+ const syncHeap = () => {
+ const mem = (performance as Mem).memory
+ if (!mem) return
+ setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
+ }
+
+ const reset = () => {
+ fps.length = 0
+ long.length = 0
+ seen.clear()
+ last = 0
+ snap = 0
+ batch(() => {
+ setState("fps", undefined)
+ setState("gap", undefined)
+ setState("jank", undefined)
+ setState("delay", undefined)
+ setState("inp", undefined)
+ if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
+ })
+ }
+
+ const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
+ if (typeof PerformanceObserver === "undefined") return false
+ if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
+ const ob = new PerformanceObserver((list) => fn(list.getEntries()))
+ try {
+ ob.observe(init)
+ obs.push(ob)
+ return true
+ } catch {
+ ob.disconnect()
+ return false
+ }
+ }
+
+ if (
+ watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
+ const add = entries.reduce((sum, entry) => {
+ const item = entry as Shift
+ if (item.hadRecentInput) return sum
+ return sum + item.value
+ }, 0)
+ if (add === 0) return
+ setState("cls", (value) => (value ?? 0) + add)
+ })
+ ) {
+ setState("cls", 0)
+ }
+
+ if (
+ watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
+ const at = performance.now()
+ long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
+ syncLong(at)
+ })
+ ) {
+ hasLong = true
+ setState("long", { block: 0, count: 0, max: 0 })
+ }
+
+ watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
+ for (const raw of entries) {
+ const entry = raw as Evt
+ if (entry.duration < 16) continue
+ const key =
+ entry.interactionId && entry.interactionId > 0
+ ? entry.interactionId
+ : `${entry.name}:${Math.round(entry.startTime)}`
+ const prev = seen.get(key)
+ const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
+ seen.set(key, {
+ at: entry.startTime,
+ delay: Math.max(prev?.delay ?? 0, delay),
+ dur: Math.max(prev?.dur ?? 0, entry.duration),
+ })
+ if (seen.size <= 200) continue
+ const first = seen.keys().next().value
+ if (first !== undefined) seen.delete(first)
+ }
+ syncInp()
+ })
+
+ const loop = (at: number) => {
+ if (document.visibilityState !== "visible") {
+ raf = 0
+ return
+ }
+
+ if (last === 0) {
+ last = at
+ raf = requestAnimationFrame(loop)
+ return
+ }
+
+ fps.push({ at, dur: at - last })
+ last = at
+
+ if (at - snap >= 250) {
+ snap = at
+ syncFrame(at)
+ }
+
+ raf = requestAnimationFrame(loop)
+ }
+
+ const stop = () => {
+ if (raf !== 0) cancelAnimationFrame(raf)
+ raf = 0
+ if (poll === undefined) return
+ clearInterval(poll)
+ poll = undefined
+ }
+
+ const start = () => {
+ if (document.visibilityState !== "visible") return
+ if (poll === undefined) {
+ poll = window.setInterval(() => {
+ syncLong()
+ syncInp()
+ syncHeap()
+ }, 1000)
+ }
+ if (raf !== 0) return
+ raf = requestAnimationFrame(loop)
+ }
+
+ const vis = () => {
+ if (document.visibilityState !== "visible") {
+ stop()
+ return
+ }
+ reset()
+ start()
+ }
+
+ syncHeap()
+ start()
+ document.addEventListener("visibilitychange", vis)
+
+ onCleanup(() => {
+ if (one !== 0) cancelAnimationFrame(one)
+ if (two !== 0) cancelAnimationFrame(two)
+ stop()
+ document.removeEventListener("visibilitychange", vis)
+ for (const ob of obs) ob.disconnect()
+ })
+ })
+
+ return (
+ <aside
+ aria-label="Development performance diagnostics"
+ class="pointer-events-auto h-full min-h-0 w-[36px] shrink-0 overflow-y-auto text-text-on-interactive-base no-scrollbar sm:w-[38px]"
+ style={{ "background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)" }}
+ >
+ <div class="flex min-h-full flex-col gap-0.5 py-2 font-mono">
+ <Cell
+ label="NAV"
+ tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
+ value={navv()}
+ bad={bad(state.nav.dur, 400)}
+ dim={state.nav.dur === undefined && !state.nav.pending}
+ />
+ <Cell
+ label="FPS"
+ tip="Rolling frames per second over the last 5 seconds."
+ value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
+ bad={bad(state.fps, 50, true)}
+ dim={state.fps === undefined}
+ />
+ <Cell
+ label="FRM"
+ tip="Worst frame time over the last 5 seconds."
+ value={time(state.gap)}
+ bad={bad(state.gap, 50)}
+ dim={state.gap === undefined}
+ />
+ <Cell
+ label="JNK"
+ tip="Frames over 32ms in the last 5 seconds."
+ value={state.jank === undefined ? "n/a" : `${state.jank}`}
+ bad={bad(state.jank, 8)}
+ dim={state.jank === undefined}
+ />
+ <Cell
+ label="LNG"
+ tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
+ value={longv()}
+ bad={bad(state.long.block, 200)}
+ dim={state.long.count === undefined}
+ />
+ <Cell
+ label="DLY"
+ tip="Worst observed input delay in the last 5 seconds."
+ value={time(state.delay)}
+ bad={bad(state.delay, 100)}
+ dim={state.delay === undefined}
+ />
+ <Cell
+ label="INP"
+ tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
+ value={time(state.inp)}
+ bad={bad(state.inp, 200)}
+ dim={state.inp === undefined}
+ />
+ <Cell
+ label="CLS"
+ tip="Cumulative layout shift for the current app lifetime."
+ value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
+ bad={bad(state.cls, 0.1)}
+ dim={state.cls === undefined}
+ />
+ <Cell
+ label="MEM"
+ tip={
+ state.heap.used === undefined
+ ? "Used JS heap vs heap limit. Chromium only."
+ : `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
+ }
+ value={heapv()}
+ bad={bad(heap(), 0.8)}
+ dim={state.heap.used === undefined}
+ />
+ </div>
+ </aside>
+ )
+}