tool-utils.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
  2. import {
  3. animate,
  4. type AnimationPlaybackControls,
  5. clearFadeStyles,
  6. clearMaskStyles,
  7. COLLAPSIBLE_SPRING,
  8. GROW_SPRING,
  9. WIPE_MASK,
  10. } from "./motion"
  11. import { prefersReducedMotion } from "../hooks/use-reduced-motion"
  12. import type { ToolPart } from "@opencode-ai/sdk/v2"
  13. export const TEXT_RENDER_THROTTLE_MS = 100
  14. export function createThrottledValue(getValue: () => string) {
  15. const [value, setValue] = createSignal(getValue())
  16. let timeout: ReturnType<typeof setTimeout> | undefined
  17. let last = 0
  18. createEffect(() => {
  19. const next = getValue()
  20. const now = Date.now()
  21. const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
  22. if (remaining <= 0) {
  23. if (timeout) {
  24. clearTimeout(timeout)
  25. timeout = undefined
  26. }
  27. last = now
  28. setValue(next)
  29. return
  30. }
  31. if (timeout) clearTimeout(timeout)
  32. timeout = setTimeout(() => {
  33. last = Date.now()
  34. setValue(next)
  35. timeout = undefined
  36. }, remaining)
  37. })
  38. onCleanup(() => {
  39. if (timeout) clearTimeout(timeout)
  40. })
  41. return value
  42. }
  43. export function busy(status: string | undefined) {
  44. return status === "pending" || status === "running"
  45. }
  46. export function hold(state: () => boolean, wait = 2000) {
  47. const [live, setLive] = createSignal(state())
  48. let timer: ReturnType<typeof setTimeout> | undefined
  49. createEffect(() => {
  50. if (state()) {
  51. if (timer) clearTimeout(timer)
  52. timer = undefined
  53. setLive(true)
  54. return
  55. }
  56. if (timer) clearTimeout(timer)
  57. timer = setTimeout(() => {
  58. timer = undefined
  59. setLive(false)
  60. }, wait)
  61. })
  62. onCleanup(() => {
  63. if (timer) clearTimeout(timer)
  64. })
  65. return live
  66. }
  67. export function updateScrollMask(el: HTMLElement, fade = 12) {
  68. const { scrollTop, scrollHeight, clientHeight } = el
  69. const overflow = scrollHeight - clientHeight
  70. if (overflow <= 1) {
  71. el.style.maskImage = ""
  72. el.style.webkitMaskImage = ""
  73. return
  74. }
  75. const top = scrollTop > 1
  76. const bottom = scrollTop < overflow - 1
  77. const mask =
  78. top && bottom
  79. ? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)`
  80. : top
  81. ? `linear-gradient(to bottom, transparent 0, black ${fade}px)`
  82. : bottom
  83. ? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)`
  84. : ""
  85. el.style.maskImage = mask
  86. el.style.webkitMaskImage = mask
  87. }
  88. export function useCollapsible(options: {
  89. content: () => HTMLElement | undefined
  90. body: () => HTMLElement | undefined
  91. open: () => boolean
  92. measure?: () => number
  93. onOpen?: () => void
  94. }) {
  95. let heightAnim: AnimationPlaybackControls | undefined
  96. let fadeAnim: AnimationPlaybackControls | undefined
  97. let gen = 0
  98. createEffect(
  99. on(
  100. options.open,
  101. (isOpen) => {
  102. const content = options.content()
  103. const body = options.body()
  104. if (!content || !body) return
  105. heightAnim?.stop()
  106. fadeAnim?.stop()
  107. const id = ++gen
  108. if (isOpen) {
  109. content.style.display = ""
  110. content.style.height = "0px"
  111. body.style.opacity = "0"
  112. body.style.filter = "blur(2px)"
  113. fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
  114. queueMicrotask(() => {
  115. if (gen !== id) return
  116. const c = options.content()
  117. if (!c) return
  118. const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
  119. heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
  120. heightAnim.finished.then(
  121. () => {
  122. if (gen !== id) return
  123. c.style.height = "auto"
  124. options.onOpen?.()
  125. },
  126. () => {},
  127. )
  128. })
  129. return
  130. }
  131. const h = content.getBoundingClientRect().height
  132. heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
  133. fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
  134. heightAnim.finished.then(
  135. () => {
  136. if (gen !== id) return
  137. content.style.display = "none"
  138. },
  139. () => {},
  140. )
  141. },
  142. { defer: true },
  143. ),
  144. )
  145. onCleanup(() => {
  146. ++gen
  147. heightAnim?.stop()
  148. fadeAnim?.stop()
  149. })
  150. }
  151. export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
  152. const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
  153. const [settled, setSettled] = createSignal(false)
  154. createEffect(() => {
  155. if (!anyRunning() && !working?.()) setSettled(true)
  156. })
  157. return createMemo(() => !settled() && (!!working?.() || anyRunning()))
  158. }
  159. export function useRowWipe(opts: {
  160. id: () => string
  161. text: () => string | undefined
  162. ref: () => HTMLElement | undefined
  163. seen: Set<string>
  164. }) {
  165. const reduce = prefersReducedMotion
  166. createEffect(() => {
  167. const id = opts.id()
  168. const txt = opts.text()
  169. const el = opts.ref()
  170. if (!el) return
  171. if (!txt) {
  172. clearFadeStyles(el)
  173. clearMaskStyles(el)
  174. return
  175. }
  176. if (reduce() || typeof window === "undefined") {
  177. clearFadeStyles(el)
  178. clearMaskStyles(el)
  179. return
  180. }
  181. if (opts.seen.has(id)) {
  182. clearFadeStyles(el)
  183. clearMaskStyles(el)
  184. return
  185. }
  186. opts.seen.add(id)
  187. el.style.maskImage = WIPE_MASK
  188. el.style.webkitMaskImage = WIPE_MASK
  189. el.style.maskSize = "240% 100%"
  190. el.style.webkitMaskSize = "240% 100%"
  191. el.style.maskRepeat = "no-repeat"
  192. el.style.webkitMaskRepeat = "no-repeat"
  193. el.style.maskPosition = "100% 0%"
  194. el.style.webkitMaskPosition = "100% 0%"
  195. el.style.opacity = "0"
  196. el.style.filter = "blur(2px)"
  197. el.style.transform = "translateX(-0.06em)"
  198. let done = false
  199. const clear = () => {
  200. if (done) return
  201. done = true
  202. clearFadeStyles(el)
  203. clearMaskStyles(el)
  204. }
  205. if (typeof requestAnimationFrame !== "function") {
  206. clear()
  207. return
  208. }
  209. let anim: AnimationPlaybackControls | undefined
  210. let frame: number | undefined = requestAnimationFrame(() => {
  211. frame = undefined
  212. const node = opts.ref()
  213. if (!node) return
  214. anim = animate(
  215. node,
  216. {
  217. opacity: [0, 1],
  218. filter: ["blur(2px)", "blur(0px)"],
  219. transform: ["translateX(-0.06em)", "translateX(0)"],
  220. maskPosition: "0% 0%",
  221. },
  222. GROW_SPRING,
  223. )
  224. anim.finished.catch(() => {}).finally(clear)
  225. })
  226. onCleanup(() => {
  227. if (frame !== undefined) {
  228. cancelAnimationFrame(frame)
  229. clear()
  230. }
  231. })
  232. })
  233. }
  234. export function useToolFade(
  235. ref: () => HTMLElement | undefined,
  236. options?: { delay?: number; wipe?: boolean; animate?: boolean },
  237. ) {
  238. let anim: AnimationPlaybackControls | undefined
  239. let frame: number | undefined
  240. const delay = options?.delay ?? 0
  241. const wipe = options?.wipe ?? false
  242. const active = options?.animate !== false
  243. onMount(() => {
  244. if (!active) return
  245. const el = ref()
  246. if (!el || typeof window === "undefined") return
  247. if (prefersReducedMotion()) return
  248. const mask =
  249. wipe &&
  250. typeof CSS !== "undefined" &&
  251. (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
  252. CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
  253. el.style.opacity = "0"
  254. el.style.filter = wipe ? "blur(3px)" : "blur(2px)"
  255. el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)"
  256. if (mask) {
  257. el.style.maskImage = WIPE_MASK
  258. el.style.webkitMaskImage = WIPE_MASK
  259. el.style.maskSize = "240% 100%"
  260. el.style.webkitMaskSize = "240% 100%"
  261. el.style.maskRepeat = "no-repeat"
  262. el.style.webkitMaskRepeat = "no-repeat"
  263. el.style.maskPosition = "100% 0%"
  264. el.style.webkitMaskPosition = "100% 0%"
  265. }
  266. frame = requestAnimationFrame(() => {
  267. frame = undefined
  268. const node = ref()
  269. if (!node) return
  270. anim = wipe
  271. ? mask
  272. ? animate(
  273. node,
  274. { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
  275. { ...GROW_SPRING, delay },
  276. )
  277. : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay })
  278. : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay })
  279. anim?.finished.then(() => {
  280. const value = ref()
  281. if (!value) return
  282. clearFadeStyles(value)
  283. if (mask) clearMaskStyles(value)
  284. })
  285. })
  286. })
  287. onCleanup(() => {
  288. if (frame !== undefined) cancelAnimationFrame(frame)
  289. anim?.stop()
  290. })
  291. }