Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 55 additions & 1 deletion packages/app/test-browser/motion-spring.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,60 @@
import { expect, test } from "bun:test"
import { useSpring } from "@opencode/ui/motion-spring"
import { createRoot, createSignal } from "solid-js"
import { createEffect, createRoot, createSignal } from "solid-js"

// Records every value the spring emits and resolves once it reaches `settled`.
function watch(progress: () => number, settled: number) {
const seen: number[] = []
const { promise, resolve } = Promise.withResolvers<void>()

createEffect(() => {
const value = progress()

seen.push(value)

if (value === settled) resolve()
})

return { seen, settled: promise }
}

test("springs progress to its target", async () => {
const state = createRoot((dispose) => {
const [target, setTarget] = createSignal(0)
const progress = useSpring(target, { visualDuration: 0.3, bounce: 0 })

return { dispose, progress, setTarget, ...watch(progress, 1) }
})

state.setTarget(1)
await Promise.race([state.settled, Bun.sleep(2000)])

expect(state.progress()).toBe(1)
expect(state.seen.some((value) => value > 0 && value < 1)).toBe(true)
expect(state.seen.every((value) => value <= 1)).toBe(true)
state.dispose()
})

test("new spring options replace the running spring", async () => {
const state = createRoot((dispose) => {
const [target, setTarget] = createSignal(0)
const [duration, setDuration] = createSignal(4)
const progress = useSpring(target, () => ({ visualDuration: duration(), bounce: 0 }))

return { dispose, progress, setTarget, setDuration, ...watch(progress, 1) }
})

state.setTarget(1)
await Bun.sleep(100)
expect(state.progress()).toBeLessThan(0.5)

// The old four-second spring would still be far from its target after a second.
state.setDuration(0.1)
await Promise.race([state.settled, Bun.sleep(1000)])

expect(state.progress()).toBe(1)
state.dispose()
})

test("snaps spring progress when the session changes", async () => {
const state = createRoot((dispose) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/session-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@kitlangton/solid-motion": "0.2.2",
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/ui": "workspace:*",
Expand All @@ -77,7 +78,6 @@
"effect": "catalog:",
"dompurify": "3.4.14",
"marked": "catalog:",
"motion": "12.34.5",
"pele": "1.7.0",
"remend": "catalog:",
"solid-js": "catalog:"
Expand Down
108 changes: 27 additions & 81 deletions packages/session-ui/src/components/basic-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type Accessor,
type JSX,
} from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { Option, Schema } from "effect"
import { useI18n } from "@opencode/ui/context/i18n"
import { createStore } from "solid-js/store"
import { Collapsible } from "@opencode/ui/collapsible"
Expand All @@ -28,13 +28,8 @@ export type TriggerTitle = {
action?: JSX.Element
}

const isTriggerTitle = (val: unknown): val is TriggerTitle => {
if (typeof val !== "object" || val === null) return false

if (typeof Node !== "undefined" && val instanceof Node) return false

return "title" in val && typeof val.title === "string"
}
const isTriggerTitle = (value: TriggerTitle | JSX.Element): value is TriggerTitle =>
value instanceof Object && !("nodeType" in value) && "title" in value

export interface BasicToolProps {
icon: IconProps["name"]
Expand All @@ -51,7 +46,6 @@ export interface BasicToolProps {
allowOpenWhilePending?: boolean
defer?: boolean
locked?: boolean
animated?: boolean
rail?: boolean
onSubtitleClick?: () => void
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
Expand All @@ -62,8 +56,6 @@ export interface BasicToolProps {
compact?: boolean
}

const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }

const deferredMounts: Array<{ active: boolean; fn: () => void }> = []

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

return typeof value === "function" ? value(open) : value
return value instanceof Function ? value(open) : value
})

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

const triggerElement = () => {
const value = triggerContent()

return isTriggerTitle(value) ? undefined : value
}

let cancelReady: (() => void) | undefined

const cancel = () => {
Expand Down Expand Up @@ -185,39 +183,6 @@ export function BasicTool(props: BasicToolProps) {
),
)

// Animated height for collapsible open/close
let contentRef: HTMLDivElement | undefined
let heightAnim: AnimationPlaybackControls | undefined
const initialOpen = open()

createEffect(
on(
open,
(isOpen) => {
if (!props.animated || !contentRef) return
heightAnim?.stop()

if (isOpen) {
contentRef.style.overflow = "hidden"
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
void heightAnim.finished.then(() => {
if (!contentRef || !open()) return
contentRef.style.overflow = "visible"
contentRef.style.height = "auto"
})
} else {
contentRef.style.overflow = "hidden"
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
}
},
{ defer: true },
),
)

onCleanup(() => {
heightAnim?.stop()
})

const handleOpenChange = (value: boolean) => {
if (pending() && !props.allowOpenWhilePending) return

Expand Down Expand Up @@ -286,7 +251,7 @@ export function BasicTool(props: BasicToolProps) {
</div>
)}
</Match>
<Match when={true}>{triggerContent() as JSX.Element}</Match>
<Match when={true}>{triggerElement()}</Match>
</Switch>
</div>
</div>
Expand Down Expand Up @@ -340,20 +305,7 @@ export function BasicTool(props: BasicToolProps) {
{trigger()}
</Collapsible.Trigger>
</Show>
<Show when={props.animated && hasChildren() && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
data-animated
style={{
height: initialOpen ? "auto" : "0px",
overflow: initialOpen ? "visible" : "hidden",
}}
>
<Show when={!props.defer || ready()}>{props.children}</Show>
</div>
</Show>
<Show when={!props.animated && hasChildren() && !props.hideDetails}>
<Show when={hasChildren() && !props.hideDetails}>
<Show
when={props.locked && open() && !props.defer}
fallback={
Expand All @@ -371,36 +323,30 @@ export function BasicTool(props: BasicToolProps) {
)
}

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

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

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

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

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

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

return []
})
return Object.entries(input)
.filter(([key]) => !labelKeys.includes(key))
.flatMap(([key, value]) => Option.toArray(argValue(value)).map((arg) => `${key}=${arg}`))
.slice(0, 3)
}

export function GenericTool(props: {
tool: string
status?: string
hideDetails?: boolean
input?: Record<string, unknown>
}) {
export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean; input?: GenericToolInput }) {
const i18n = useI18n()

return (
Expand Down
9 changes: 7 additions & 2 deletions packages/session-ui/src/tools/tool-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { IconButton } from "@opencode/ui/icon-button"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { AnimatedNumber } from "@opencode/ui/animated-number"
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
import { animate } from "motion"
import { animate } from "@kitlangton/solid-motion"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
import type {
FileDiffInfo,
Expand Down Expand Up @@ -73,7 +73,12 @@ function ShellSubmessage(props: { text: string; animate?: boolean }) {
}

if (valueRef) {
animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
valueRef.animate?.(
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] },
{ duration: 320, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
)
valueRef.style.opacity = "1"
valueRef.style.filter = "blur(0px)"
}
})
})
Expand Down
1 change: 0 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@
"fuzzysort": "catalog:",
"katex": "0.16.47",
"marked": "catalog:",
"motion": "12.34.5",
"solid-list": "catalog:",
"solid-sonner": "catalog:"
},
Expand Down
10 changes: 6 additions & 4 deletions packages/ui/src/components/motion-spring.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion"
import { attachSpring, motionValue, type SpringOptions } from "@kitlangton/solid-motion"
import { createComputed, createEffect, createSignal, onCleanup } from "solid-js"

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

export function useSpring(target: () => number, options?: Opt | (() => Opt), snapKey?: () => unknown) {
const read = () => (typeof options === "function" ? options() : options)
export function useSpring<K>(target: () => number, options?: Opt | (() => Opt), snapKey?: () => K) {
const read = () => (options instanceof Function ? options() : options)
const [value, setValue] = createSignal(target())
const source = motionValue(value())
const spring = motionValue(value())
Expand Down Expand Up @@ -48,7 +47,10 @@ export function useSpring(target: () => number, options?: Opt | (() => Opt), sna
if (eq(config, next)) return
config = next
stop()
// attachSpring's cleanup only unsubscribes from the source; the running animation keeps ticking until stopped.
spring.stop()
stop = attachSpring(spring, source, next)
spring.set(source.get())
setValue(spring.get())
})

Expand Down
Loading