| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325 |
- import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
- import {
- animate,
- type AnimationPlaybackControls,
- clearFadeStyles,
- clearMaskStyles,
- COLLAPSIBLE_SPRING,
- GROW_SPRING,
- WIPE_MASK,
- } from "./motion"
- import { prefersReducedMotion } from "../hooks/use-reduced-motion"
- import type { ToolPart } from "@opencode-ai/sdk/v2"
- export const TEXT_RENDER_THROTTLE_MS = 100
- export function createThrottledValue(getValue: () => string) {
- const [value, setValue] = createSignal(getValue())
- let timeout: ReturnType<typeof setTimeout> | undefined
- let last = 0
- createEffect(() => {
- const next = getValue()
- const now = Date.now()
- const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
- if (remaining <= 0) {
- if (timeout) {
- clearTimeout(timeout)
- timeout = undefined
- }
- last = now
- setValue(next)
- return
- }
- if (timeout) clearTimeout(timeout)
- timeout = setTimeout(() => {
- last = Date.now()
- setValue(next)
- timeout = undefined
- }, remaining)
- })
- onCleanup(() => {
- if (timeout) clearTimeout(timeout)
- })
- return value
- }
- export function busy(status: string | undefined) {
- return status === "pending" || status === "running"
- }
- export function hold(state: () => boolean, wait = 2000) {
- const [live, setLive] = createSignal(state())
- let timer: ReturnType<typeof setTimeout> | undefined
- createEffect(() => {
- if (state()) {
- if (timer) clearTimeout(timer)
- timer = undefined
- setLive(true)
- return
- }
- if (timer) clearTimeout(timer)
- timer = setTimeout(() => {
- timer = undefined
- setLive(false)
- }, wait)
- })
- onCleanup(() => {
- if (timer) clearTimeout(timer)
- })
- return live
- }
- export function updateScrollMask(el: HTMLElement, fade = 12) {
- const { scrollTop, scrollHeight, clientHeight } = el
- const overflow = scrollHeight - clientHeight
- if (overflow <= 1) {
- el.style.maskImage = ""
- el.style.webkitMaskImage = ""
- return
- }
- const top = scrollTop > 1
- const bottom = scrollTop < overflow - 1
- const mask =
- top && bottom
- ? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)`
- : top
- ? `linear-gradient(to bottom, transparent 0, black ${fade}px)`
- : bottom
- ? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)`
- : ""
- el.style.maskImage = mask
- el.style.webkitMaskImage = mask
- }
- export function useCollapsible(options: {
- content: () => HTMLElement | undefined
- body: () => HTMLElement | undefined
- open: () => boolean
- measure?: () => number
- onOpen?: () => void
- }) {
- let heightAnim: AnimationPlaybackControls | undefined
- let fadeAnim: AnimationPlaybackControls | undefined
- let gen = 0
- createEffect(
- on(
- options.open,
- (isOpen) => {
- const content = options.content()
- const body = options.body()
- if (!content || !body) return
- heightAnim?.stop()
- fadeAnim?.stop()
- const id = ++gen
- if (isOpen) {
- content.style.display = ""
- content.style.height = "0px"
- body.style.opacity = "0"
- body.style.filter = "blur(2px)"
- fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
- queueMicrotask(() => {
- if (gen !== id) return
- const c = options.content()
- if (!c) return
- const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
- heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
- heightAnim.finished.then(
- () => {
- if (gen !== id) return
- c.style.height = "auto"
- options.onOpen?.()
- },
- () => {},
- )
- })
- return
- }
- const h = content.getBoundingClientRect().height
- heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
- fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
- heightAnim.finished.then(
- () => {
- if (gen !== id) return
- content.style.display = "none"
- },
- () => {},
- )
- },
- { defer: true },
- ),
- )
- onCleanup(() => {
- ++gen
- heightAnim?.stop()
- fadeAnim?.stop()
- })
- }
- export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
- const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
- const [settled, setSettled] = createSignal(false)
- createEffect(() => {
- if (!anyRunning() && !working?.()) setSettled(true)
- })
- return createMemo(() => !settled() && (!!working?.() || anyRunning()))
- }
- export function useRowWipe(opts: {
- id: () => string
- text: () => string | undefined
- ref: () => HTMLElement | undefined
- seen: Set<string>
- }) {
- const reduce = prefersReducedMotion
- createEffect(() => {
- const id = opts.id()
- const txt = opts.text()
- const el = opts.ref()
- if (!el) return
- if (!txt) {
- clearFadeStyles(el)
- clearMaskStyles(el)
- return
- }
- if (reduce() || typeof window === "undefined") {
- clearFadeStyles(el)
- clearMaskStyles(el)
- return
- }
- if (opts.seen.has(id)) {
- clearFadeStyles(el)
- clearMaskStyles(el)
- return
- }
- opts.seen.add(id)
- el.style.maskImage = WIPE_MASK
- el.style.webkitMaskImage = WIPE_MASK
- el.style.maskSize = "240% 100%"
- el.style.webkitMaskSize = "240% 100%"
- el.style.maskRepeat = "no-repeat"
- el.style.webkitMaskRepeat = "no-repeat"
- el.style.maskPosition = "100% 0%"
- el.style.webkitMaskPosition = "100% 0%"
- el.style.opacity = "0"
- el.style.filter = "blur(2px)"
- el.style.transform = "translateX(-0.06em)"
- let done = false
- const clear = () => {
- if (done) return
- done = true
- clearFadeStyles(el)
- clearMaskStyles(el)
- }
- if (typeof requestAnimationFrame !== "function") {
- clear()
- return
- }
- let anim: AnimationPlaybackControls | undefined
- let frame: number | undefined = requestAnimationFrame(() => {
- frame = undefined
- const node = opts.ref()
- if (!node) return
- anim = animate(
- node,
- {
- opacity: [0, 1],
- filter: ["blur(2px)", "blur(0px)"],
- transform: ["translateX(-0.06em)", "translateX(0)"],
- maskPosition: "0% 0%",
- },
- GROW_SPRING,
- )
- anim.finished.catch(() => {}).finally(clear)
- })
- onCleanup(() => {
- if (frame !== undefined) {
- cancelAnimationFrame(frame)
- clear()
- }
- })
- })
- }
- export function useToolFade(
- ref: () => HTMLElement | undefined,
- options?: { delay?: number; wipe?: boolean; animate?: boolean },
- ) {
- let anim: AnimationPlaybackControls | undefined
- let frame: number | undefined
- const delay = options?.delay ?? 0
- const wipe = options?.wipe ?? false
- const active = options?.animate !== false
- onMount(() => {
- if (!active) return
- const el = ref()
- if (!el || typeof window === "undefined") return
- if (prefersReducedMotion()) return
- const mask =
- wipe &&
- typeof CSS !== "undefined" &&
- (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
- CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
- el.style.opacity = "0"
- el.style.filter = wipe ? "blur(3px)" : "blur(2px)"
- el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)"
- if (mask) {
- el.style.maskImage = WIPE_MASK
- el.style.webkitMaskImage = WIPE_MASK
- el.style.maskSize = "240% 100%"
- el.style.webkitMaskSize = "240% 100%"
- el.style.maskRepeat = "no-repeat"
- el.style.webkitMaskRepeat = "no-repeat"
- el.style.maskPosition = "100% 0%"
- el.style.webkitMaskPosition = "100% 0%"
- }
- frame = requestAnimationFrame(() => {
- frame = undefined
- const node = ref()
- if (!node) return
- anim = wipe
- ? mask
- ? animate(
- node,
- { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
- { ...GROW_SPRING, delay },
- )
- : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay })
- : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay })
- anim?.finished.then(() => {
- const value = ref()
- if (!value) return
- clearFadeStyles(value)
- if (mask) clearMaskStyles(value)
- })
- })
- })
- onCleanup(() => {
- if (frame !== undefined) cancelAnimationFrame(frame)
- anim?.stop()
- })
- }
|