debug-bar.tsx 16 KB

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