From 094c3db0f1b20eed414e8fb9420fb418ba129e2a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 14:05:25 -0700 Subject: [PATCH 1/6] fix(app): use the subtle scrollbar in menus --- .../src/session/composer/activity-chips.css | 22 ++++-- .../src/session/composer/activity-chips.tsx | 76 +++++++++++-------- packages/app/src/workspaces/submenu.tsx | 10 ++- packages/ui/src/components/scroll-view.tsx | 64 ++++++++++------ 4 files changed, 108 insertions(+), 64 deletions(-) diff --git a/packages/app/src/session/composer/activity-chips.css b/packages/app/src/session/composer/activity-chips.css index c97a3079fd87..5d655097a9b9 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; + display: flex; + flex-direction: column; column-gap: 8px; - padding: 4px; - scroll-padding-block: 4px; /* 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.tsx b/packages/app/src/session/composer/activity-chips.tsx index 9910bbc772e7..d04aeebc9915 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" @@ -71,16 +72,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 +202,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)} + + )} + + diff --git a/packages/app/src/workspaces/submenu.tsx b/packages/app/src/workspaces/submenu.tsx index 1f8b3eccc032..aa73036b92da 100644 --- a/packages/app/src/workspaces/submenu.tsx +++ b/packages/app/src/workspaces/submenu.tsx @@ -1,5 +1,6 @@ import { Icon } from "@opencode/ui/icon" import { Menu } from "@opencode/ui/menu" +import { ScrollView } from "@opencode/ui/scroll-view" import { getFilename } from "@opencode/util/path" import { createMemo, For, Show } from "solid-js" import { createStore } from "solid-js/store" @@ -86,7 +87,12 @@ export function WorkspaceSubmenu(props: { /> -
+ (list = el)} + data-slot="workspace-submenu-list" + class="[&_[data-scrollable]]:overscroll-contain" + > {(directory) => ( props.onSelect(directory)}> @@ -103,7 +109,7 @@ export function WorkspaceSubmenu(props: { {language.t("session.new.workspace.search.empty")}
- + props.onViewAll?.()}> diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index e46cec7af820..0cfdef29bd92 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,4 +1,14 @@ -import { createEffect, mergeProps, on, onCleanup, onMount, Show, splitProps, type ComponentProps } from "solid-js" +import { + createEffect, + mergeProps, + on, + onCleanup, + onMount, + Show, + splitProps, + type ComponentProps, + type JSX, +} from "solid-js" import { Portal } from "solid-js/web" import { createResizeObserver } from "@solid-primitives/resize-observer" import { createStore } from "solid-js/store" @@ -24,6 +34,8 @@ export interface ScrollViewProps extends ComponentProps<"div"> { onBeforeScroll?: () => void /** Offset/extent correction for a virtualized vertical scrollbar. */ verticalScrollAdjustment?: number + /** Set `false` inside menus and lists that own focus and arrow keys: the viewport takes no focus and handles no keys. */ + focusable?: boolean } export const scrollKey = (event: Pick) => { @@ -130,7 +142,7 @@ export function scrollOffsetFromThumbPointer(input: { export function ScrollView(props: ScrollViewProps) { const i18n = useI18n() - const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props) + const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover", focusable: true }, props) const [local, events, rest] = splitProps( merged, @@ -144,6 +156,7 @@ export function ScrollView(props: ScrollViewProps) { "thumbHoverTarget", "onBeforeScroll", "verticalScrollAdjustment", + "focusable", "style", ], [ @@ -170,6 +183,7 @@ export function ScrollView(props: ScrollViewProps) { const [state, setState] = createStore({ isHovered: false, + // SAFETY: widens the initial `undefined` to the field's full type; the store only ever assigns an axis or undefined. dragging: undefined as "vertical" | "horizontal" | undefined, isScrolling: false, verticalThumbSize: 0, @@ -261,10 +275,7 @@ export function ScrollView(props: ScrollViewProps) { // The observer reports every target once when it starts observing (including a thumb mount that // appears later), after layout. Measuring here as well would force a synchronous layout per // ScrollView while the page is still rendering. - createResizeObserver( - () => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[], - updateThumb, - ) + createResizeObserver(() => [viewportRef, viewportRef.firstElementChild, thumbMount()], updateThumb) }) // Inputs that change the thumb without resizing anything: re-measure on change, but leave the @@ -481,33 +492,29 @@ export function ScrollView(props: ScrollViewProps) { updateThumb() markScrolling() - if (typeof events.onScroll === "function") events.onScroll(e as any) + forward(events.onScroll, e) }} // Passive so the compositor never waits on the main thread to scroll; onWheel cannot cancel. on:wheel={{ passive: true, handleEvent: (e) => { markScrolling() - const handler = events.onWheel - - if (typeof handler === "function") handler(e as any) - - if (Array.isArray(handler)) handler[0](handler[1], e as any) + forward(events.onWheel, e) }, }} - onTouchStart={events.onTouchStart as any} - onTouchMove={events.onTouchMove as any} - onTouchEnd={events.onTouchEnd as any} - onTouchCancel={events.onTouchCancel as any} - onPointerDown={events.onPointerDown as any} - onClick={events.onClick as any} - tabIndex={0} - role="region" - aria-label={i18n.t("ui.scrollView.ariaLabel")} + onTouchStart={events.onTouchStart} + onTouchMove={events.onTouchMove} + onTouchEnd={events.onTouchEnd} + onTouchCancel={events.onTouchCancel} + onPointerDown={events.onPointerDown} + onClick={events.onClick} + tabIndex={local.focusable ? 0 : undefined} + role={local.focusable ? "region" : undefined} + aria-label={local.focusable ? i18n.t("ui.scrollView.ariaLabel") : undefined} onKeyDown={(e) => { - onKeyDown(e) + if (local.focusable) onKeyDown(e) - if (typeof events.onKeyDown === "function") events.onKeyDown(e as any) + forward(events.onKeyDown, e) }} > {local.children} @@ -527,3 +534,14 @@ export function ScrollView(props: ScrollViewProps) { ) } + +function forward( + handler: JSX.EventHandlerUnion | undefined, + event: E & { currentTarget: HTMLDivElement; target: Element }, +) { + if (!handler) return + + if ("0" in handler) return handler[0](handler[1], event) + + handler(event) +} From 425d4d1a65030f207dc591fa6363a33c57ab5068 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 15:14:18 -0700 Subject: [PATCH 2/6] fix(app): use the subtle scrollbar in the remaining menus and popovers --- .../src/providers/models/select-dialog.tsx | 56 +++++++++--------- .../src/session/composer/location-missing.tsx | 38 +++++++------ .../app/src/settings/providers/providers.tsx | 36 ++++++------ packages/app/src/settings/settings.css | 1 - .../titlebar/recently-closed-tabs-menu.tsx | 27 +++++---- .../gui-extensions/src/details/details.css | 13 +++-- .../gui-extensions/src/details/popover.tsx | 7 ++- .../src/details/server-panel.tsx | 5 +- .../src/details/workspace-menu.tsx | 21 ++++--- .../line-comment/line-comment.css | 14 +++-- .../line-comment/line-comment.tsx | 5 +- packages/ui/src/forms/select/select.css | 3 - packages/ui/src/forms/select/select.tsx | 21 ++++--- packages/ui/src/forms/typeahead/typeahead.tsx | 57 +++++++++---------- 14 files changed, 169 insertions(+), 135 deletions(-) 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/location-missing.tsx b/packages/app/src/session/composer/location-missing.tsx index 09b611bdd5be..6b86df43f1c9 100644 --- a/packages/app/src/session/composer/location-missing.tsx +++ b/packages/app/src/session/composer/location-missing.tsx @@ -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" @@ -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() @@ -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" > move.mutate({ source: "worktree" })} disabled={move.isPending}> @@ -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")} @@ -212,18 +216,20 @@ export function SessionLocationMissing(props: { sessionID: string; projectID: st {language.t("session.new.workspace.search.empty")} - - {(worktree) => ( - move.mutate({ source: "worktree", directory: worktree.directory })} - disabled={move.isPending} - > - - {getFilename(worktree.directory)} - - )} - + + + {(worktree) => ( + move.mutate({ source: "worktree", directory: worktree.directory })} + disabled={move.isPending} + > + + {getFilename(worktree.directory)} + + )} + + diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index be2b5c91691a..e85d63e86929 100644 --- a/packages/app/src/settings/providers/providers.tsx +++ b/packages/app/src/settings/providers/providers.tsx @@ -4,6 +4,7 @@ import { useDialog } from "@opencode/ui/context/dialog" import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" +import { ScrollView } from "@opencode/ui/scroll-view" import { OpenCodeLogo } from "@/providers/opencode-logo" import { showToast } from "@/shell/notifications/toast" import { popularProviders, useProviders } from "@/providers/catalog/providers" @@ -335,23 +336,24 @@ export const SettingsProviders: Component<{