diff --git a/packages/app/src/shell/titlebar/sidebar-hover.tsx b/packages/app/src/shell/titlebar/sidebar-hover.tsx
deleted file mode 100644
index b3ae1a90be8..00000000000
--- a/packages/app/src/shell/titlebar/sidebar-hover.tsx
+++ /dev/null
@@ -1,199 +0,0 @@
-import { frameLoop, prefersReducedMotion, Spring } from "@opencode/ui/motion"
-import { motionTuning } from "@opencode/ui/motion-tuning"
-import { makeEventListener } from "@solid-primitives/event-listener"
-import { createSignal, onCleanup, onMount, Show } from "solid-js"
-
-const choice = motionTuning.choice("sidebar.hover", {
- label: "Style",
- group: "Experiments",
- card: "Sidebar hover",
- order: 2,
- value: "today",
- labels: { today: "Today", glide: "Glide" },
-})
-
-const tuning = motionTuning.spring("sidebar.hover.glide", {
- label: "Glide",
- group: "Experiments",
- card: "Sidebar hover",
- order: 2,
- presets: {
- snappy: { duration: 0.14, bounce: 0 },
- smooth: { duration: 0.2, bounce: 0 },
- calm: { duration: 0.28, bounce: 0 },
- },
-})
-
-const [mode, setMode] = createSignal(choice())
-
-motionTuning.subscribe(() => setMode(choice()))
-
-const rows = "[data-titlebar-tab], [data-titlebar-tab-action]"
-
-// The pill's corner radius, matching the rows'.
-const radius = 6
-
-const grid = (value: number) => Math.round(value * 64) / 64
-
-const edges = ["top", "bottom", "left", "right"] as const
-
-/**
- * Experiment: one hover pill glides behind the sidebar's rows, instead of each row lighting its own background. It
- * targets the row nearest the pointer, so crossing a gap between rows holds it rather than dropping it.
- */
-export function SidebarHover() {
- return (
-
-
-
- )
-}
-
-function Pill() {
- let pill!: HTMLDivElement
- let top!: HTMLDivElement
- let fill!: HTMLDivElement
- let bottom!: HTMLDivElement
- let pointer: { x: number; y: number } | undefined
- // The hovered row's edges relative to the sidebar, from the last frame's read phase.
- let hit: { top: number; bottom: number; left: number; right: number } | undefined
- let frame: (() => void) | undefined
-
- const motion = {
- top: Spring.fromDuration(0, tuning()),
- bottom: Spring.fromDuration(0, tuning()),
- left: Spring.fromDuration(0, tuning()),
- right: Spring.fromDuration(0, tuning()),
- opacity: Spring.fromDuration(0, { duration: 0.15, bounce: 0 }),
- }
-
- onMount(() => {
- // SAFETY: the pill renders inside the sidebar's portal root.
- const root = pill.parentElement!
- root.dataset.sidebarHover = "glide"
- onCleanup(() => delete root.dataset.sidebarHover)
-
- const read = () => {
- hit = pointer && !root.querySelector('[data-dragging="true"]') ? nearest(root, pointer) : undefined
- }
-
- const write = (dt: number) => {
- const spring = tuning()
- const reduced = prefersReducedMotion()
-
- const target = hit
-
- edges.forEach((edge) => {
- motion[edge].omega = (2 * Math.PI) / spring.duration
- motion[edge].zeta = 1 - spring.bounce
-
- if (!target) return
-
- // Appearing from hidden starts on the row rather than sweeping in from where it left.
- if (motion.opacity.value === 0 || reduced) motion[edge].snap(target[edge])
- motion[edge].set(target[edge])
- })
-
- motion.opacity.set(target ? 1 : 0)
-
- const y0 = grid(motion.top.step(dt))
- const y1 = grid(motion.bottom.step(dt))
- top.style.transform = `translateY(${y0}px)`
- bottom.style.transform = `translateY(${y1 - radius * 2}px)`
- fill.style.transform = `translateY(${y0 + radius}px) scaleY(${Math.max(0, y1 - y0 - radius * 2)})`
-
- const x0 = grid(motion.left.step(dt))
- const width = `${grid(motion.right.step(dt)) - x0}px`
- pill.style.transform = `translateX(${x0}px)`
-
- // Only Home beside the sidebar toggle is narrower, so width changes only on the way to or from it.
- if (pill.style.width !== width) pill.style.width = width
- pill.style.opacity = String(motion.opacity.step(dt))
-
- if (Object.values(motion).some((item) => !item.resting)) return true
- frame = undefined
-
- return false
- }
-
- const wake = () => {
- frame ??= frameLoop.addMeasured(read, write)
- }
-
- // While the pointer rests inside, rows that open, close, resize or finish a drag move the pill to the row now under it.
- const resize = new ResizeObserver(wake)
- const mutation = new MutationObserver(wake)
-
- makeEventListener(root, "pointermove", (event) => {
- if (event.pointerType === "touch") return
-
- if (!pointer)
- root.querySelectorAll("[data-titlebar-tab-list]").forEach((list) => {
- resize.observe(list)
- mutation.observe(list, { childList: true, subtree: true, attributeFilter: ["data-dragging"] })
- })
-
- pointer = { x: event.clientX, y: event.clientY }
- wake()
- })
- makeEventListener(root, "pointerleave", () => {
- pointer = undefined
- resize.disconnect()
- mutation.disconnect()
- wake()
- })
- makeEventListener(root, "pointerup", wake)
- // Rows that scroll under a resting pointer move the pill with them.
- makeEventListener(root, "scroll", wake, { capture: true, passive: true })
- onCleanup(() => {
- frame?.()
- resize.disconnect()
- mutation.disconnect()
- })
- })
-
- // Opaque slices, so the caps can overlap the middle without darkening it; the group fades as one.
- return (
-
- )
-}
-
-// The row nearest the pointer, clipped to the scrolling list, or none once the pointer leaves the rows' span.
-function nearest(root: HTMLElement, pointer: { x: number; y: number }) {
- const box = root.getBoundingClientRect()
- const scroll = root.querySelector('[data-slot="vertical-tabs-scroll"]')
- const view = scroll?.getBoundingClientRect()
-
- const boxes = [...root.querySelectorAll(rows)].flatMap((element) => {
- const rect = element.getBoundingClientRect()
- const clip = view && scroll?.contains(element)
- const top = clip ? Math.max(rect.top, view.top) : rect.top
- const bottom = clip ? Math.min(rect.bottom, view.bottom) : rect.bottom
-
- return bottom - top > 0 ? [{ top, bottom, left: rect.left, right: rect.right }] : []
- })
-
- const first = boxes[0]
-
- const last = boxes.at(-1)
-
- if (!first || !last || pointer.y < first.top || pointer.y > last.bottom) return
-
- const distance = (row: { top: number; bottom: number }) => Math.max(row.top - pointer.y, pointer.y - row.bottom, 0)
- // A tie in a gap goes to the lower row, as clicks do.
- const row = boxes.reduce((best, item) => (distance(item) <= distance(best) ? item : best))
-
- // Beside the row, such as on the sidebar toggle next to Home, is off the rows.
- if (pointer.x < row.left || pointer.x > row.right) return
-
- return { top: row.top - box.top, bottom: row.bottom - box.top, left: row.left - box.left, right: row.right - box.left }
-}
diff --git a/packages/app/src/shell/titlebar/tab-nav.css b/packages/app/src/shell/titlebar/tab-nav.css
index cea93a048f4..e8427b1d563 100644
--- a/packages/app/src/shell/titlebar/tab-nav.css
+++ b/packages/app/src/shell/titlebar/tab-nav.css
@@ -144,34 +144,6 @@
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
-/* The sidebar hover experiment: one pill glides behind the rows, which keep only their own pressed and focus states. */
-[data-sidebar-hover="glide"] {
- position: relative;
- isolation: isolate;
-}
-
-[data-slot="sidebar-hover-pill"] > * {
- background:
- linear-gradient(var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
- var(--v2-background-bg-deep);
-}
-
-[data-sidebar-hover="glide"]
- :is(
- [data-titlebar-tab]:not(:has(> [data-slot="tab-link"]:focus-visible)),
- [data-titlebar-tab-action]:not(:focus-visible)
- ):not(:active, [data-state="pressed"], [data-dragging="true"], [data-editing="true"]),
-[data-sidebar-hover="glide"]
- [data-titlebar-tab]:not(:has(> [data-slot="tab-link"]:focus-visible)):not(
- :active,
- [data-state="pressed"],
- [data-dragging="true"],
- [data-editing="true"]
- )
- [data-slot="tab-close"] {
- background: none;
-}
-
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
diff --git a/packages/app/src/shell/titlebar/tab-popover.tsx b/packages/app/src/shell/titlebar/tab-popover.tsx
index 080eda6165f..0ffc06bf374 100644
--- a/packages/app/src/shell/titlebar/tab-popover.tsx
+++ b/packages/app/src/shell/titlebar/tab-popover.tsx
@@ -1,5 +1,7 @@
import { HoverCard } from "@kobalte/core/hover-card"
-import { createEffect, createSignal, onCleanup, Show, type JSXElement } from "solid-js"
+import { frameLoop, prefersReducedMotion, Spring } from "@opencode/ui/motion"
+import { motionTuning } from "@opencode/ui/motion-tuning"
+import { createEffect, createSignal, on, onCleanup, Show, type JSXElement } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { createMediaQuery } from "@solid-primitives/media"
import "./tab-popover.css"
@@ -18,6 +20,41 @@ let lastClosedAt = 0
let activePreview: (() => void) | undefined
+const style = motionTuning.choice("sidebars.preview.style", {
+ label: "Style",
+ group: "Experiments",
+ card: "Sidebar hover",
+ value: "glide",
+ labels: { today: "Today", glide: "Glide", cursor: "Follow cursor" },
+})
+
+const spring = motionTuning.spring("sidebars.preview", {
+ label: "Glide",
+ group: "Experiments",
+ card: "Sidebar hover",
+ when: () => style() !== "today",
+ presets: {
+ snappy: { duration: 0.2, bounce: 0 },
+ smooth: { duration: 0.28, bounce: 0 },
+ calm: { duration: 0.36, bounce: 0 },
+ },
+})
+
+const glides = () => style() !== "today"
+
+// In the vertical strip, the next tab's preview starts where the last one was and glides to its own row, so moving
+// down the tabs reads as one card following the cursor. "Follow cursor" glides to the pointer's height instead.
+const glide: Glide = {
+ closedAt: Number.NEGATIVE_INFINITY,
+ offset: Spring.fromDuration(0, spring()),
+ pointerY: 0,
+}
+
+// Knob values aren't signals, so a style change reaches the open card through the tuning subscription.
+motionTuning.subscribe(() => {
+ if (style() !== "cursor") unwatch()
+})
+
export interface TabPreviewData {
projectName?: string
title?: string
@@ -57,7 +94,23 @@ export function TabPreviewPopover(props: {
activePreview = undefined
lastClosedAt = Date.now()
})
+ createEffect(
+ on(
+ open,
+ (now, before) => {
+ if (!before || now || !triggerEl) return
+ if (triggerEl === glide.trigger) unwatch()
+ if (props.orientation === "vertical" && glides()) leave(triggerEl)
+ },
+ { defer: true },
+ ),
+ )
onCleanup(() => {
+ if (glide.trigger === triggerEl) {
+ unwatch()
+ Object.assign(glide, { trigger: undefined, card: undefined })
+ }
+
if (activePreview !== close) return
activePreview = undefined
lastClosedAt = Date.now()
@@ -87,7 +140,16 @@ export function TabPreviewPopover(props: {
placement={placement()}
gutter={6}
>
-
+ {
+ glide.pointerY = event.clientY
+ if (props.orientation === "vertical" && style() === "cursor" && triggerEl === glide.trigger) follow()
+ }}
+ >
{props.trigger}
@@ -98,6 +160,8 @@ export function TabPreviewPopover(props: {
const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme")
if (theme) el.setAttribute("data-theme", theme)
+
+ if (triggerEl && props.orientation === "vertical" && glides()) show(el, triggerEl)
}}
data-component="session-tab-popover"
data-instant={instant() || undefined}
@@ -125,3 +189,132 @@ export function TabPreviewPopover(props: {
)
}
+
+function show(card: HTMLElement, trigger: HTMLElement) {
+ const previous = glide.trigger
+
+ // The last card may still be up (the next one usually opens first) or have closed a moment ago.
+ const handover =
+ previous !== undefined &&
+ previous !== trigger &&
+ previous.isConnected &&
+ (!!glide.card?.isConnected || performance.now() - glide.closedAt < 100)
+
+ Object.assign(glide, { card, trigger, closedAt: Number.NEGATIVE_INFINITY, rects: undefined })
+ unwatch()
+
+ if (prefersReducedMotion()) return glide.offset.snap(0)
+ const cursor = style() === "cursor"
+
+ if (cursor) watch(trigger)
+
+ if (!handover && !cursor) return glide.offset.snap(0)
+ // Starts from where the last card is, keeping the velocity of a glide already under way.
+ if (handover) glide.offset.value += previous.getBoundingClientRect().top - trigger.getBoundingClientRect().top
+ glide.offset.target = cursor ? cursorOffset() : 0
+
+ if (!handover) glide.offset.snap(glide.offset.target)
+ card.style.transform = `translateY(${glide.offset.value}px)`
+ glide.stop ??= frameLoop.add(step)
+}
+
+function follow() {
+ if (!glide.card?.isConnected || prefersReducedMotion()) return
+ glide.offset.target = cursorOffset()
+ glide.stop ??= frameLoop.add(step)
+}
+
+// Keeps the pointer level with the middle of the row-sized slot the card would sit in by default, so the offset is 0
+// at the row's centre and runs continuously across row boundaries, clamped to the sidebar.
+function cursorOffset() {
+ const rects = glide.rects ?? measure()
+
+ if (!rects) return 0
+ // The card has no height until it's attached, so a measurement taken as it mounts isn't kept.
+ if (rects.card > 0) glide.rects = rects
+ const top = glide.pointerY - rects.row.height / 2
+ const clamped = Math.min(Math.max(top, rects.bounds.top), rects.bounds.bottom - rects.card)
+
+ return clamped - rects.row.top
+}
+
+function measure() {
+ if (!glide.trigger || !glide.card) return
+ const bounds = (glide.trigger.closest('[data-slot="vertical-tabs"]') ?? glide.trigger).getBoundingClientRect()
+
+ return { row: glide.trigger.getBoundingClientRect(), bounds, card: glide.card.offsetHeight }
+}
+
+// While a "Follow cursor" card is up, scrolling or resizing the tabs moves the rows under the cached rects, so measure
+// again and re-clamp the card even if the pointer stays still.
+function watch(trigger: HTMLElement) {
+ const refresh = () => {
+ glide.rects = undefined
+ follow()
+ }
+ const observer = new ResizeObserver(refresh)
+ observer.observe(trigger.closest('[data-slot="vertical-tabs"]') ?? trigger)
+ window.addEventListener("scroll", refresh, { capture: true, passive: true })
+ window.addEventListener("resize", refresh, { passive: true })
+ glide.unwatch = () => {
+ observer.disconnect()
+ window.removeEventListener("scroll", refresh, { capture: true })
+ window.removeEventListener("resize", refresh)
+ }
+}
+
+function unwatch() {
+ glide.unwatch?.()
+ glide.unwatch = undefined
+}
+
+function leave(trigger: HTMLElement) {
+ if (trigger !== glide.trigger) return
+ unwatch()
+ glide.card = undefined
+ glide.closedAt = performance.now()
+}
+
+function step(dt: number) {
+ const card = glide.card
+
+ if (!card?.isConnected) {
+ unwatch()
+ Object.assign(glide, { card: undefined, stop: undefined })
+
+ return false
+ }
+
+ glide.offset.omega = (2 * Math.PI) / spring().duration
+ glide.offset.zeta = 1 - spring().bounce
+
+ if (style() === "cursor" && !glide.rects) glide.offset.target = cursorOffset()
+
+ if (prefersReducedMotion()) glide.offset.snap(0)
+ const offset = glide.offset.step(dt)
+
+ if (!glide.offset.resting) {
+ card.style.transform = `translateY(${offset}px)`
+
+ return true
+ }
+
+ if (offset === 0) card.style.removeProperty("transform")
+ else card.style.transform = `translateY(${offset}px)`
+ glide.stop = undefined
+
+ return false
+}
+
+type Glide = {
+ // The row whose preview is up, or closed last.
+ trigger?: HTMLElement
+ card?: HTMLElement
+ closedAt: number
+ offset: Spring
+ stop?: () => void
+ // The last pointer height over a row, and the rects "Follow cursor" clamps against, measured once per card.
+ pointerY: number
+ rects?: { row: DOMRect; bounds: DOMRect; card: number }
+ unwatch?: () => void
+}
diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx
index ef944cdfaa3..f8ae23387d1 100644
--- a/packages/app/src/shell/titlebar/titlebar.tsx
+++ b/packages/app/src/shell/titlebar/titlebar.tsx
@@ -31,7 +31,6 @@ import { sessionTabTitle } from "./tab-title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import { RecentlyClosedTabsMenu } from "./recently-closed-tabs-menu"
-import { SidebarHover } from "./sidebar-hover"
import { useSettingsDialog } from "@/settings/command"
import { rootSession } from "@/shell/routes/session"
import { TitlebarItems, useTitlebarItems } from "@/runtime/extension/titlebar-items"
@@ -790,7 +789,6 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
mount={mount}
ref={(element) => (element.className = "flex size-full min-h-0 flex-col")}
>
-
{/* Shares the session header's 48px center line. Without traffic lights, Home fills the
start so the row doesn't leave an empty band. */}