feat(app): peek the closed sidebar on hover (#58156)

This commit is contained in:
James Long
2026-10-22 13:10:56 -04:00
committed by GitHub
Unverified
parent 010283ed0d
commit 110faa19ae
7 changed files with 388 additions and 8 deletions
+1
View File
@@ -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",
+14 -1
View File
@@ -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. */}
<div
ref={(element) => 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 }}
>
<div
ref={(element) => 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`,
+41
View File
@@ -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;
}
+280
View File
@@ -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<boolean>
open: Accessor<boolean>
aside: Accessor<HTMLElement | undefined>
/** The floating box, which doesn't move while the content inside it slides. */
panel: Accessor<HTMLElement | undefined>
content: Accessor<HTMLElement | undefined>
}) {
const [shown, setShown] = createSignal(false)
let toggle: HTMLElement | undefined
let animation: Animation | undefined
let opening: ReturnType<typeof setTimeout> | undefined
let closing: ReturnType<typeof setTimeout> | undefined
let watch: ReturnType<typeof setInterval> | 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<HTMLElement>(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(", ")})` }
}
+42 -7
View File
@@ -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 (
<Show when={props.show}>
<span
data-slot="sidebar-toggle-unread"
aria-hidden="true"
class="pointer-events-none absolute -top-0.5 -end-0.5 size-[7px] rounded-full bg-v2-background-bg-accent"
classList={{
"shadow-[0_0_0_1.5px_var(--v2-background-bg-base)]": props.ring === "base",
"shadow-[0_0_0_1.5px_var(--v2-background-bg-deep)]": props.ring === "deep",
}}
/>
</Show>
)
}
// 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" }) {
<Tooltip
class="shrink-0 [app-region:no-drag]"
placement="bottom"
// Held shut while the closed sidebar peeks on hover, where it would cover the peek.
forceOpen={peek() && !settings.appearance.sidebarOpen() ? false : undefined}
value={
<>
{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" ? (
<span class="relative flex">
<Icon ref={full} name="layout-left-full" />
<Icon ref={outline} name="layout-left" class="absolute inset-0" />
<span ref={outline} class="absolute inset-0 flex">
<Icon name={closedIcon()} />
<UnreadBadge show={unread()} ring="base" />
</span>
</span>
) : (
<Icon
name={side === "out" || (!side && settings.appearance.sidebarOpen()) ? "layout-left-full" : "layout-left"}
/>
<span class="relative flex">
<Icon
name={side === "out" || (!side && settings.appearance.sidebarOpen()) ? "layout-left-full" : closedIcon()}
/>
<UnreadBadge show={!side && unread()} ring="deep" />
</span>
)
}
/>
@@ -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`,
@@ -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. */}
<div
data-slot="sidebar-header"
data-home={homeInSidebarHeader() ? "" : undefined}
class="flex w-full shrink-0 gap-2"
classList={{
"items-start pt-2.5 pb-1": homeInSidebarHeader(),