diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index 06856aa6bf4e..7f7c5894d14a 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -728,7 +728,9 @@ test("long MCP lists stay in the viewport in a real RTL locale", async ({ page } await summary.getByRole("button", { name: "MCP", exact: true }).click() const menu = page.getByRole("dialog", { name: "MCP", exact: true }) await expect(menu.locator(".session-mcp-row")).toHaveCount(30) - await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) + await expect + .poll(() => menu.locator("[data-scrollable]").evaluate((element) => element.scrollHeight > element.clientHeight)) + .toBe(true) await expect .poll(async () => { const bounds = (await menu.boundingBox())! diff --git a/packages/app/src/providers/models/select-dialog.tsx b/packages/app/src/providers/models/select-dialog.tsx index 99de94474375..08c0e3507c3c 100644 --- a/packages/app/src/providers/models/select-dialog.tsx +++ b/packages/app/src/providers/models/select-dialog.tsx @@ -991,38 +991,40 @@ export function DialogSelectModel(props: { provider?: string; model?: ModelState ref={(element) => (flyoutRef = element)} data-component="menu-v2-content" data-slot="model-dialog-flyout" - class="!fixed max-h-[400px] w-[200px] overflow-y-auto !p-0 !animate-none" + class="!fixed max-h-[400px] w-[200px] overflow-hidden !p-0 !animate-none" style={{ left: `${store.position.left}px`, top: `${store.position.top}px` }} onMouseEnter={() => triangle.cancel()} > - 0}> -
- - {(value) => ( -
event.currentTarget.setAttribute("data-highlighted", "")} - onMouseLeave={(event) => event.currentTarget.removeAttribute("data-highlighted")} - onClick={() => choose(item(), value ?? null)} - > - - {value ?? language.t("common.default")} - - + 0}> +
+ + {(value) => ( +
event.currentTarget.setAttribute("data-highlighted", "")} + onMouseLeave={(event) => event.currentTarget.removeAttribute("data-highlighted")} + onClick={() => choose(item(), value ?? null)} > - - -
- )} -
-
-
- + + {value ?? language.t("common.default")} + + + + +
+ )} +
+
+
+ + )} diff --git a/packages/app/src/session/composer/activity-chips.css b/packages/app/src/session/composer/activity-chips.css index c97a3079fd87..d429650616c7 100644 --- a/packages/app/src/session/composer/activity-chips.css +++ b/packages/app/src/session/composer/activity-chips.css @@ -67,13 +67,9 @@ min-width: max(var(--kb-popper-anchor-width, 0px), 240px); max-width: min(360px, calc(100vw - 56px)); max-height: min(270px, var(--kb-popper-content-available-height, 270px)); - overflow-y: auto; - display: grid; - grid-template-columns: auto auto minmax(0, 1fr) auto; - align-content: start; - column-gap: 8px; - padding: 4px; - scroll-padding-block: 4px; + display: flex; + flex-direction: column; + padding: 0; /* The capsule's hairline, so both insets round to the same device pixels. */ border: 0.5px solid transparent; border-radius: 10px; @@ -82,6 +78,11 @@ &[data-orb="true"] { column-gap: 6px; + + .scroll-view__viewport { + column-gap: 6px; + } + [data-component="menu-v2-item"] { padding-inline: 1px 8px; } @@ -180,6 +181,15 @@ } } + .scroll-view__viewport { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-content: start; + column-gap: 8px; + padding: 4px; + scroll-padding-block: 4px; + } + :root[data-motion="reduce"] & { animation: none; } diff --git a/packages/app/src/session/composer/activity-chips.fixture.tsx b/packages/app/src/session/composer/activity-chips.fixture.tsx index b64028579771..f9845f7062d5 100644 --- a/packages/app/src/session/composer/activity-chips.fixture.tsx +++ b/packages/app/src/session/composer/activity-chips.fixture.tsx @@ -1,7 +1,6 @@ import "@/index.css" import { render } from "solid-js/web" import { DialogProvider } from "@opencode/ui/context/dialog" -import { MarkedProvider } from "@opencode/ui/context/marked" import { ThemeProvider, useTheme } from "@opencode/ui/theme" import { LanguageProvider, UiI18nBridge } from "@/runtime/i18n/language" import { motionTuning } from "@opencode/ui/motion-tuning" @@ -18,9 +17,7 @@ render( - - - + diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx index 9910bbc772e7..9be4303a6390 100644 --- a/packages/app/src/session/composer/activity-chips.tsx +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -1,3 +1,4 @@ +import { ScrollView } from "@opencode/ui/scroll-view" import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" @@ -17,13 +18,16 @@ const chip = motionTuning.choice("activity.chip", { 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: "Experiments", 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 = { @@ -71,16 +75,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 @@ -197,44 +205,49 @@ export function SessionActivityChips(props: { aria-label={label()} 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" })) }} > - - {(item) => ( - - - - - {item.agent - ? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}` - : language.t("ui.tool.agent.default")} - - - - {item.label} - - {end(item)} - - )} - - - {(item) => ( - - - - {item.label} - - {end(item)} - - )} - + + + {(item) => ( + + + + + {item.agent + ? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}` + : language.t("ui.tool.agent.default")} + + + + {item.label} + + {end(item)} + + )} + + + {(item) => ( + + + + {item.label} + + {end(item)} + + )} + + @@ -246,12 +259,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 (