Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/app/component-tests/debug-bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>(`[data-motion-feature="Switches · ${name}"]`)

const enabled = (name: string) =>
[...document.querySelectorAll("h3")]
.find((heading) => heading.textContent === name)
?.parentElement?.querySelector<HTMLInputElement>('[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 })
})
1 change: 1 addition & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 18 additions & 2 deletions packages/app/src/session/composer/activity-chips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -200,6 +211,7 @@ export function SessionActivityChips(props: {
onFocusIn={(event: FocusEvent) => {
if (!(event.target instanceof HTMLElement)) return
const row = event.target.closest<HTMLElement>('[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" }))
Expand Down Expand Up @@ -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 (
<span data-slot="activity-glyph" data-orb={experiment.chip === "orb" && loaded()} aria-hidden="true">
<span data-slot="activity-glyph-today">
Expand Down
Loading
Loading