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 (