debug-bar.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import { useIsRouting, useLocation } from "@solidjs/router"
  2. import { batch, createEffect, onCleanup, onMount } from "solid-js"
  3. import { createStore } from "solid-js/store"
  4. import { makeEventListener } from "@solid-primitives/event-listener"
  5. import { Tooltip } from "@opencode-ai/ui/tooltip"
  6. import { useLanguage } from "@/context/language"
  7. type Mem = Performance & {
  8. memory?: {
  9. usedJSHeapSize: number
  10. jsHeapSizeLimit: number
  11. }
  12. }
  13. type Evt = PerformanceEntry & {
  14. interactionId?: number
  15. processingStart?: number
  16. }
  17. type Shift = PerformanceEntry & {
  18. hadRecentInput: boolean
  19. value: number
  20. }
  21. type Obs = PerformanceObserverInit & {
  22. durationThreshold?: number
  23. }
  24. const span = 5000
  25. const ms = (n?: number, d = 0) => {
  26. if (n === undefined || Number.isNaN(n)) return
  27. return `${n.toFixed(d)}ms`
  28. }
  29. const time = (n?: number) => {
  30. if (n === undefined || Number.isNaN(n)) return
  31. return `${Math.round(n)}`
  32. }
  33. const mb = (n?: number) => {
  34. if (n === undefined || Number.isNaN(n)) return
  35. const v = n / 1024 / 1024
  36. return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
  37. }
  38. const bad = (n: number | undefined, limit: number, low = false) => {
  39. if (n === undefined || Number.isNaN(n)) return false
  40. return low ? n < limit : n > limit
  41. }
  42. const session = (path: string) => path.includes("/session")
  43. function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
  44. return (
  45. <Tooltip value={props.tip} placement="top">
  46. <div
  47. classList={{
  48. "flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
  49. "col-span-2": !!props.wide,
  50. }}
  51. >
  52. <div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
  53. <div
  54. classList={{
  55. "text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
  56. "text-text-on-critical-base": !!props.bad,
  57. "opacity-70": !!props.dim,
  58. }}
  59. >
  60. {props.value}
  61. </div>
  62. </div>
  63. </Tooltip>
  64. )
  65. }
  66. export function DebugBar() {
  67. const language = useLanguage()
  68. const location = useLocation()
  69. const routing = useIsRouting()
  70. const [state, setState] = createStore({
  71. cls: undefined as number | undefined,
  72. delay: undefined as number | undefined,
  73. fps: undefined as number | undefined,
  74. gap: undefined as number | undefined,
  75. heap: {
  76. limit: undefined as number | undefined,
  77. used: undefined as number | undefined,
  78. },
  79. inp: undefined as number | undefined,
  80. jank: undefined as number | undefined,
  81. long: {
  82. block: undefined as number | undefined,
  83. count: undefined as number | undefined,
  84. max: undefined as number | undefined,
  85. },
  86. nav: {
  87. dur: undefined as number | undefined,
  88. pending: false,
  89. },
  90. })
  91. const na = () => language.t("debugBar.na")
  92. const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
  93. const heapv = () => {
  94. const value = heap()
  95. if (value === undefined) return na()
  96. return `${Math.round(value * 100)}%`
  97. }
  98. const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
  99. const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
  100. let prev = ""
  101. let start = 0
  102. let init = false
  103. let one = 0
  104. let two = 0
  105. createEffect(() => {
  106. const busy = routing()
  107. const next = `${location.pathname}${location.search}`
  108. if (!init) {
  109. init = true
  110. prev = next
  111. return
  112. }
  113. if (busy) {
  114. if (one !== 0) cancelAnimationFrame(one)
  115. if (two !== 0) cancelAnimationFrame(two)
  116. one = 0
  117. two = 0
  118. if (start !== 0) return
  119. start = performance.now()
  120. if (session(prev)) setState("nav", { dur: undefined, pending: true })
  121. return
  122. }
  123. if (start === 0) {
  124. prev = next
  125. return
  126. }
  127. const at = start
  128. const from = prev
  129. start = 0
  130. prev = next
  131. if (!(session(from) || session(next))) return
  132. if (one !== 0) cancelAnimationFrame(one)
  133. if (two !== 0) cancelAnimationFrame(two)
  134. one = requestAnimationFrame(() => {
  135. one = 0
  136. two = requestAnimationFrame(() => {
  137. two = 0
  138. setState("nav", { dur: performance.now() - at, pending: false })
  139. })
  140. })
  141. })
  142. onMount(() => {
  143. const obs: PerformanceObserver[] = []
  144. const fps: Array<{ at: number; dur: number }> = []
  145. const long: Array<{ at: number; dur: number }> = []
  146. const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
  147. let hasLong = false
  148. let poll: number | undefined
  149. let raf = 0
  150. let last = 0
  151. let snap = 0
  152. const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
  153. while (list[0] && at - list[0].at > span) list.shift()
  154. }
  155. const syncFrame = (at: number) => {
  156. trim(fps, span, at)
  157. const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
  158. const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
  159. const jank = fps.filter((entry) => entry.dur > 32).length
  160. batch(() => {
  161. setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
  162. setState("gap", gap > 0 ? gap : undefined)
  163. setState("jank", jank)
  164. })
  165. }
  166. const syncLong = (at = performance.now()) => {
  167. if (!hasLong) return
  168. trim(long, span, at)
  169. const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
  170. const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
  171. setState("long", { block, count: long.length, max })
  172. }
  173. const syncInp = (at = performance.now()) => {
  174. for (const [key, entry] of seen) {
  175. if (at - entry.at > span) seen.delete(key)
  176. }
  177. let delay = 0
  178. let inp = 0
  179. for (const entry of seen.values()) {
  180. delay = Math.max(delay, entry.delay)
  181. inp = Math.max(inp, entry.dur)
  182. }
  183. batch(() => {
  184. setState("delay", delay > 0 ? delay : undefined)
  185. setState("inp", inp > 0 ? inp : undefined)
  186. })
  187. }
  188. const syncHeap = () => {
  189. const mem = (performance as Mem).memory
  190. if (!mem) return
  191. setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
  192. }
  193. const reset = () => {
  194. fps.length = 0
  195. long.length = 0
  196. seen.clear()
  197. last = 0
  198. snap = 0
  199. batch(() => {
  200. setState("fps", undefined)
  201. setState("gap", undefined)
  202. setState("jank", undefined)
  203. setState("delay", undefined)
  204. setState("inp", undefined)
  205. if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
  206. })
  207. }
  208. const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
  209. if (typeof PerformanceObserver === "undefined") return false
  210. if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
  211. const ob = new PerformanceObserver((list) => fn(list.getEntries()))
  212. try {
  213. ob.observe(init)
  214. obs.push(ob)
  215. return true
  216. } catch {
  217. ob.disconnect()
  218. return false
  219. }
  220. }
  221. if (
  222. watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
  223. const add = entries.reduce((sum, entry) => {
  224. const item = entry as Shift
  225. if (item.hadRecentInput) return sum
  226. return sum + item.value
  227. }, 0)
  228. if (add === 0) return
  229. setState("cls", (value) => (value ?? 0) + add)
  230. })
  231. ) {
  232. setState("cls", 0)
  233. }
  234. if (
  235. watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
  236. const at = performance.now()
  237. long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
  238. syncLong(at)
  239. })
  240. ) {
  241. hasLong = true
  242. setState("long", { block: 0, count: 0, max: 0 })
  243. }
  244. watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
  245. for (const raw of entries) {
  246. const entry = raw as Evt
  247. if (entry.duration < 16) continue
  248. const key =
  249. entry.interactionId && entry.interactionId > 0
  250. ? entry.interactionId
  251. : `${entry.name}:${Math.round(entry.startTime)}`
  252. const prev = seen.get(key)
  253. const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
  254. seen.set(key, {
  255. at: entry.startTime,
  256. delay: Math.max(prev?.delay ?? 0, delay),
  257. dur: Math.max(prev?.dur ?? 0, entry.duration),
  258. })
  259. if (seen.size <= 200) continue
  260. const first = seen.keys().next().value
  261. if (first !== undefined) seen.delete(first)
  262. }
  263. syncInp()
  264. })
  265. const loop = (at: number) => {
  266. if (document.visibilityState !== "visible") {
  267. raf = 0
  268. return
  269. }
  270. if (last === 0) {
  271. last = at
  272. raf = requestAnimationFrame(loop)
  273. return
  274. }
  275. fps.push({ at, dur: at - last })
  276. last = at
  277. if (at - snap >= 250) {
  278. snap = at
  279. syncFrame(at)
  280. }
  281. raf = requestAnimationFrame(loop)
  282. }
  283. const stop = () => {
  284. if (raf !== 0) cancelAnimationFrame(raf)
  285. raf = 0
  286. if (poll === undefined) return
  287. clearInterval(poll)
  288. poll = undefined
  289. }
  290. const start = () => {
  291. if (document.visibilityState !== "visible") return
  292. if (poll === undefined) {
  293. poll = window.setInterval(() => {
  294. syncLong()
  295. syncInp()
  296. syncHeap()
  297. }, 1000)
  298. }
  299. if (raf !== 0) return
  300. raf = requestAnimationFrame(loop)
  301. }
  302. const vis = () => {
  303. if (document.visibilityState !== "visible") {
  304. stop()
  305. return
  306. }
  307. reset()
  308. start()
  309. }
  310. syncHeap()
  311. start()
  312. makeEventListener(document, "visibilitychange", vis)
  313. onCleanup(() => {
  314. if (one !== 0) cancelAnimationFrame(one)
  315. if (two !== 0) cancelAnimationFrame(two)
  316. stop()
  317. for (const ob of obs) ob.disconnect()
  318. })
  319. })
  320. return (
  321. <aside
  322. aria-label={language.t("debugBar.ariaLabel")}
  323. class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
  324. >
  325. <div class="grid grid-cols-5 gap-px font-mono">
  326. <Cell
  327. label={language.t("debugBar.nav.label")}
  328. tip={language.t("debugBar.nav.tip")}
  329. value={navv()}
  330. bad={bad(state.nav.dur, 400)}
  331. dim={state.nav.dur === undefined && !state.nav.pending}
  332. />
  333. <Cell
  334. label={language.t("debugBar.fps.label")}
  335. tip={language.t("debugBar.fps.tip")}
  336. value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
  337. bad={bad(state.fps, 50, true)}
  338. dim={state.fps === undefined}
  339. />
  340. <Cell
  341. label={language.t("debugBar.frame.label")}
  342. tip={language.t("debugBar.frame.tip")}
  343. value={time(state.gap) ?? na()}
  344. bad={bad(state.gap, 50)}
  345. dim={state.gap === undefined}
  346. />
  347. <Cell
  348. label={language.t("debugBar.jank.label")}
  349. tip={language.t("debugBar.jank.tip")}
  350. value={state.jank === undefined ? na() : `${state.jank}`}
  351. bad={bad(state.jank, 8)}
  352. dim={state.jank === undefined}
  353. />
  354. <Cell
  355. label={language.t("debugBar.long.label")}
  356. tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
  357. value={longv()}
  358. bad={bad(state.long.block, 200)}
  359. dim={state.long.count === undefined}
  360. />
  361. <Cell
  362. label={language.t("debugBar.delay.label")}
  363. tip={language.t("debugBar.delay.tip")}
  364. value={time(state.delay) ?? na()}
  365. bad={bad(state.delay, 100)}
  366. dim={state.delay === undefined}
  367. />
  368. <Cell
  369. label={language.t("debugBar.inp.label")}
  370. tip={language.t("debugBar.inp.tip")}
  371. value={time(state.inp) ?? na()}
  372. bad={bad(state.inp, 200)}
  373. dim={state.inp === undefined}
  374. />
  375. <Cell
  376. label={language.t("debugBar.cls.label")}
  377. tip={language.t("debugBar.cls.tip")}
  378. value={state.cls === undefined ? na() : state.cls.toFixed(2)}
  379. bad={bad(state.cls, 0.1)}
  380. dim={state.cls === undefined}
  381. />
  382. <Cell
  383. label={language.t("debugBar.mem.label")}
  384. tip={
  385. state.heap.used === undefined
  386. ? language.t("debugBar.mem.tipUnavailable")
  387. : language.t("debugBar.mem.tip", {
  388. used: mb(state.heap.used) ?? na(),
  389. limit: mb(state.heap.limit) ?? na(),
  390. })
  391. }
  392. value={heapv()}
  393. bad={bad(heap(), 0.8)}
  394. dim={state.heap.used === undefined}
  395. wide
  396. />
  397. </div>
  398. </aside>
  399. )
  400. }