From 1744121801e566f977876afc083c98ac52fcde13 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 10:44:50 -0700 Subject: [PATCH] fix(gui-extensions): keep the Knobs panel open while tuning (#57701) --- .../test-browser/motion-tuning-panel.test.ts | 27 +++++++++++++++++++ packages/gui-extensions/src/debug/i18n/en.ts | 6 ++--- packages/gui-extensions/src/debug/motion.tsx | 4 +-- .../gui-extensions/src/debug/renderer.tsx | 4 +-- packages/ui/src/motion-tuning-panel.ts | 8 ++++-- 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/app/test-browser/motion-tuning-panel.test.ts b/packages/app/test-browser/motion-tuning-panel.test.ts index b5f2a9fa50a..c508663d214 100644 --- a/packages/app/test-browser/motion-tuning-panel.test.ts +++ b/packages/app/test-browser/motion-tuning-panel.test.ts @@ -62,6 +62,33 @@ it("offers Snappy, Smooth and Calm for every spring, with one Global card and on expect(image()).toEqual({ duration: 0.3, bounce: 0 }) }) +it("closes only from its own close button or Esc inside it, never from the app around it", async () => { + let closed = 0 + const outside = document.createElement("button") + const container = document.createElement("div") + document.body.append(outside, container) + cleanups.push(mountMotionPanel(container, { onClose: () => closed++ })) + + const close = await waitFor(() => container.querySelector(".knobs-close") ?? undefined) + + outside.focus() + outside.click() + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })) + document.dispatchEvent(new KeyboardEvent("keydown", { key: "p", metaKey: true, bubbles: true })) + expect(closed).toBe(0) + + const random = await waitFor( + () => container.querySelector('button[aria-label="Random"]') ?? undefined, + ) + + random.focus() + random.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })) + expect(closed).toBe(1) + + close.click() + expect(closed).toBe(2) +}) + async function waitFor(find: () => T | undefined) { for (let i = 0; i < 200; i++) { const found = find() diff --git a/packages/gui-extensions/src/debug/i18n/en.ts b/packages/gui-extensions/src/debug/i18n/en.ts index 63090f19998..2f397cbccdc 100644 --- a/packages/gui-extensions/src/debug/i18n/en.ts +++ b/packages/gui-extensions/src/debug/i18n/en.ts @@ -34,9 +34,9 @@ export default { "mem.label": "MEM", "mem.tipUnavailable": "Used JS heap vs heap limit. Chromium only.", "mem.tip": "Used JS heap vs heap limit. {{used}} of {{limit}}.", - "motion.label": "MOTION", - "motion.title": "Motion", - "motion.tip": "Tune animation springs live. Changes persist on this device.", + "motion.label": "KNOBS", + "motion.title": "Knobs", + "motion.tip": "Tune live. Changes persist on this device.", "speed.label": "SPEED", "speed.tip": "Global animation speed. Click to step through 0.5× and 0.25× back to 1×.", "slow.label": "SLOW", diff --git a/packages/gui-extensions/src/debug/motion.tsx b/packages/gui-extensions/src/debug/motion.tsx index 117fd58f607..126942d2dcb 100644 --- a/packages/gui-extensions/src/debug/motion.tsx +++ b/packages/gui-extensions/src/debug/motion.tsx @@ -5,7 +5,7 @@ import { Portal } from "solid-js/web" const key = "opencode.motion.panel" // The Motion workspace, docked above the debug bar's right end until its header is dragged. knobs loads once it opens. -export default function Motion(props: { title: string }) { +export default function Motion(props: { title: string; onClose: () => void }) { let container!: HTMLDivElement let unmount: (() => void) | undefined let disposed = false @@ -76,7 +76,7 @@ export default function Motion(props: { title: string }) { }) void import("@opencode/ui/motion-tuning-panel").then(({ mountMotionPanel }) => { - if (!disposed) unmount = mountMotionPanel(container, { title: props.title }) + if (!disposed) unmount = mountMotionPanel(container, { title: props.title, onClose: props.onClose }) }) return ( diff --git a/packages/gui-extensions/src/debug/renderer.tsx b/packages/gui-extensions/src/debug/renderer.tsx index 08138f7bffd..962af26f28a 100644 --- a/packages/gui-extensions/src/debug/renderer.tsx +++ b/packages/gui-extensions/src/debug/renderer.tsx @@ -50,10 +50,10 @@ const setup: Setup = (ctx) => { {Motion && ( - // The Motion workspace opens only from the debug bar's MOTION cell, and shows while the bar does. + // The Knobs workspace opens only from the debug bar's KNOBS cell, and shows while the bar does. - + )} diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index d2a7462ce87..b155f5c396d 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -10,7 +10,10 @@ const global = "Global" * mount, so importing this module costs nothing until then. Works anywhere a surface registered its knobs, including * Storybook stories. Returns its removal. */ -export function mountMotionPanel(container: HTMLElement, options: { title?: string; section?: string } = {}) { +export function mountMotionPanel( + container: HTMLElement, + options: { title?: string; section?: string; onClose?: () => void } = {}, +) { let unmount: (() => void) | undefined let disposed = false let section = options.section @@ -113,7 +116,8 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri const stopPanel = controls.mount(container, { theme: "auto", - title: options.title ?? "Motion", + title: options.title ?? "Knobs", + onClose: options.onClose, tabs: sections, tab: section, onTab: (id) => (section = id),