fix(app): stop question dock motion when its knob turns off (#59407)

This commit is contained in:
Kit Langton
2026-10-26 14:39:53 -07:00
committed by GitHub
Unverified
parent a0dca06854
commit 8eb272c8df
3 changed files with 87 additions and 18 deletions
@@ -1,5 +1,6 @@
import { expect, story } from "../../stories/playwright/story"
import type { Locator } from "@playwright/test"
import type { MotionKnob } from "@opencode/ui/motion-tuning"
import { expect, sourceURL, story } from "../../stories/playwright/story"
story("resizes the dock to fit different answer counts", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-answer-counts")
@@ -639,6 +640,42 @@ story.describe("with the question dock's motion on", () => {
expect(leave.at(-1)).toMatchObject({ next: 1, submit: 0, footer: false })
await expect(back).toHaveAttribute("inert", "")
})
story("stops a switch where it is when the knob turns off", async ({ mount }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("radio").first()).toBeFocused()
const frames = await question.evaluate(
async (root, tuning) => {
const { motionTuning } = await import(tuning)
const knob = motionTuning.list().find((item: MotionKnob) => item.id === "question.motion")
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
const sample = () => ({
animations: document.getAnimations().length,
heights: [...root.querySelectorAll<HTMLElement>("*")].filter((el) => el.style.height).length,
ghosts: root.querySelectorAll("[data-ghost]").length,
})
document.activeElement!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
)
await frame()
await frame()
const moving = sample()
motionTuning.set(knob, false)
await frame()
await frame()
return { moving, after: sample() }
},
sourceURL(new URL("../../ui/src/motion-tuning.ts", import.meta.url)),
)
expect(frames.moving.heights + frames.moving.ghosts).toBeGreaterThan(0)
expect(frames.after).toEqual({ animations: 0, heights: 0, ghosts: 0 })
})
})
story("switches questions, folds and submits on the same frame with the dock's motion off", async ({ mount, page }) => {
@@ -267,6 +267,13 @@ export function ComposerMorph<T extends { key: string }>(props: {
),
)
// A face that turns instant mid-flight lands at once.
createEffect(() => {
const list = untrack(faces)
if (stop && list.some((item) => props.instant?.(item.value()))) settle(list)
})
onCleanup(observeReducedMotion(() => prefersReducedMotion() && stop && settle(untrack(faces))))
onCleanup(() => stop?.())
@@ -357,6 +364,16 @@ export function Grow<T>(props: {
let el: HTMLDivElement | undefined
let entering = !!props.when && !!props.appear
const closed = () => {
setPresent(false)
props.onClosed?.()
}
// Turning instant mid-grow finishes the grow where it was going.
createEffect(() => {
if (props.instant && el) growing.get(el)?.end?.()
})
createEffect(
on(
() => [!!props.when, present()] as const,
@@ -370,18 +387,22 @@ export function Grow<T>(props: {
if (!el || (!entering && (!prev || prev[0] === when))) return
entering = false
if (props.instant && !growing.has(el)) {
if (props.instant) {
const current = growing.get(el)
if (current) {
current.open = when
current.done = closed
return current.end?.()
}
if (when) return
setPresent(false)
return props.onClosed?.()
return closed()
}
grow(el, when, props.fade, props.card, props.close?.(), () => {
setPresent(false)
props.onClosed?.()
})
grow(el, when, props.fade, props.card, props.close?.(), closed)
},
),
)
@@ -409,7 +430,9 @@ export function Grow<T>(props: {
)
}
const growing = new WeakMap<HTMLElement, { spring: Spring; presence: Spring; open: boolean; done?: () => void }>()
type Growth = { spring: Spring; presence: Spring; open: boolean; done?: () => void; end?: () => void }
const growing = new WeakMap<HTMLElement, Growth>()
function grow(
el: HTMLElement,
@@ -436,7 +459,7 @@ function grow(
const prevMb =
(!mb && el.previousElementSibling ? parseFloat(getComputedStyle(el.previousElementSibling).marginBottom) : 0) || 0
const state = {
const state: Growth = {
spring: Spring.fromDuration(
open ? 0 : el.getBoundingClientRect().height,
reduced ? fade : open ? morph() : (close ?? morph()),
@@ -475,7 +498,8 @@ function grow(
})
if (child) observer.observe(child)
frameLoop.add((dt) => {
const stop = frameLoop.add((dt) => {
if (!el.isConnected) {
observer.disconnect()
growing.delete(el)
@@ -527,6 +551,13 @@ function grow(
return true
})
state.end = () => {
stop()
observer.disconnect()
growing.delete(el)
finish(el, state.open, state.done)
}
}
function finish(el: HTMLElement, open: boolean, done?: () => void) {
@@ -8,7 +8,7 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { DockPrompt } from "@opencode/session-ui/dock-prompt"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { frameLoop, observeReducedMotion, Spring, type DurationOptions } from "@opencode/ui/motion"
import { frameLoop, Spring, type DurationOptions } from "@opencode/ui/motion"
import { motionTuning } from "@opencode/ui/motion-tuning"
import { showToast } from "@/shell/notifications/toast"
import type { FormAnswer, FormInfo, FormMultiselectField, FormStringField } from "@opencode/client/promise"
@@ -893,11 +893,12 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
paint()
}
onCleanup(
observeReducedMotion(() => {
if (!questionMotion() && stop) still()
}),
)
// Turning the knob off also lands any running pick or dash transition.
createEffect(() => {
if (questionMotion()) return
still()
root?.getAnimations({ subtree: true }).forEach((animation) => animation.cancel())
})
onCleanup(() => stop?.())
const jump = (tab: number) => {