Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/app/e2e/regression/session-summary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())!
Expand Down
56 changes: 29 additions & 27 deletions packages/app/src/providers/models/select-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
>
<Show when={variantsOf(item()).length > 0}>
<div class="flex flex-col p-0.5">
<For each={[undefined, ...variantsOf(item())]}>
{(value) => (
<div
data-component="menu-v2-item"
role="menuitemradio"
aria-checked={variant(item()) === value}
data-checked={variant(item()) === value ? "" : undefined}
onMouseEnter={(event) => event.currentTarget.setAttribute("data-highlighted", "")}
onMouseLeave={(event) => event.currentTarget.removeAttribute("data-highlighted")}
onClick={() => choose(item(), value ?? null)}
>
<span data-slot="menu-v2-item-content" class="capitalize">
{value ?? language.t("common.default")}
</span>
<span
data-slot="menu-v2-item-indicator"
<ScrollView focusable={false}>
<Show when={variantsOf(item()).length > 0}>
<div class="flex flex-col p-0.5">
<For each={[undefined, ...variantsOf(item())]}>
{(value) => (
<div
data-component="menu-v2-item"
role="menuitemradio"
aria-checked={variant(item()) === value}
data-checked={variant(item()) === value ? "" : undefined}
onMouseEnter={(event) => event.currentTarget.setAttribute("data-highlighted", "")}
onMouseLeave={(event) => event.currentTarget.removeAttribute("data-highlighted")}
onClick={() => choose(item(), value ?? null)}
>
<Icon name="check" size="small" />
</span>
</div>
)}
</For>
</div>
</Show>
<ModelDetails model={item()} provider={labels.name(item().provider)} compact />
<span data-slot="menu-v2-item-content" class="capitalize">
{value ?? language.t("common.default")}
</span>
<span
data-slot="menu-v2-item-indicator"
data-checked={variant(item()) === value ? "" : undefined}
>
<Icon name="check" size="small" />
</span>
</div>
)}
</For>
</div>
</Show>
<ModelDetails model={item()} provider={labels.name(item().provider)} compact />
</ScrollView>
</div>
)}
</Show>
Expand Down
24 changes: 17 additions & 7 deletions packages/app/src/session/composer/activity-chips.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -18,9 +17,7 @@ render(
<LanguageProvider locale="en">
<UiI18nBridge>
<DialogProvider>
<MarkedProvider>
<Preview />
</MarkedProvider>
<Preview />
</DialogProvider>
</UiI18nBridge>
</LanguageProvider>
Expand Down
83 changes: 50 additions & 33 deletions packages/app/src/session/composer/activity-chips.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<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" }))
}}
>
<For each={props.subagents}>
{(item) => (
<Menu.Item {...row(item)}>
<ActivityGlyph muted />
<span class="shrink-0 font-[530]">
<bdi>
{item.agent
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
: language.t("ui.tool.agent.default")}
</bdi>
</span>
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
{item.label}
</span>
{end(item)}
</Menu.Item>
)}
</For>
<For each={props.shells}>
{(item) => (
<Menu.Item {...row(item)}>
<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />
<span
dir="ltr"
class="min-w-0 flex-1 truncate font-mono text-[13px] tracking-[-0.04px] text-v2-text-text-base"
>
{item.label}
</span>
{end(item)}
</Menu.Item>
)}
</For>
<ScrollView focusable={false}>
<For each={props.subagents}>
{(item) => (
<Menu.Item {...row(item)}>
<ActivityGlyph muted />
<span class="shrink-0 font-[530]">
<bdi>
{item.agent
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
: language.t("ui.tool.agent.default")}
</bdi>
</span>
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
{item.label}
</span>
{end(item)}
</Menu.Item>
)}
</For>
<For each={props.shells}>
{(item) => (
<Menu.Item {...row(item)}>
<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />
<span
dir="ltr"
class="min-w-0 flex-1 truncate font-mono text-[13px] tracking-[-0.04px] text-v2-text-text-base"
>
{item.label}
</span>
{end(item)}
</Menu.Item>
)}
</For>
</ScrollView>
</Menu.Content>
</Menu.Portal>
</Menu>
Expand All @@ -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 (
<span data-slot="activity-glyph" data-orb={experiment.chip === "orb" && loaded()} aria-hidden="true">
<span data-slot="activity-glyph-today">
Expand Down
38 changes: 22 additions & 16 deletions packages/app/src/session/composer/location-missing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DockShell, DockTray } from "@opencode/ui/dock-surface"
import { Icon } from "@opencode/ui/icon"
import { Loader } from "@opencode/ui/loader"
import { Menu } from "@opencode/ui/menu"
import { ScrollView } from "@opencode/ui/scroll-view"
import { getFilename, sameDirectory } from "@opencode/util/path"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useMutation } from "@tanstack/solid-query"
Expand Down Expand Up @@ -46,6 +47,7 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st

const visibleWorktrees = () => {
const query = state.search.trim().toLowerCase()

const matches = query
? otherWorktrees().filter((item) => getFilename(item.directory).toLowerCase().includes(query))
: otherWorktrees()
Expand Down Expand Up @@ -157,16 +159,20 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
"keydown",
(event) => {
if (!searchInput || event.target === searchInput) return

if (event.ctrlKey || event.metaKey || event.altKey) return

if (event.key.length !== 1 && event.key !== "Backspace") return

if (event.key === " " && !state.search) return

event.stopPropagation()
searchInput.focus({ preventScroll: true })
},
{ capture: true },
)
}
class="max-h-[66.667dvh] w-[200px] overflow-y-auto"
class="max-h-[66.667dvh] w-[200px] overflow-hidden"
>
<Menu.Item onSelect={() => move.mutate({ source: "worktree" })} disabled={move.isPending}>
<Icon name="plus" />
Expand All @@ -185,9 +191,7 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
// Worktrees load after the menu opens, so focus the field once it mounts.
ref={(element) => {
searchInput = element
requestAnimationFrame(() =>
requestAnimationFrame(() => element.focus({ preventScroll: true })),
)
requestAnimationFrame(() => requestAnimationFrame(() => element.focus({ preventScroll: true })))
}}
value={state.search}
placeholder={language.t("session.new.workspace.search.placeholder")}
Expand All @@ -212,18 +216,20 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st
{language.t("session.new.workspace.search.empty")}
</div>
</Show>
<For each={visibleWorktrees()}>
{(worktree) => (
<Menu.Item
title={worktree.directory}
onSelect={() => move.mutate({ source: "worktree", directory: worktree.directory })}
disabled={move.isPending}
>
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(worktree.directory)}</span>
</Menu.Item>
)}
</For>
<ScrollView focusable={false}>
<For each={visibleWorktrees()}>
{(worktree) => (
<Menu.Item
title={worktree.directory}
onSelect={() => move.mutate({ source: "worktree", directory: worktree.directory })}
disabled={move.isPending}
>
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(worktree.directory)}</span>
</Menu.Item>
)}
</For>
</ScrollView>
</Menu.Content>
</Menu.Portal>
</Menu>
Expand Down
Loading
Loading