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. */}