Skip to content

Commit 8eb272c

Browse files
authored
fix(app): stop question dock motion when its knob turns off (#59407)
1 parent a0dca06 commit 8eb272c

3 files changed

Lines changed: 87 additions & 18 deletions

File tree

‎packages/app/component-tests/session-question-dock.spec.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { expect, story } from "../../stories/playwright/story"
21
import type { Locator } from "@playwright/test"
2+
import type { MotionKnob } from "@opencode/ui/motion-tuning"
3+
import { expect, sourceURL, story } from "../../stories/playwright/story"
34

45
story("resizes the dock to fit different answer counts", async ({ mount }) => {
56
const component = await mount("app-current-session-surface--question-answer-counts")
@@ -639,6 +640,42 @@ story.describe("with the question dock's motion on", () => {
639640
expect(leave.at(-1)).toMatchObject({ next: 1, submit: 0, footer: false })
640641
await expect(back).toHaveAttribute("inert", "")
641642
})
643+
644+
story("stops a switch where it is when the knob turns off", async ({ mount }) => {
645+
const component = await mount("session-question-switch--question-series")
646+
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
647+
await expect(question.getByRole("radio").first()).toBeFocused()
648+
649+
const frames = await question.evaluate(
650+
async (root, tuning) => {
651+
const { motionTuning } = await import(tuning)
652+
const knob = motionTuning.list().find((item: MotionKnob) => item.id === "question.motion")
653+
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
654+
655+
const sample = () => ({
656+
animations: document.getAnimations().length,
657+
heights: [...root.querySelectorAll<HTMLElement>("*")].filter((el) => el.style.height).length,
658+
ghosts: root.querySelectorAll("[data-ghost]").length,
659+
})
660+
661+
document.activeElement!.dispatchEvent(
662+
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
663+
)
664+
await frame()
665+
await frame()
666+
const moving = sample()
667+
motionTuning.set(knob, false)
668+
await frame()
669+
await frame()
670+
671+
return { moving, after: sample() }
672+
},
673+
sourceURL(new URL("../../ui/src/motion-tuning.ts", import.meta.url)),
674+
)
675+
676+
expect(frames.moving.heights + frames.moving.ghosts).toBeGreaterThan(0)
677+
expect(frames.after).toEqual({ animations: 0, heights: 0, ghosts: 0 })
678+
})
642679
})
643680

644681
story("switches questions, folds and submits on the same frame with the dock's motion off", async ({ mount, page }) => {

‎packages/app/src/session/composer/spring-height.tsx‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,13 @@ export function ComposerMorph<T extends { key: string }>(props: {
267267
),
268268
)
269269

270+
// A face that turns instant mid-flight lands at once.
271+
createEffect(() => {
272+
const list = untrack(faces)
273+
274+
if (stop && list.some((item) => props.instant?.(item.value()))) settle(list)
275+
})
276+
270277
onCleanup(observeReducedMotion(() => prefersReducedMotion() && stop && settle(untrack(faces))))
271278
onCleanup(() => stop?.())
272279

@@ -357,6 +364,16 @@ export function Grow<T>(props: {
357364
let el: HTMLDivElement | undefined
358365
let entering = !!props.when && !!props.appear
359366

367+
const closed = () => {
368+
setPresent(false)
369+
props.onClosed?.()
370+
}
371+
372+
// Turning instant mid-grow finishes the grow where it was going.
373+
createEffect(() => {
374+
if (props.instant && el) growing.get(el)?.end?.()
375+
})
376+
360377
createEffect(
361378
on(
362379
() => [!!props.when, present()] as const,
@@ -370,18 +387,22 @@ export function Grow<T>(props: {
370387
if (!el || (!entering && (!prev || prev[0] === when))) return
371388
entering = false
372389

373-
if (props.instant && !growing.has(el)) {
374-
if (when) return
390+
if (props.instant) {
391+
const current = growing.get(el)
392+
393+
if (current) {
394+
current.open = when
395+
current.done = closed
375396

376-
setPresent(false)
397+
return current.end?.()
398+
}
377399

378-
return props.onClosed?.()
400+
if (when) return
401+
402+
return closed()
379403
}
380404

381-
grow(el, when, props.fade, props.card, props.close?.(), () => {
382-
setPresent(false)
383-
props.onClosed?.()
384-
})
405+
grow(el, when, props.fade, props.card, props.close?.(), closed)
385406
},
386407
),
387408
)
@@ -409,7 +430,9 @@ export function Grow<T>(props: {
409430
)
410431
}
411432

412-
const growing = new WeakMap<HTMLElement, { spring: Spring; presence: Spring; open: boolean; done?: () => void }>()
433+
type Growth = { spring: Spring; presence: Spring; open: boolean; done?: () => void; end?: () => void }
434+
435+
const growing = new WeakMap<HTMLElement, Growth>()
413436

414437
function grow(
415438
el: HTMLElement,
@@ -436,7 +459,7 @@ function grow(
436459
const prevMb =
437460
(!mb && el.previousElementSibling ? parseFloat(getComputedStyle(el.previousElementSibling).marginBottom) : 0) || 0
438461

439-
const state = {
462+
const state: Growth = {
440463
spring: Spring.fromDuration(
441464
open ? 0 : el.getBoundingClientRect().height,
442465
reduced ? fade : open ? morph() : (close ?? morph()),
@@ -475,7 +498,8 @@ function grow(
475498
})
476499

477500
if (child) observer.observe(child)
478-
frameLoop.add((dt) => {
501+
502+
const stop = frameLoop.add((dt) => {
479503
if (!el.isConnected) {
480504
observer.disconnect()
481505
growing.delete(el)
@@ -527,6 +551,13 @@ function grow(
527551

528552
return true
529553
})
554+
555+
state.end = () => {
556+
stop()
557+
observer.disconnect()
558+
growing.delete(el)
559+
finish(el, state.open, state.done)
560+
}
530561
}
531562

532563
function finish(el: HTMLElement, open: boolean, done?: () => void) {

‎packages/app/src/session/requests/session-question-dock.tsx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Tooltip } from "@opencode/ui/tooltip"
88
import { DockPrompt } from "@opencode/session-ui/dock-prompt"
99
import { Icon } from "@opencode/ui/icon"
1010
import { TextInput } from "@opencode/ui/text-input"
11-
import { frameLoop, observeReducedMotion, Spring, type DurationOptions } from "@opencode/ui/motion"
11+
import { frameLoop, Spring, type DurationOptions } from "@opencode/ui/motion"
1212
import { motionTuning } from "@opencode/ui/motion-tuning"
1313
import { showToast } from "@/shell/notifications/toast"
1414
import type { FormAnswer, FormInfo, FormMultiselectField, FormStringField } from "@opencode/client/promise"
@@ -893,11 +893,12 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
893893
paint()
894894
}
895895

896-
onCleanup(
897-
observeReducedMotion(() => {
898-
if (!questionMotion() && stop) still()
899-
}),
900-
)
896+
// Turning the knob off also lands any running pick or dash transition.
897+
createEffect(() => {
898+
if (questionMotion()) return
899+
still()
900+
root?.getAnimations({ subtree: true }).forEach((animation) => animation.cancel())
901+
})
901902
onCleanup(() => stop?.())
902903

903904
const jump = (tab: number) => {

0 commit comments

Comments
 (0)