diff --git a/packages/app/component-tests/session-question-dock.spec.ts b/packages/app/component-tests/session-question-dock.spec.ts index 0c0a959f462..4ffef8520ed 100644 --- a/packages/app/component-tests/session-question-dock.spec.ts +++ b/packages/app/component-tests/session-question-dock.spec.ts @@ -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("*")].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 }) => { diff --git a/packages/app/src/session/composer/spring-height.tsx b/packages/app/src/session/composer/spring-height.tsx index d3ea10d29be..fc140648255 100644 --- a/packages/app/src/session/composer/spring-height.tsx +++ b/packages/app/src/session/composer/spring-height.tsx @@ -267,6 +267,13 @@ export function ComposerMorph(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(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(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(props: { ) } -const growing = new WeakMap void }>() +type Growth = { spring: Spring; presence: Spring; open: boolean; done?: () => void; end?: () => void } + +const growing = new WeakMap() 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) { diff --git a/packages/app/src/session/requests/session-question-dock.tsx b/packages/app/src/session/requests/session-question-dock.tsx index cbd53f39d61..ea7b9654922 100644 --- a/packages/app/src/session/requests/session-question-dock.tsx +++ b/packages/app/src/session/requests/session-question-dock.tsx @@ -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) => {