chore: refactoring ui hooks

This commit is contained in:
Adam 2026-03-08 06:34:02 -05:00
parent c797b60069
commit f386137fba
No known key found for this signature in database
GPG Key ID: 9CB48779AF150E75
16 changed files with 123 additions and 129 deletions

View File

@ -483,8 +483,11 @@
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3", "@solid-primitives/bounds": "0.1.3",
"@solid-primitives/lifecycle": "0.1.2",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/page-visibility": "2.1.1",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/rootless": "1.5.2",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
@ -1834,10 +1837,14 @@
"@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="], "@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
"@solid-primitives/lifecycle": ["@solid-primitives/lifecycle@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="],
"@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="], "@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
"@solid-primitives/page-visibility": ["@solid-primitives/page-visibility@2.1.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="],
"@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="], "@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="], "@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],

View File

@ -2,10 +2,10 @@ import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useReducedMotion } from "@opencode-ai/ui/hooks"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion" import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
@ -32,7 +32,7 @@ export function SessionTimelineHeader(props: {
const sync = useSync() const sync = useSync()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const reduce = prefersReducedMotion const reduce = useReducedMotion()
const [title, setTitle] = createStore({ const [title, setTitle] = createStore({
draft: "", draft: "",

View File

@ -48,8 +48,11 @@
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3", "@solid-primitives/bounds": "0.1.3",
"@solid-primitives/lifecycle": "0.1.2",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/page-visibility": "2.1.1",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/rootless": "1.5.2",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",

View File

@ -1,8 +1,8 @@
import { createMemo, createSignal, For, onMount } from "solid-js" import { createMemo, createSignal, For, onMount } from "solid-js"
import type { ToolPart } from "@opencode-ai/sdk/v2" import type { ToolPart } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
import { ToolCall } from "./basic-tool" import { ToolCall } from "./basic-tool"
import { ToolStatusTitle } from "./tool-status-title" import { ToolStatusTitle } from "./tool-status-title"
import { AnimatedCountList } from "./tool-count-summary" import { AnimatedCountList } from "./tool-count-summary"
@ -149,10 +149,10 @@ export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: bo
} }
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) { export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
const reduce = useReducedMotion()
const wiped = new Set<string>() const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false) const [mounted, setMounted] = createSignal(false)
onMount(() => setMounted(true)) onMount(() => setMounted(true))
const reduce = prefersReducedMotion
const show = () => mounted() && props.pending const show = () => mounted() && props.pending
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING) const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING) const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)

View File

@ -1,6 +1,6 @@
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion" import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
export interface GrowBoxProps { export interface GrowBoxProps {
children: JSX.Element children: JSX.Element
@ -49,7 +49,7 @@ export interface GrowBoxProps {
* Used for timeline turns, assistant part groups, and user messages. * Used for timeline turns, assistant part groups, and user messages.
*/ */
export function GrowBox(props: GrowBoxProps) { export function GrowBox(props: GrowBoxProps) {
const reduce = prefersReducedMotion const reduce = useReducedMotion()
const spring = () => props.spring ?? GROW_SPRING const spring = () => props.spring ?? GROW_SPRING
const toggleSpring = () => props.toggleSpring ?? spring() const toggleSpring = () => props.toggleSpring ?? spring()
let mode: "mount" | "toggle" = "mount" let mode: "mount" | "toggle" = "mount"
@ -293,6 +293,18 @@ export function GrowBox(props: GrowBoxProps) {
offChange() offChange()
}) })
if (watch()) {
observer = new ResizeObserver(() => {
if (!open()) return
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
setHeight("mount")
})
})
observer.observe(body)
}
if (!animated()) { if (!animated()) {
setInstant(open()) setInstant(open())
return return
@ -318,17 +330,6 @@ export function GrowBox(props: GrowBoxProps) {
if (grow()) setHeight("mount") if (grow()) setHeight("mount")
}) })
} }
if (watch()) {
observer = new ResizeObserver(() => {
if (!open()) return
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
setHeight("mount")
})
})
observer.observe(body)
}
}) })
createEffect( createEffect(
@ -402,7 +403,12 @@ export function GrowBox(props: GrowBoxProps) {
ref={root} ref={root}
data-slot={props.slot} data-slot={props.slot}
class={props.class} class={props.class}
style={{ transform: "translateZ(0)", position: "relative" }} style={{
transform: "translateZ(0)",
position: "relative",
height: open() ? undefined : "0px",
overflow: open() ? undefined : "clip",
}}
> >
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}> <div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
{props.children} {props.children}

View File

@ -1,3 +1,4 @@
import { usePageVisibility } from "@solid-primitives/page-visibility"
import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js" import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@ -254,8 +255,6 @@ function urls(text: string | undefined) {
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
import { pageVisible } from "../hooks/use-page-visible"
function createGroupOpenState() { function createGroupOpenState() {
const [state, setState] = createStore<Record<string, boolean>>({}) const [state, setState] = createStore<Record<string, boolean>>({})
const read = (key?: string, collapse?: boolean) => { const read = (key?: string, collapse?: boolean) => {
@ -277,6 +276,7 @@ function createGroupOpenState() {
function shouldCollapseGroup( function shouldCollapseGroup(
statuses: (string | undefined)[], statuses: (string | undefined)[],
opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean }, opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean },
pageVisible: () => boolean,
) { ) {
if (opts.afterTool) return true if (opts.afterTool) return true
if (opts.groupTail === false) return true if (opts.groupTail === false) return true
@ -363,6 +363,7 @@ export function AssistantParts(props: {
}) { }) {
const data = useData() const data = useData()
const emptyParts: PartType[] = [] const emptyParts: PartType[] = []
const pageVisible = usePageVisibility()
const groupState = createGroupOpenState() const groupState = createGroupOpenState()
const grouped = createMemo(() => { const grouped = createMemo(() => {
const keys: string[] = [] const keys: string[] = []
@ -485,11 +486,15 @@ export function AssistantParts(props: {
groupTail?: boolean, groupTail?: boolean,
group?: { part: ToolPart; message: AssistantMessage }[], group?: { part: ToolPart; message: AssistantMessage }[],
) => ) =>
shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], { shouldCollapseGroup(
afterTool, group?.map((item) => item.part.state.status) ?? [],
groupTail, {
working: props.working, afterTool,
}) groupTail,
working: props.working,
},
pageVisible,
)
const value = ctx() const value = ctx()
if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts)) if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts))
const entry = part() const entry = part()

View File

@ -1,7 +1,7 @@
import { attachSpring, motionValue } from "motion" import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion" import type { SpringOptions } from "motion"
import { createEffect, createSignal, onCleanup } from "solid-js" import { createEffect, createSignal, onCleanup } from "solid-js"
import { prefersReducedMotion } from "../hooks/use-reduced-motion" import { useReducedMotion } from "../hooks/use-reduced-motion"
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity"> type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
const eq = (a: Opt | undefined, b: Opt | undefined) => const eq = (a: Opt | undefined, b: Opt | undefined) =>
@ -14,7 +14,7 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
export function useSpring(target: () => number, options?: Opt | (() => Opt)) { export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
const read = () => (typeof options === "function" ? options() : options) const read = () => (typeof options === "function" ? options() : options)
const reduce = prefersReducedMotion const reduce = useReducedMotion()
const [value, setValue] = createSignal(target()) const [value, setValue] = createSignal(target())
const source = motionValue(value()) const source = motionValue(value())
const spring = motionValue(value()) const spring = motionValue(value())

View File

@ -1,6 +1,6 @@
import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js" import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion" import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
export type RollingResultsProps<T> = { export type RollingResultsProps<T> = {
items: T[] items: T[]
@ -27,8 +27,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
let shift: AnimationPlaybackControls | undefined let shift: AnimationPlaybackControls | undefined
let resize: AnimationPlaybackControls | undefined let resize: AnimationPlaybackControls | undefined
let edgeFade: AnimationPlaybackControls | undefined let edgeFade: AnimationPlaybackControls | undefined
const reduce = useReducedMotion()
const reducedMotion = prefersReducedMotion
const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3))) const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22))) const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
@ -54,7 +53,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
return count() - rendered().length return count() - rendered().length
}) })
const open = createMemo(() => props.open !== false) const open = createMemo(() => props.open !== false)
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion()) const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reduce())
const noFade = () => props.noFadeOnCollapse === true const noFade = () => props.noFadeOnCollapse === true
const overflowing = createMemo(() => count() > rows()) const overflowing = createMemo(() => count() > rows())
const shown = createMemo(() => Math.min(rows(), count())) const shown = createMemo(() => Math.min(rows(), count()))

View File

@ -1,7 +1,7 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2" import type { ToolPart } from "@opencode-ai/sdk/v2"
import { prefersReducedMotion } from "../hooks/use-reduced-motion" import { useReducedMotion } from "../hooks/use-reduced-motion"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { RollingResults } from "./rolling-results" import { RollingResults } from "./rolling-results"
import { Icon } from "./icon" import { Icon } from "./icon"
@ -178,6 +178,7 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) { export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
const i18n = useI18n() const i18n = useI18n()
const reduce = useReducedMotion()
const wiped = new Set<string>() const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false) const [mounted, setMounted] = createSignal(false)
const [userToggled, setUserToggled] = createSignal(false) const [userToggled, setUserToggled] = createSignal(false)
@ -208,7 +209,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
if (typeof value === "string") return value if (typeof value === "string") return value
return "" return ""
}) })
const reduce = prefersReducedMotion
const skip = () => reduce() || props.animate === false const skip = () => reduce() || props.animate === false
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING) const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING) const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)

View File

@ -1,4 +1,5 @@
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { import {
animate, animate,
type AnimationPlaybackControls, type AnimationPlaybackControls,
@ -7,7 +8,6 @@ import {
GROW_SPRING, GROW_SPRING,
WIPE_MASK, WIPE_MASK,
} from "./motion" } from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
const px = (value: number | string | undefined, fallback: number) => { const px = (value: number | string | undefined, fallback: number) => {
if (typeof value === "number") return `${value}px` if (typeof value === "number") return `${value}px`
@ -143,12 +143,13 @@ export function TextWipe(props: { text?: string; class?: string; delay?: number;
let ref: HTMLSpanElement | undefined let ref: HTMLSpanElement | undefined
let frame: number | undefined let frame: number | undefined
let anim: AnimationPlaybackControls | undefined let anim: AnimationPlaybackControls | undefined
const reduce = useReducedMotion()
const run = () => { const run = () => {
if (props.animate === false) return if (props.animate === false) return
const el = ref const el = ref
if (!el || !props.text || typeof window === "undefined") return if (!el || !props.text || typeof window === "undefined") return
if (prefersReducedMotion()) return if (reduce()) return
const mask = const mask =
typeof CSS !== "undefined" && typeof CSS !== "undefined" &&

View File

@ -1,8 +1,8 @@
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion" import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
import { TextShimmer } from "./text-shimmer" import { TextShimmer } from "./text-shimmer"
import { commonPrefix } from "./text-utils" import { commonPrefix } from "./text-utils"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
function contentWidth(el: HTMLSpanElement | undefined) { function contentWidth(el: HTMLSpanElement | undefined) {
if (!el) return 0 if (!el) return 0
@ -18,6 +18,7 @@ export function ToolStatusTitle(props: {
class?: string class?: string
split?: boolean split?: boolean
}) { }) {
const reduce = useReducedMotion()
const split = createMemo(() => commonPrefix(props.activeText, props.doneText)) const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
const suffix = createMemo( const suffix = createMemo(
() => () =>
@ -38,8 +39,6 @@ export function ToolStatusTitle(props: {
const node = () => (suffix() ? tailRef : swapRef) const node = () => (suffix() ? tailRef : swapRef)
const reduce = prefersReducedMotion
const setNodeWidth = (width: string) => { const setNodeWidth = (width: string) => {
if (swapRef) swapRef.style.width = width if (swapRef) swapRef.style.width = width
if (tailRef) tailRef.style.width = width if (tailRef) tailRef.style.width = width

View File

@ -1,4 +1,6 @@
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { import {
animate, animate,
type AnimationPlaybackControls, type AnimationPlaybackControls,
@ -8,8 +10,6 @@ import {
GROW_SPRING, GROW_SPRING,
WIPE_MASK, WIPE_MASK,
} from "./motion" } 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 const TEXT_RENDER_THROTTLE_MS = 100
@ -106,57 +106,67 @@ export function useCollapsible(options: {
measure?: () => number measure?: () => number
onOpen?: () => void onOpen?: () => void
}) { }) {
const reduce = useReducedMotion()
let heightAnim: AnimationPlaybackControls | undefined let heightAnim: AnimationPlaybackControls | undefined
let fadeAnim: AnimationPlaybackControls | undefined let fadeAnim: AnimationPlaybackControls | undefined
let gen = 0 let gen = 0
createEffect( createEffect(
on( on(options.open, (isOpen) => {
options.open, const content = options.content()
(isOpen) => { const body = options.body()
const content = options.content() if (!content || !body) return
const body = options.body() heightAnim?.stop()
if (!content || !body) return fadeAnim?.stop()
heightAnim?.stop() if (reduce()) {
fadeAnim?.stop() body.style.opacity = ""
const id = ++gen body.style.filter = ""
if (isOpen) { if (isOpen) {
content.style.display = "" content.style.display = ""
content.style.height = "0px" content.style.height = "auto"
body.style.opacity = "0" options.onOpen?.()
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 return
} }
content.style.height = "0px"
content.style.display = "none"
return
}
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 const h = content.getBoundingClientRect().height
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING) heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING) fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
heightAnim.finished.then( heightAnim.finished.then(
() => { () => {
if (gen !== id) return if (gen !== id) return
content.style.display = "none" content.style.display = "none"
}, },
() => {}, () => {},
) )
}, }),
{ defer: true },
),
) )
onCleanup(() => { onCleanup(() => {
@ -181,7 +191,7 @@ export function useRowWipe(opts: {
ref: () => HTMLElement | undefined ref: () => HTMLElement | undefined
seen: Set<string> seen: Set<string>
}) { }) {
const reduce = prefersReducedMotion const reduce = useReducedMotion()
createEffect(() => { createEffect(() => {
const id = opts.id() const id = opts.id()
@ -265,13 +275,14 @@ export function useToolFade(
const delay = options?.delay ?? 0 const delay = options?.delay ?? 0
const wipe = options?.wipe ?? false const wipe = options?.wipe ?? false
const active = options?.animate !== false const active = options?.animate !== false
const reduce = useReducedMotion()
onMount(() => { onMount(() => {
if (!active) return if (!active) return
const el = ref() const el = ref()
if (!el || typeof window === "undefined") return if (!el || typeof window === "undefined") return
if (prefersReducedMotion()) return if (reduce()) return
const mask = const mask =
wipe && wipe &&

View File

@ -1,5 +1,3 @@
export * from "./use-filtered-list" export * from "./use-filtered-list"
export * from "./create-auto-scroll" export * from "./create-auto-scroll"
export * from "./use-element-height"
export * from "./use-reduced-motion" export * from "./use-reduced-motion"
export * from "./use-page-visible"

View File

@ -1,25 +0,0 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
/**
* Tracks an element's height via ResizeObserver.
* Returns a reactive signal that updates whenever the element resizes.
*/
export function useElementHeight(
ref: Accessor<HTMLElement | undefined> | (() => HTMLElement | undefined),
initial = 0,
): Accessor<number> {
const [height, setHeight] = createSignal(initial)
createEffect(() => {
const el = ref()
if (!el) return
setHeight(el.getBoundingClientRect().height)
const observer = new ResizeObserver(() => {
setHeight(el.getBoundingClientRect().height)
})
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return height
}

View File

@ -1,11 +0,0 @@
import { createSignal } from "solid-js"
export const pageVisible = /* @__PURE__ */ (() => {
const [visible, setVisible] = createSignal(true)
if (typeof document !== "undefined") {
const sync = () => setVisible(document.visibilityState !== "hidden")
sync()
document.addEventListener("visibilitychange", sync)
}
return visible
})()

View File

@ -1,9 +1,10 @@
import { createSignal } from "solid-js" import { isHydrated } from "@solid-primitives/lifecycle"
import { createMediaQuery } from "@solid-primitives/media"
import { createHydratableSingletonRoot } from "@solid-primitives/rootless"
export const prefersReducedMotion = /* @__PURE__ */ (() => { const query = "(prefers-reduced-motion: reduce)"
if (typeof window === "undefined") return () => false
const mql = window.matchMedia("(prefers-reduced-motion: reduce)") export const useReducedMotion = createHydratableSingletonRoot(() => {
const [reduced, setReduced] = createSignal(mql.matches) const value = createMediaQuery(query)
mql.addEventListener("change", () => setReduced(mql.matches)) return () => !isHydrated() || value()
return reduced })
})()