From 4c075c8982a96c90e417229d410ec952ba1af0c3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 24 Oct 2026 21:12:22 -0700 Subject: [PATCH] feat(app): rebuild Knobs as a stack of features The Knobs panel is now a list of features under their areas. Opening one pushes its page: its presets, its controls on one grid, Replay where a trigger exists, Focus on the part of the app it changes, and Reset to Shipped. Wide enough, the list and the page sit side by side. Speed and slow motion stay in the debug bar. Also removes the Message menu experiment. --- .../app/component-tests/debug-bar.spec.ts | 48 ++ packages/app/src/runtime/i18n/en.ts | 1 + .../src/session/composer/activity-chips.tsx | 20 +- .../src/session/composer/spring-height.tsx | 83 +++- packages/app/src/session/screen.tsx | 2 +- packages/app/src/shell/panel-motion.ts | 22 +- packages/app/src/shell/shell.tsx | 6 +- packages/app/src/shell/sidebar-peek.ts | 10 +- .../app/src/shell/titlebar/tab-popover.tsx | 8 + packages/app/src/voice/composer.tsx | 4 + .../test-browser/motion-tuning-panel.test.ts | 149 ------ .../app/test-browser/panel-motion.test.ts | 6 +- packages/desktop/src/renderer/styles.css | 1 - packages/gui-extensions/src/debug/i18n/en.ts | 11 + .../gui-extensions/src/debug/motion-rows.tsx | 360 ++++++++++++++ packages/gui-extensions/src/debug/motion.tsx | 451 +++++++++++++++++- packages/session-ui/src/sources/experiment.ts | 2 + packages/ui/package.json | 2 +- .../project-avatar/project-avatar.tsx | 5 +- .../ui/src/motion-tuning-features.test.ts | 129 +++++ packages/ui/src/motion-tuning-features.ts | 161 +++++++ packages/ui/src/motion-tuning-panel.ts | 180 ------- packages/ui/src/motion-tuning.ts | 36 +- 23 files changed, 1319 insertions(+), 378 deletions(-) delete mode 100644 packages/app/test-browser/motion-tuning-panel.test.ts create mode 100644 packages/gui-extensions/src/debug/motion-rows.tsx create mode 100644 packages/ui/src/motion-tuning-features.test.ts create mode 100644 packages/ui/src/motion-tuning-features.ts delete mode 100644 packages/ui/src/motion-tuning-panel.ts diff --git a/packages/app/component-tests/debug-bar.spec.ts b/packages/app/component-tests/debug-bar.spec.ts index 075d04427a98..e40f48e1a4e3 100644 --- a/packages/app/component-tests/debug-bar.spec.ts +++ b/packages/app/component-tests/debug-bar.spec.ts @@ -51,3 +51,51 @@ story("the debug bar stays open across a remount until its close button hides it expect(result).toEqual({ opened: true, saved: { visible: false, motion: false }, reopened: false }) }) + +story("a feature page's Enabled switch turns that feature on after another feature's page", async ({ page }) => { + const result = await page.evaluate( + async (modules) => { + localStorage.setItem("opencode.motion.panel", JSON.stringify({ x: 0, y: 0, width: 680 })) + const { motionTuning } = await import(modules.tuning) + + const experiment = (id: string, subsection: string) => + motionTuning.toggle(id, { label: subsection, group: "Switches", subsection, experiment: true, value: false }) + + const first = experiment("switches.first", "First") + const second = experiment("switches.second", "Second") + const { mountExtensions, until } = await import(modules.fixture) + const debug = await import(modules.debug) + const definition = { ...debug.default, renderer: () => import(modules.renderer) } + + const host = mountExtensions({ + definitions: [definition], + stored: { "extension.debug.bar": { visible: true, motion: true } }, + }) + + host.release() + + const row = (name: string) => + document.querySelector(`[data-motion-feature="Switches · ${name}"]`) + + const enabled = (name: string) => + [...document.querySelectorAll("h3")] + .find((heading) => heading.textContent === name) + ?.parentElement?.querySelector('[data-component="switch"] input') + + await until(() => row("First") !== null) + row("First")?.click() + await until(() => !!enabled("First")) + row("Second")?.click() + await until(() => !!enabled("Second")) + enabled("Second")?.click() + await until(() => first() || second()) + const values = { first: first(), second: second() } + host.unmount() + + return values + }, + { ...modules, tuning: sourceURL(new URL("../../ui/src/motion-tuning.ts", import.meta.url)) }, + ) + + expect(result).toEqual({ first: false, second: true }) +}) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 97989d5b5d86..4fab4e053993 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -19,6 +19,7 @@ export const dict = { "voice.failure.dropped": "The call disconnected.", "voice.failure.connection": "Couldn’t connect the call.", "experiment.activityChip": "Activity chip", + "experiment.activityChip.style": "Style", "experiment.activityChip.today": "Today", "experiment.activityChip.orb": "CSS orb", "experiment.chipCorners": "Chip corners", diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx index 7164ddeca9d7..fffee8c6c568 100644 --- a/packages/app/src/session/composer/activity-chips.tsx +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -12,20 +12,27 @@ import { useSettings } from "@/settings/model" import "./activity-chips.css" const chip = motionTuning.choice("activity.chip", { - label: en["experiment.activityChip"], + label: en["experiment.activityChip.style"], group: "Composer", + subsection: en["experiment.activityChip"], + description: "Chip above the composer", + target: '[data-component="session-activity-chips"]', experiment: true, value: "today", labels: { today: en["experiment.activityChip.today"], orb: en["experiment.activityChip.orb"] }, }) + const corners = motionTuning.choice("activity.chip.corners", { label: en["experiment.chipCorners"], group: "Composer", + subsection: en["experiment.activityChip"], experiment: true, value: "capsule", labels: { capsule: en["experiment.chipCorners.capsule"], composer: en["experiment.chipCorners.composer"] }, }) + const [experiment, setExperiment] = createStore({ chip: chip(), corners: corners() }) + motionTuning.subscribe(() => setExperiment({ chip: chip(), corners: corners() })) export type SessionActivityItem = { @@ -73,16 +80,20 @@ export function SessionActivityChips(props: { }) { const language = useLanguage() const settings = useSettings() + const active = () => settings.general.backgroundActivity() !== "off" && (props.subagents.length > 0 || props.shells.length > 0) + const alignment = createMemo(() => { const value = settings.general.backgroundActivity() + if (value === "center") return "center" // Left/right are physical positions; Kobalte and flex alignment use logical start/end. return (value === "left") !== (language.direction() === "rtl") ? "start" : "end" }) - const placement = () => (alignment() === "center" ? "top" : alignment() === "end" ? "top-end" : "top-start") + + const placement = () => ({ center: "top", start: "top-start", end: "top-end" } as const)[alignment()] // A subagent can only be interrupted once its child session exists. const stoppable = (item: SessionActivityItem) => item.type === "shell" || !!item.sessionID @@ -200,6 +211,7 @@ export function SessionActivityChips(props: { onFocusIn={(event: FocusEvent) => { if (!(event.target instanceof HTMLElement)) return const row = event.target.closest('[data-component="menu-v2-item"]') + if (!row) return // Kobalte's scroll adjustment ignores scroll-padding; native scrolling keeps the ring clear of the edge. queueMicrotask(() => row.scrollIntoView({ block: "nearest", inline: "nearest" })) @@ -248,12 +260,16 @@ export function SessionActivityChips(props: { // Load on first opt-in, then retain both identities for an interruptible crossfade. function ActivityGlyph(props: { muted?: boolean }) { const enabled = createMemo((previous) => previous || experiment.chip === "orb", false) + const [orb] = createResource(enabled, async () => { const { CssAgentOrb } = await import("@/session/agent-glyph/css-agent-orb") + return CssAgentOrb }) + // Guard the first read so loading this experiment never suspends the composer or hides today's glyph. const loaded = () => orb.state === "ready" + return (