Skip to content

Commit a4eb2f8

Browse files
authored
refactor(ui): use one animation library (#59025)
1 parent 3e4463a commit a4eb2f8

7 files changed

Lines changed: 97 additions & 100 deletions

File tree

‎bun.lock‎

Lines changed: 1 addition & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/app/test-browser/motion-spring.test.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,60 @@
11
import { expect, test } from "bun:test"
22
import { useSpring } from "@opencode/ui/motion-spring"
3-
import { createRoot, createSignal } from "solid-js"
3+
import { createEffect, createRoot, createSignal } from "solid-js"
4+
5+
// Records every value the spring emits and resolves once it reaches `settled`.
6+
function watch(progress: () => number, settled: number) {
7+
const seen: number[] = []
8+
const { promise, resolve } = Promise.withResolvers<void>()
9+
10+
createEffect(() => {
11+
const value = progress()
12+
13+
seen.push(value)
14+
15+
if (value === settled) resolve()
16+
})
17+
18+
return { seen, settled: promise }
19+
}
20+
21+
test("springs progress to its target", async () => {
22+
const state = createRoot((dispose) => {
23+
const [target, setTarget] = createSignal(0)
24+
const progress = useSpring(target, { visualDuration: 0.3, bounce: 0 })
25+
26+
return { dispose, progress, setTarget, ...watch(progress, 1) }
27+
})
28+
29+
state.setTarget(1)
30+
await Promise.race([state.settled, Bun.sleep(2000)])
31+
32+
expect(state.progress()).toBe(1)
33+
expect(state.seen.some((value) => value > 0 && value < 1)).toBe(true)
34+
expect(state.seen.every((value) => value <= 1)).toBe(true)
35+
state.dispose()
36+
})
37+
38+
test("new spring options replace the running spring", async () => {
39+
const state = createRoot((dispose) => {
40+
const [target, setTarget] = createSignal(0)
41+
const [duration, setDuration] = createSignal(4)
42+
const progress = useSpring(target, () => ({ visualDuration: duration(), bounce: 0 }))
43+
44+
return { dispose, progress, setTarget, setDuration, ...watch(progress, 1) }
45+
})
46+
47+
state.setTarget(1)
48+
await Bun.sleep(100)
49+
expect(state.progress()).toBeLessThan(0.5)
50+
51+
// The old four-second spring would still be far from its target after a second.
52+
state.setDuration(0.1)
53+
await Promise.race([state.settled, Bun.sleep(1000)])
54+
55+
expect(state.progress()).toBe(1)
56+
state.dispose()
57+
})
458

559
test("snaps spring progress when the session changes", async () => {
660
const state = createRoot((dispose) => {

‎packages/session-ui/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"vite-plugin-solid": "catalog:"
6464
},
6565
"dependencies": {
66+
"@kitlangton/solid-motion": "0.2.2",
6667
"@kobalte/core": "catalog:",
6768
"@opencode/client": "workspace:*",
6869
"@opencode/ui": "workspace:*",
@@ -77,7 +78,6 @@
7778
"effect": "catalog:",
7879
"dompurify": "3.4.14",
7980
"marked": "catalog:",
80-
"motion": "12.34.5",
8181
"pele": "1.7.0",
8282
"remend": "catalog:",
8383
"solid-js": "catalog:"

‎packages/session-ui/src/components/basic-tool.tsx‎

Lines changed: 27 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
type Accessor,
1212
type JSX,
1313
} from "solid-js"
14-
import { animate, type AnimationPlaybackControls } from "motion"
14+
import { Option, Schema } from "effect"
1515
import { useI18n } from "@opencode/ui/context/i18n"
1616
import { createStore } from "solid-js/store"
1717
import { Collapsible } from "@opencode/ui/collapsible"
@@ -28,13 +28,8 @@ export type TriggerTitle = {
2828
action?: JSX.Element
2929
}
3030

31-
const isTriggerTitle = (val: unknown): val is TriggerTitle => {
32-
if (typeof val !== "object" || val === null) return false
33-
34-
if (typeof Node !== "undefined" && val instanceof Node) return false
35-
36-
return "title" in val && typeof val.title === "string"
37-
}
31+
const isTriggerTitle = (value: TriggerTitle | JSX.Element): value is TriggerTitle =>
32+
value instanceof Object && !("nodeType" in value) && "title" in value
3833

3934
export interface BasicToolProps {
4035
icon: IconProps["name"]
@@ -51,7 +46,6 @@ export interface BasicToolProps {
5146
allowOpenWhilePending?: boolean
5247
defer?: boolean
5348
locked?: boolean
54-
animated?: boolean
5549
rail?: boolean
5650
onSubtitleClick?: () => void
5751
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
@@ -62,8 +56,6 @@ export interface BasicToolProps {
6256
compact?: boolean
6357
}
6458

65-
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
66-
6759
const deferredMounts: Array<{ active: boolean; fn: () => void }> = []
6860

6961
let deferredFrame: number | undefined
@@ -122,7 +114,7 @@ export function BasicTool(props: BasicToolProps) {
122114
const triggerContent = createMemo(() => {
123115
const value = props.trigger
124116

125-
return typeof value === "function" ? value(open) : value
117+
return value instanceof Function ? value(open) : value
126118
})
127119

128120
const triggerTitle = createMemo(() => {
@@ -131,6 +123,12 @@ export function BasicTool(props: BasicToolProps) {
131123
return isTriggerTitle(value) ? value : undefined
132124
})
133125

126+
const triggerElement = () => {
127+
const value = triggerContent()
128+
129+
return isTriggerTitle(value) ? undefined : value
130+
}
131+
134132
let cancelReady: (() => void) | undefined
135133

136134
const cancel = () => {
@@ -185,39 +183,6 @@ export function BasicTool(props: BasicToolProps) {
185183
),
186184
)
187185

188-
// Animated height for collapsible open/close
189-
let contentRef: HTMLDivElement | undefined
190-
let heightAnim: AnimationPlaybackControls | undefined
191-
const initialOpen = open()
192-
193-
createEffect(
194-
on(
195-
open,
196-
(isOpen) => {
197-
if (!props.animated || !contentRef) return
198-
heightAnim?.stop()
199-
200-
if (isOpen) {
201-
contentRef.style.overflow = "hidden"
202-
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
203-
void heightAnim.finished.then(() => {
204-
if (!contentRef || !open()) return
205-
contentRef.style.overflow = "visible"
206-
contentRef.style.height = "auto"
207-
})
208-
} else {
209-
contentRef.style.overflow = "hidden"
210-
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
211-
}
212-
},
213-
{ defer: true },
214-
),
215-
)
216-
217-
onCleanup(() => {
218-
heightAnim?.stop()
219-
})
220-
221186
const handleOpenChange = (value: boolean) => {
222187
if (pending() && !props.allowOpenWhilePending) return
223188

@@ -286,7 +251,7 @@ export function BasicTool(props: BasicToolProps) {
286251
</div>
287252
)}
288253
</Match>
289-
<Match when={true}>{triggerContent() as JSX.Element}</Match>
254+
<Match when={true}>{triggerElement()}</Match>
290255
</Switch>
291256
</div>
292257
</div>
@@ -340,20 +305,7 @@ export function BasicTool(props: BasicToolProps) {
340305
{trigger()}
341306
</Collapsible.Trigger>
342307
</Show>
343-
<Show when={props.animated && hasChildren() && !props.hideDetails}>
344-
<div
345-
ref={contentRef}
346-
data-slot="collapsible-content"
347-
data-animated
348-
style={{
349-
height: initialOpen ? "auto" : "0px",
350-
overflow: initialOpen ? "visible" : "hidden",
351-
}}
352-
>
353-
<Show when={!props.defer || ready()}>{props.children}</Show>
354-
</div>
355-
</Show>
356-
<Show when={!props.animated && hasChildren() && !props.hideDetails}>
308+
<Show when={hasChildren() && !props.hideDetails}>
357309
<Show
358310
when={props.locked && open() && !props.defer}
359311
fallback={
@@ -371,36 +323,30 @@ export function BasicTool(props: BasicToolProps) {
371323
)
372324
}
373325

374-
function label(input: Record<string, unknown> | undefined) {
375-
const keys = ["description", "query", "url", "path", "pattern", "name"]
326+
// SAFETY: tool input is an open per-tool record. GenericTool shows arbitrary keys and decodes each value it reads.
327+
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- see SAFETY above
328+
type GenericToolInput = Record<string, unknown>
376329

377-
return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0)
378-
}
330+
const labelKeys = ["description", "query", "url", "path", "pattern", "name"]
379331

380-
function args(input: Record<string, unknown> | undefined) {
381-
if (!input) return []
382-
const skip = new Set(["description", "query", "url", "path", "pattern", "name"])
332+
const labelValue = Schema.decodeUnknownOption(Schema.NonEmptyString)
383333

384-
return Object.entries(input)
385-
.filter(([key]) => !skip.has(key))
386-
.flatMap(([key, value]) => {
387-
if (typeof value === "string") return [`${key}=${value}`]
334+
const argValue = Schema.decodeUnknownOption(Schema.Union([Schema.String, Schema.Number, Schema.Boolean]))
388335

389-
if (typeof value === "number") return [`${key}=${value}`]
336+
function label(input: GenericToolInput | undefined) {
337+
return labelKeys.flatMap((key) => Option.toArray(labelValue(input?.[key])))[0]
338+
}
390339

391-
if (typeof value === "boolean") return [`${key}=${value}`]
340+
function args(input: GenericToolInput | undefined) {
341+
if (!input) return []
392342

393-
return []
394-
})
343+
return Object.entries(input)
344+
.filter(([key]) => !labelKeys.includes(key))
345+
.flatMap(([key, value]) => Option.toArray(argValue(value)).map((arg) => `${key}=${arg}`))
395346
.slice(0, 3)
396347
}
397348

398-
export function GenericTool(props: {
399-
tool: string
400-
status?: string
401-
hideDetails?: boolean
402-
input?: Record<string, unknown>
403-
}) {
349+
export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean; input?: GenericToolInput }) {
404350
const i18n = useI18n()
405351

406352
return (

‎packages/session-ui/src/tools/tool-renderer.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { IconButton } from "@opencode/ui/icon-button"
4040
import { TextShimmer } from "@opencode/ui/text-shimmer"
4141
import { AnimatedNumber } from "@opencode/ui/animated-number"
4242
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
43-
import { animate } from "motion"
43+
import { animate } from "@kitlangton/solid-motion"
4444
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
4545
import type {
4646
FileDiffInfo,
@@ -73,7 +73,12 @@ function ShellSubmessage(props: { text: string; animate?: boolean }) {
7373
}
7474

7575
if (valueRef) {
76-
animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
76+
valueRef.animate?.(
77+
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] },
78+
{ duration: 320, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
79+
)
80+
valueRef.style.opacity = "1"
81+
valueRef.style.filter = "blur(0px)"
7782
}
7883
})
7984
})

‎packages/ui/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,6 @@
120120
"fuzzysort": "catalog:",
121121
"katex": "0.16.47",
122122
"marked": "catalog:",
123-
"motion": "12.34.5",
124123
"solid-list": "catalog:",
125124
"solid-sonner": "catalog:"
126125
},

‎packages/ui/src/components/motion-spring.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { attachSpring, motionValue } from "motion"
2-
import type { SpringOptions } from "motion"
1+
import { attachSpring, motionValue, type SpringOptions } from "@kitlangton/solid-motion"
32
import { createComputed, createEffect, createSignal, onCleanup } from "solid-js"
43

54
type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
@@ -12,8 +11,8 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
1211
a?.mass === b?.mass &&
1312
a?.velocity === b?.velocity
1413

15-
export function useSpring(target: () => number, options?: Opt | (() => Opt), snapKey?: () => unknown) {
16-
const read = () => (typeof options === "function" ? options() : options)
14+
export function useSpring<K>(target: () => number, options?: Opt | (() => Opt), snapKey?: () => K) {
15+
const read = () => (options instanceof Function ? options() : options)
1716
const [value, setValue] = createSignal(target())
1817
const source = motionValue(value())
1918
const spring = motionValue(value())
@@ -48,7 +47,10 @@ export function useSpring(target: () => number, options?: Opt | (() => Opt), sna
4847
if (eq(config, next)) return
4948
config = next
5049
stop()
50+
// attachSpring's cleanup only unsubscribes from the source; the running animation keeps ticking until stopped.
51+
spring.stop()
5152
stop = attachSpring(spring, source, next)
53+
spring.set(source.get())
5254
setValue(spring.get())
5355
})
5456

0 commit comments

Comments
 (0)