Files
anomalyco_opencode/packages/ui/src/components/tool-utils.ts
T
Kit Langton b47ab35ddf fix(ui): fix useRowWipe stuck blur and useCollapsible race conditions
- Remove anim.stop() from useRowWipe cleanup — stopping mid-animation
  leaves WAAPI fill-forward that overrides cleared inline styles. Let
  animations run to completion; cancelAnimationFrame prevents starts.
- Add generation counter to useCollapsible to guard against stale
  microtask and promise callbacks on rapid open/close toggling.
- Use .then(ok, err) instead of .catch().then() to prevent callbacks
  firing after animation cancellation.
- Remove redundant fade constant in ShellExpanded.
- Clean up unused imports in context-tool-results.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:34:18 -06:00

323 lines
8.8 KiB
TypeScript

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)
})
})
}
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()
})
}