From 110faa19ae990346060600592f4f91863109f804 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 22 Oct 2026 13:10:56 -0400 Subject: [PATCH] feat(app): peek the closed sidebar on hover (#58156) --- packages/app/src/runtime/i18n/en.ts | 1 + packages/app/src/shell/shell.tsx | 15 +- packages/app/src/shell/sidebar-peek.css | 41 +++ packages/app/src/shell/sidebar-peek.ts | 280 ++++++++++++++++++ packages/app/src/shell/sidebar-toggle.tsx | 49 ++- packages/app/src/shell/titlebar/tab-strip.tsx | 8 + packages/app/src/shell/titlebar/titlebar.tsx | 2 + 7 files changed, 388 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/shell/sidebar-peek.css create mode 100644 packages/app/src/shell/sidebar-peek.ts diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 1afeb7ebbee..e04f1d5082d 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -62,6 +62,7 @@ export const dict = { "command.input.focus": "Focus input", "command.review.toggle": "Toggle review", "command.sidebar.toggle": "Toggle sidebar", + "command.sidebar.toggle.unread": "Toggle sidebar, unread sessions", "command.bottom.toggle": "Toggle bottom panel", "command.message.previous": "Previous message", "command.message.previous.description": "Go to the previous user message", diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 2a64a5b9802..4cfac8c3596 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -19,6 +19,7 @@ import { useServers } from "@/runtime/server/registry" import { useTabs } from "@/shell/tabs/tabs" import { CollapsedSidebarToggle, measureSidebarToggles, paintCollapsedSlots } from "@/shell/sidebar-toggle" import { createPanelMotion, guardClosingFocus, panelOpacity, replayPanelToggle } from "@/shell/panel-motion" +import { createSidebarPeek } from "@/shell/sidebar-peek" import { windowDragEnabled } from "@/session/header/drag-area" export const sidebarToggleCommand = { id: "sidebar.toggle", keybind: "mod+shift+b" } @@ -68,6 +69,14 @@ export default function Layout(props: ParentProps) { offset: () => -10, }) + const peek = createSidebarPeek({ + available: () => verticalTabs() && !preferences.appearance.sidebarOpen() && !sidebarMotion.present(), + open: preferences.appearance.sidebarOpen, + aside: () => state.tabsAside, + panel: () => state.tabsPanel, + content: () => state.tabsMount, + }) + replayPanelToggle({ id: "sidebar", label: "Toggle the left sidebar", toggle: toggleSidebar }) guardClosingFocus({ @@ -158,6 +167,8 @@ export default function Layout(props: ParentProps) { id="vertical-tabs-sidebar" data-slot="vertical-tabs-sidebar" data-state={preferences.appearance.sidebarOpen() ? "open" : "closed"} + data-peek={peek.shown() ? "" : undefined} + style={{ "--sidebar-peek-width": `${state.tabsWidth}px` }} ref={(element) => setState("tabsAside", element)} class="relative z-10 h-full min-h-0 shrink-0" > @@ -165,13 +176,15 @@ export default function Layout(props: ParentProps) { a reopen reuses their layout. */}
setState("tabsPanel", element)} + data-slot="vertical-tabs-panel" class="absolute inset-0 overflow-hidden [contain:layout_style_paint]" - classList={{ "[content-visibility:hidden]": !sidebarMotion.present() }} + classList={{ "[content-visibility:hidden]": !sidebarMotion.present() && !peek.shown() }} // Under James the sidebar keeps its landing width (start edge held) while the layout moves. style={{ width: sidebarMotion.pinned() ? `${state.tabsWidth}px` : undefined }} >
setState("tabsMount", element)} + data-slot="vertical-tabs-mount" class="absolute inset-y-0 end-0 contain-strict will-change-[opacity] flex min-h-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]" style={{ width: `${state.tabsWidth}px`, diff --git a/packages/app/src/shell/sidebar-peek.css b/packages/app/src/shell/sidebar-peek.css new file mode 100644 index 00000000000..7c8cfb15dc6 --- /dev/null +++ b/packages/app/src/shell/sidebar-peek.css @@ -0,0 +1,41 @@ +/* The closed sidebar floats as a card below the session header, as tall as its rows up to the window's bottom gap, + without resizing its zero-width aside. */ +[data-slot="vertical-tabs-sidebar"][data-peek] > [data-slot="vertical-tabs-panel"] { + /* Past the shell's 8px frame inset, so the card's edges sit inside the frame's. */ + --sidebar-peek-gap: 12px; + --sidebar-peek-start: 16px; + /* Below the session header, which ends 48px past the shell's top inset. */ + --sidebar-peek-top: calc(var(--shell-top-inset, 8px) + 48px + var(--sidebar-peek-gap)); + inset: var(--sidebar-peek-top) auto auto; + inset-inline: var(--sidebar-peek-start) auto; + width: var(--sidebar-peek-width); + max-height: calc(100% - var(--sidebar-peek-top) - var(--sidebar-peek-gap)); + display: flex; + flex-direction: column; + overflow: visible; + contain: none; +} + +[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="vertical-tabs-mount"] { + position: relative; + inset: auto; + width: 100% !important; + flex: 0 1 auto; + min-height: 0; + padding-block: 8px !important; + padding-inline: 8px 4px !important; + contain: layout style; + overflow: hidden; + border-radius: 12px; + box-shadow: var(--v2-elevation-floating); +} + +/* The header only spaces the traffic lights and holds the docked toggle; Home, where it leads, stays as a row. */ +[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="sidebar-header"]:not([data-home]), +[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="sidebar-header"] > :last-child { + display: none; +} + +[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="sidebar-header"] { + padding-top: 0; +} diff --git a/packages/app/src/shell/sidebar-peek.ts b/packages/app/src/shell/sidebar-peek.ts new file mode 100644 index 00000000000..50ba411f2bc --- /dev/null +++ b/packages/app/src/shell/sidebar-peek.ts @@ -0,0 +1,280 @@ +import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" +import { prefersReducedMotion, Spring } from "@opencode/ui/motion" +import { motionTuning } from "@opencode/ui/motion-tuning" +import "./sidebar-peek.css" + +export const sidebarPeekEnabled = motionTuning.toggle("sidebar.peek", { + label: "Peekable sidebar", + group: "Experiments", + value: true, +}) + +/** Whether any session in the vertical sidebar draws its unread dot, published by the sidebar's tab strip. */ +export const [sidebarUnread, setSidebarUnread] = createSignal(false) + +const toggles = 'button[aria-controls="vertical-tabs-sidebar"]' + +const menus = '[data-component="menu-v2-content"], [role="menu"]' + +// How long the pointer rests on the toggle before the sidebar peeks, so passing over it doesn't flash it. +const intent = 500 + +// How far past the sidebar and toggle the pointer may stray, and for how long, before the peek closes. +const margin = 15 + +const grace = 100 + +/** + * Experiment: while the left sidebar is closed, hovering its toggle floats the sidebar over the app without moving the + * layout. The peek holds while the pointer stays within the box spanning the toggle and the sidebar, plus a margin, so + * the way from the toggle into the sidebar never closes it. Closing reads the pointer's last known position on every + * move, scroll and wheel, and on a slow interval, rather than trusting enter and leave events on elements. + */ +export function createSidebarPeek(input: { + /** Whether a peek may start: the sidebar is fully closed. */ + available: Accessor + open: Accessor + aside: Accessor + /** The floating box, which doesn't move while the content inside it slides. */ + panel: Accessor + content: Accessor +}) { + const [shown, setShown] = createSignal(false) + let toggle: HTMLElement | undefined + let animation: Animation | undefined + let opening: ReturnType | undefined + let closing: ReturnType | undefined + let watch: ReturnType | undefined + // The pointer's last position in the window, or undefined once it has left the window. + let pointer: { x: number; y: number } | undefined + let pressed = false + let keyboard = false + + const cancelOpen = () => { + clearTimeout(opening) + opening = undefined + } + + const cancelClose = () => { + clearTimeout(closing) + closing = undefined + } + + const show = () => { + cancelOpen() + cancelClose() + const content = input.content() + + if (!content) return + setShown(true) + watch ??= setInterval(check, 150) + + if (animation) { + if (animation.playbackRate < 0) animation.reverse() + animation.onfinish = null + return + } + + const direction = document.documentElement.dir === "rtl" ? 1 : -1 + // Filled forward, so it also lifts the closed sidebar's dimmed opacity while the peek lasts. + animation = content.animate( + [ + { opacity: 0, transform: `translateX(calc(${direction} * (100% + 16px)))` }, + { opacity: 1, transform: "none" }, + ], + { ...(prefersReducedMotion() ? { duration: 0 } : timing()), fill: "both" }, + ) + } + + const end = () => { + cancelOpen() + cancelClose() + clearInterval(watch) + watch = undefined + animation?.cancel() + animation = undefined + toggle = undefined + setShown(false) + } + + const hide = (focus?: boolean) => { + cancelClose() + + if (!shown()) return + clearInterval(watch) + watch = undefined + const aside = input.aside() + + if (aside?.contains(document.activeElement)) { + if (focus && toggle?.isConnected) toggle.focus({ preventScroll: true }) + if (!focus && document.activeElement instanceof HTMLElement) document.activeElement.blur() + } + + if (!animation || prefersReducedMotion()) return end() + const current = animation + current.reverse() + current.onfinish = () => { + if (current.playbackRate < 0) end() + } + } + + // A visible menu, a drag, or typing in one of the sidebar's fields keeps it open wherever the pointer goes. + const held = () => { + const active = document.activeElement + const typing = + active instanceof HTMLElement && + input.aside()?.contains(active) && + (active.matches("input, textarea") || active.isContentEditable) && + active.getClientRects().length > 0 + const menu = [...document.querySelectorAll(menus)].some((element) => element.getClientRects().length > 0) + + return pressed || typing || menu || !!document.querySelector('[data-dragging="true"]') + } + + const inside = (point: { x: number; y: number }) => { + const panel = input.panel() + + if (!panel) return false + const sheet = panel.getBoundingClientRect() + const button = toggle?.isConnected ? toggle.getBoundingClientRect() : sheet + + // The box spanning both is the corridor from the toggle down to the sheet's top. + return ( + point.x >= Math.min(sheet.left, button.left) - margin && + point.x <= Math.max(sheet.right, button.right) + margin && + point.y >= Math.min(sheet.top, button.top) - margin && + point.y <= Math.max(sheet.bottom, button.bottom) + margin + ) + } + + function check() { + if (!shown() || input.open() || !watch) return + + if ((pointer && inside(pointer)) || held()) return cancelClose() + closing ??= setTimeout(() => { + closing = undefined + + if (!(pointer && inside(pointer)) && !held()) hide() + }, grace) + } + + const track = (event: PointerEvent | WheelEvent) => { + if (event instanceof PointerEvent && event.pointerType === "touch") return + + if (event instanceof PointerEvent) pressed = event.buttons !== 0 + pointer = { x: event.clientX, y: event.clientY } + check() + } + + makeEventListener( + document, + "pointerover", + (event) => { + if (event.pointerType === "touch" || !(event.target instanceof Element)) return + const button = event.target.closest(toggles) + + if (!button || input.aside()?.contains(button)) return + toggle = button + + if (shown()) return show() + + if (!sidebarPeekEnabled() || !input.available() || opening) return + opening = setTimeout(() => { + opening = undefined + + if (sidebarPeekEnabled() && input.available() && toggle?.matches(":hover")) show() + }, intent) + }, + { passive: true }, + ) + makeEventListener( + document, + "pointerout", + (event) => { + // Leaving the window, or into an iframe or native view, reports no related target. + if (!event.relatedTarget) { + pointer = undefined + check() + } + + if (!opening || !(event.target instanceof Element)) return + const button = event.target.closest(toggles) + + if (button && !(event.relatedTarget instanceof Node && button.contains(event.relatedTarget))) cancelOpen() + }, + { passive: true }, + ) + makeEventListener(document, "pointermove", track, { passive: true, capture: true }) + makeEventListener(document, "wheel", track, { passive: true, capture: true }) + makeEventListener(document, "scroll", () => check(), { passive: true, capture: true }) + makeEventListener( + document, + "pointerdown", + (event) => { + keyboard = false + pressed = event.pointerType !== "touch" + }, + { passive: true, capture: true }, + ) + makeEventListener(window, "pointerup", () => (pressed = false), { passive: true, capture: true }) + makeEventListener(window, "pointercancel", () => (pressed = false), { passive: true, capture: true }) + makeEventListener(document.documentElement, "pointerleave", () => { + pointer = undefined + check() + }) + makeEventListener(document, "keydown", (event) => { + keyboard = true + + if (event.key !== "Escape" || !shown() || input.open() || event.defaultPrevented || held()) return + hide(true) + }) + makeEventListener(window, "blur", () => { + pressed = false + hide() + }) + + // Keyboard focus leaving the peek closes it; pointer clicks elsewhere already close it by leaving. + createEffect(() => { + const aside = input.aside() + + if (!aside) return + const leave = (event: FocusEvent) => { + const next = event.relatedTarget + + if (!keyboard || !shown() || input.open() || !(next instanceof Element)) return + + if (aside.contains(next) || toggle?.contains(next) || next.closest(menus)) return + hide() + } + aside.addEventListener("focusout", leave) + onCleanup(() => aside.removeEventListener("focusout", leave)) + }) + + // Docking hands over to the docked sidebar's own motion. + createEffect(on(input.open, (open) => open && end(), { defer: true })) + + createEffect(() => { + if (!sidebarPeekEnabled()) end() + }) + + onCleanup(end) + + return { shown } +} + +// The peek's spring, 0.15s without bounce, sampled per 60 Hz frame into a CSS `linear()` easing. +function timing() { + const motion = Spring.fromDuration(0, { duration: 0.15, bounce: 0 }).set(1) + const points = [0] + + while (points.length < 240) { + points.push(Math.round(motion.step(1 / 60) * 1000) / 1000) + + if (Math.abs(1 - motion.value) < 0.005 && Math.abs(motion.velocity) < 0.05) break + } + + points.push(1) + + return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` } +} diff --git a/packages/app/src/shell/sidebar-toggle.tsx b/packages/app/src/shell/sidebar-toggle.tsx index 8a0bd50ccdd..809be443e1b 100644 --- a/packages/app/src/shell/sidebar-toggle.tsx +++ b/packages/app/src/shell/sidebar-toggle.tsx @@ -1,4 +1,4 @@ -import { createEffect, onCleanup, Show } from "solid-js" +import { createEffect, createSignal, onCleanup, Show } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" @@ -8,6 +8,32 @@ import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { useSettings } from "@/settings/model" +import { motionTuning } from "@opencode/ui/motion-tuning" +import { sidebarPeekEnabled, sidebarUnread } from "@/shell/sidebar-peek" + +// The Sidebar peek experiment shows the closed sidebar's toggle as a menu. +const [peek, setPeek] = createSignal(sidebarPeekEnabled()) + +motionTuning.subscribe(() => setPeek(sidebarPeekEnabled())) + +const closedIcon = () => (peek() ? "menu" : "layout-left") + +// The menu icon's dot for sidebar sessions with unread activity, shown while the sidebar is closed. +function UnreadBadge(props: { show: boolean; ring: "base" | "deep" }) { + return ( + + + ) +} // The desktop window sets `trafficLightPosition: { x: 14, y: 14 }`, and the titlebar moves the buttons down to share the // header's center line in vertical tabs. macOS 26 and later draw 14px buttons on a 23px pitch, so the zoom button ends @@ -44,7 +70,8 @@ export function SidebarToggle(props: { handoff?: "out" | "in" }) { const keybind = () => command.keybindParts("sidebar.toggle") const side = props.handoff let full: SVGSVGElement | undefined - let outline: SVGSVGElement | undefined + let outline: HTMLSpanElement | undefined + const unread = () => peek() && !settings.appearance.sidebarOpen() && sidebarUnread() const ref = side === "out" ? collapsedRef((button, closed) => { @@ -70,6 +97,8 @@ export function SidebarToggle(props: { handoff?: "out" | "in" }) { {language.t("command.sidebar.toggle")} @@ -87,19 +116,25 @@ export function SidebarToggle(props: { handoff?: "out" | "in" }) { class="shrink-0" onMouseDown={(event) => event.preventDefault()} onClick={() => settings.appearance.setSidebarOpen(!settings.appearance.sidebarOpen())} - aria-label={language.t("command.sidebar.toggle")} + aria-label={language.t(unread() ? "command.sidebar.toggle.unread" : "command.sidebar.toggle")} aria-expanded={settings.appearance.sidebarOpen()} aria-controls="vertical-tabs-sidebar" icon={ side === "in" ? ( - + + + + ) : ( - + + + + ) } /> diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx index 83a784d19c5..d31a52624da 100644 --- a/packages/app/src/shell/titlebar/tab-strip.tsx +++ b/packages/app/src/shell/titlebar/tab-strip.tsx @@ -25,6 +25,7 @@ import { sessionTreeIDs } from "@/session/requests/session-request-tree" import { sessionTitle } from "@/session/title" import { errorMessage } from "@/shell/layout/helpers" import { notifySessionTabsRemoved } from "./session-events" +import { setSidebarUnread } from "@/shell/sidebar-peek" import type { SessionInfo } from "@opencode/client/promise" export const previousSessionCommand = { id: "session.previous", keybind: "alt+arrowup" } @@ -396,6 +397,13 @@ export function TitlebarTabStrip(props: { const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)])) const visibleTabIds = () => visibleTabs().map(tabKey) + // Tells the closed sidebar's toggle whether any row here shows its unread dot, which the current tab never does. + createEffect(() => { + if (!vertical()) return + setSidebarUnread(props.tabs.some((tab) => tab !== props.currentTab && !!unread[tabKey(tab)])) + onCleanup(() => setSidebarUnread(false)) + }) + command.register("titlebar-tab-cycle", () => [ { id: `tab.prev`, diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index 706db602015..706617b0678 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -796,6 +796,8 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) { {/* Shares the session header's 48px center line. Without traffic lights, Home fills the start so the row doesn't leave an empty band. */}