feat(app): peek the sidebar from the window edge (#58244)

This commit is contained in:
Kit Langton
2026-10-23 09:36:46 -07:00
committed by GitHub
Unverified
parent 6aa0d5c203
commit 60bcd8aab9
7 changed files with 492 additions and 184 deletions
@@ -58,8 +58,8 @@ test("the content beside a panel follows the panel's edge on every frame of a to
const review = page.locator('[data-slot="session-review-toggle"] button')
const runs = [
{ toggle: sidebar, before: '[data-slot="vertical-tabs-sidebar"] > div > div', after: "main", gap: 0 },
{ toggle: sidebar, before: '[data-slot="vertical-tabs-sidebar"] > div > div', after: "main", gap: 0 },
{ toggle: sidebar, before: '[data-slot="vertical-tabs-mount"]', after: "main", gap: 0 },
{ toggle: sidebar, before: '[data-slot="vertical-tabs-mount"]', after: "main", gap: 0 },
{ toggle: review, before: '[data-slot="session-chat-panel"]', after: "#review-panel", gap: 8 },
{ toggle: review, before: '[data-slot="session-chat-panel"]', after: "#review-panel", gap: 8 },
]
+3
View File
@@ -29,6 +29,9 @@ const spring = motionTuning.spring("sidebars", {
},
})
/** The shared spring, for motion that must read as the sidebar's own, like its peek. */
export const panelSpring = spring
const style = motionTuning.choice("sidebars.style", {
label: "Motion",
group,
+75 -5
View File
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { ResizeHandle } from "@opencode/ui/resize-handle"
import { useDialog } from "@opencode/ui/context/dialog"
import { prefersReducedMotion } from "@opencode/ui/motion"
import { Titlebar } from "@/shell/titlebar/titlebar"
import { usePlatform } from "@/runtime/platform/platform"
import { ToastRegion } from "@/shell/notifications/toast"
@@ -16,11 +17,20 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { CollapsedSidebarToggle, measureSidebarToggles, paintCollapsedSlots } from "@/shell/sidebar-toggle"
import {
CollapsedSidebarToggle,
collapsedSidebarToggleEnd,
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"
// How far Home steps along a peek's header to clear the collapsed toggle: to the toggle's end, from the sidebar's 10px
// start padding.
const homeClearance = collapsedSidebarToggleEnd - 10
export const sidebarToggleCommand = { id: "sidebar.toggle", keybind: "mod+shift+b" }
export default function Layout(props: ParentProps) {
@@ -51,20 +61,65 @@ export default function Layout(props: ParentProps) {
const toggleSidebar = () => preferences.appearance.setSidebarOpen(!preferences.appearance.sidebarOpen())
// How far closed the docked sidebar is, and how far in its peek has it, undefined while it isn't peeking.
const sidebar: { closed: number; peek: number | undefined } = {
closed: preferences.appearance.sidebarOpen() ? 0 : 1,
peek: undefined,
}
let peekSurface: HTMLDivElement | undefined
let peekHome: HTMLElement | null | undefined
// The docked sidebar and its peek are one sidebar, shown as far in as whichever has it further.
const sidebarHidden = () => (sidebar.peek === undefined ? sidebar.closed : Math.min(sidebar.closed, 1 - sidebar.peek))
// A peek slides the contents in its own box, where docking slides them by sizing the aside. Reduced motion fades
// them in place instead.
const paintSidebarContents = () => {
const mount = state.tabsMount
if (!mount) return
const peeking = sidebar.peek !== undefined
const hidden = sidebarHidden()
const fade = peeking && prefersReducedMotion()
const rtl = document.documentElement.dir === "rtl"
const shift = peeking && !fade && hidden > 0 ? `translateX(${(rtl ? 100 : -100) * hidden}%)` : ""
mount.style.opacity = fade ? (hidden === 0 ? "" : `${1 - hidden}`) : panelOpacity(hidden)
mount.style.transform = shift
if (peekSurface) {
// Lifted while it floats, and settling as a dock lands; under reduced motion it fades with the contents.
peekSurface.style.opacity = peeking ? `${sidebar.closed * (fade ? 1 - hidden : 1)}` : ""
peekSurface.style.transform = shift
}
// Without traffic lights, Home starts the sidebar's header right where the collapsed toggle floats over a peek, so
// it steps past the toggle until a dock carries the toggle to the header's end.
if (peeking && peekHome === undefined)
peekHome = mount.querySelector<HTMLElement>('[data-slot="sidebar-header"][data-home] > :first-child')
if (peekHome)
peekHome.style.transform = peeking ? `translateX(${(rtl ? -1 : 1) * homeClearance * sidebar.closed}px)` : ""
if (!peeking) peekHome = undefined
}
const sidebarMotion = createPanelMotion({
open: preferences.appearance.sidebarOpen,
// The aside's real width moves main's edge with the sidebar's; its contents keep their width, pinned to that edge.
paint: (closed) => {
sidebar.closed = closed
const aside = state.tabsAside
if (aside) aside.style.width = `${state.tabsWidth * (1 - closed)}px`
// Dims the contents, background included, as they leave and brightens them as they arrive.
if (state.tabsMount) state.tabsMount.style.opacity = panelOpacity(closed)
paintSidebarContents()
paintCollapsedSlots(closed)
},
measure: () => measureSidebarToggles(state.tabsWidth),
element: () => state.tabsPanel,
measure: () => measureSidebarToggles(state.tabsWidth * sidebarHidden()),
// A dock from a peek already shows the sidebar, so reduced motion has nothing to fade in.
element: () => (peek.handedOff() ? undefined : state.tabsPanel),
offset: () => -10,
})
@@ -73,7 +128,13 @@ export default function Layout(props: ParentProps) {
open: preferences.appearance.sidebarOpen,
aside: () => state.tabsAside,
panel: () => state.tabsPanel,
content: () => state.tabsMount,
paint: (shown) => {
sidebar.peek = shown
paintSidebarContents()
},
// James opens the docked panel with its own shift and fade.
continues: () => !sidebarMotion.pinned(),
whenDocked: sidebarMotion.whenSettled,
})
replayPanelToggle({ id: "sidebar", label: "Toggle the left sidebar", toggle: toggleSidebar })
@@ -180,6 +241,15 @@ export default function Layout(props: ParentProps) {
// Under James the sidebar keeps its landing width (start edge held) while the layout moves.
style={{ width: sidebarMotion.pinned() ? `${state.tabsWidth}px` : undefined }}
>
{/* Under a peek's contents, so they dim over the sidebar's own background, as when docking, rather than
showing the content beneath through them. */}
<div
ref={peekSurface}
data-slot="vertical-tabs-peek-surface"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 end-0 bg-v2-background-bg-deep shadow-[var(--v2-elevation-floating)]"
style={{ width: `${state.tabsWidth}px` }}
/>
<div
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-mount"
+12 -31
View File
@@ -1,41 +1,22 @@
/* 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. */
/* The closed sidebar peeks in the docked sidebar's own box, over the content and without resizing its zero-width aside,
so docking from a peek only has to move the layout. */
[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;
inset-inline-end: 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 {
/* Backs the peek and lifts it off the content along its end edge. */
[data-slot="vertical-tabs-peek-surface"] {
display: none;
}
[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="sidebar-header"] {
padding-top: 0;
[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="vertical-tabs-peek-surface"] {
display: block;
}
/* The collapsed toggle is the one in reach while the sidebar peeks; the docked one's spot stays empty. */
[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="sidebar-header"] > :last-child {
visibility: hidden;
}
+297 -137
View File
@@ -1,7 +1,8 @@
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { createEffect, createSignal, on, onCleanup, untrack, type Accessor } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { prefersReducedMotion, Spring } from "@opencode/ui/motion"
import { frameLoop, prefersReducedMotion, Spring } from "@opencode/ui/motion"
import { motionTuning } from "@opencode/ui/motion-tuning"
import { panelSpring } from "@/shell/panel-motion"
import "./sidebar-peek.css"
export const sidebarPeekEnabled = motionTuning.toggle("sidebar.peek", {
@@ -17,42 +18,103 @@ 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
// A drag in progress, or a dialog over the app, which no peek should open under.
const busy = '[data-dragging="true"], [role="dialog"], [role="alertdialog"]'
// How long the pointer rests before the sidebar peeks, in ms. Both sit under the ~100–150ms where a response still reads
// as immediate, and above how long a pointer spends crossing: through the 8px edge strip in well under a frame, over the
// 28px toggle in ~30–60ms on the way to the breadcrumb or the traffic lights. The toggle waits longer because the header
// row is crossed far more often than the bare window edge, and a peek that starts just before a click docks smoothly.
const dwell = { edge: 100, toggle: 150 }
// The strip along the window's start edge that peeks the sidebar: the shell's 8px frame gutter, outside the content's
// card, so no control, scrollbar or text lives in it. It starts below the session header's 56px band, which holds the
// traffic lights and the toggle.
const edge = 8
const header = 56
// 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
// Reduced motion fades the peek in place, in about the time it would take to slide.
const fade = { duration: 0.15, bounce: 0 }
type Point = { x: number; y: number }
type Box = { left: number; right: number; top: number; bottom: number }
/**
* 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.
* Experiment: while the left sidebar is closed, resting the pointer on the window's start edge or on the sidebar's
* toggle slides the real sidebar in over the app without moving the layout, on the docked sidebar's own spring and in
* its own box. Docking from a peek continues from where it is: the sidebar stays and the layout makes room.
*
* 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, against boxes read
* once per layout change rather than on every move. Its listeners only run while the experiment is on.
*/
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. */
/** The sidebar's box while it peeks, which doesn't move while the content inside it slides. */
panel: Accessor<HTMLElement | undefined>
content: Accessor<HTMLElement | undefined>
/** Paints how far in the peek has the sidebar, from 0 to 1, on every frame it moves; undefined once it isn't peeking. */
paint: (shown: number | undefined) => void
/** Whether docking continues from the peek; otherwise the docked sidebar's own motion takes over at once. */
continues: Accessor<boolean>
/** Runs `run` once the docked sidebar's flight lands. */
whenDocked: (run: () => void) => void
}) {
const [shown, setShown] = createSignal(false)
const [enabled, setEnabled] = createSignal(sidebarPeekEnabled())
// Percent shown, so the spring's rest threshold stays well under a pixel.
const motion = new Spring(0)
let stop: (() => void) | undefined
let toggle: HTMLElement | undefined
let animation: Animation | undefined
let opening: ReturnType<typeof setTimeout> | undefined
let opening: { from: "edge" | "toggle"; timer: 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 pointer: Point | undefined
let pressed = false
let keyboard = false
// Whether the sidebar last docked from a peek, which already shows it.
let handedOff = false
let viewport = window.innerWidth
// Where the edge strip starts, and the box spanning the toggle and the sidebar, until the layout next changes.
let edgeTop: number | undefined
let corridor: Box | undefined
const paint = () => input.paint(Math.min(1, Math.max(0, motion.value / 100)))
const step = (dt: number) => {
motion.step(dt)
paint()
if (!motion.resting) return true
stop = undefined
if (motion.target === 0) end()
return false
}
// Retargeting keeps the current position and velocity, so a peek that turns around mid-flight stays smooth.
const run = (target: number) => {
const tuning = prefersReducedMotion() ? fade : panelSpring()
motion.omega = (2 * Math.PI) / tuning.duration
motion.zeta = 1 - tuning.bounce
motion.set(target)
stop ??= frameLoop.add(step)
}
const cancelOpen = () => {
clearTimeout(opening)
clearTimeout(opening?.timer)
opening = undefined
}
@@ -64,27 +126,17 @@ export function createSidebarPeek(input: {
const show = () => {
cancelOpen()
cancelClose()
const content = input.content()
if (!content) return
setShown(true)
watch ??= setInterval(check, 150)
if (!input.panel()) return
if (animation) {
if (animation.playbackRate < 0) animation.reverse()
animation.onfinish = null
return
// Painted out of view before the peek's box lays out, so its first frame doesn't show the sidebar in place.
if (!shown()) {
paint()
setShown(true)
}
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" },
)
watch ??= setInterval(check, 150)
run(100)
}
const end = () => {
@@ -92,33 +144,49 @@ export function createSidebarPeek(input: {
cancelClose()
clearInterval(watch)
watch = undefined
animation?.cancel()
animation = undefined
stop?.()
stop = undefined
motion.snap(0)
toggle = undefined
corridor = undefined
if (!shown()) return
setShown(false)
input.paint(undefined)
}
const hide = (focus?: boolean) => {
cancelClose()
if (!shown()) return
if (!shown() || input.open()) 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()
run(0)
}
// Both dwells share one path: a press, a drag, a dialog or a resize clears whichever peek is pending.
const start = (from: "edge" | "toggle", still: () => boolean) => {
cancelOpen()
opening = {
from,
timer: setTimeout(() => {
opening = undefined
if (enabled() && input.available() && !interrupted() && still()) show()
}, dwell[from]),
}
}
const interrupted = () => pressed || !!document.querySelector(busy)
// 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
@@ -132,20 +200,48 @@ export function createSidebarPeek(input: {
return pressed || typing || menu || !!document.querySelector('[data-dragging="true"]')
}
const inside = (point: { x: number; y: number }) => {
const inside = (point: Point) => {
const box = (corridor ??= measureCorridor())
return (
!!box &&
point.x >= box.left - margin &&
point.x <= box.right + margin &&
point.y >= box.top - margin &&
point.y <= box.bottom + margin
)
}
// The box spanning the toggle and the sheet is the corridor from one into the other. The sheet holds still while the
// sidebar slides inside it.
const measureCorridor = () => {
const panel = input.panel()
if (!panel) return false
if (!panel) return
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
)
return {
left: Math.min(sheet.left, button.left),
right: Math.max(sheet.right, button.right),
top: Math.min(sheet.top, button.top),
bottom: Math.max(sheet.bottom, button.bottom),
}
}
const atEdge = (point: Point) => {
const rtl = document.documentElement.dir === "rtl"
if (rtl ? point.x < viewport - edge : point.x > edge) return false
edgeTop ??= (input.aside()?.getBoundingClientRect().top ?? Infinity) + header
return point.y >= edgeTop
}
const relayout = () => {
viewport = window.innerWidth
edgeTop = undefined
corridor = undefined
}
function check() {
@@ -159,86 +255,127 @@ export function createSidebarPeek(input: {
}, grace)
}
// Starts the edge's dwell while the pointer rests at the window's start edge, and cancels it once the pointer leaves.
const approach = (event: PointerEvent | WheelEvent) => {
if (shown() && motion.target === 100) return
const target = event.target
const near =
!pressed &&
!!pointer &&
input.available() &&
atEdge(pointer) &&
// Within the shell's own layout, not a dialog or popover portaled over it.
target instanceof Node &&
!!input.aside()?.parentElement?.contains(target)
if (!near) {
if (opening?.from === "edge") cancelOpen()
return
}
// Back at the edge while it slides out: it turns around at once.
if (shown()) return show()
if (opening) return
start("edge", () => !!pointer && !held() && atEdge(pointer))
}
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 }
approach(event)
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.
// Only while the experiment is on, so turning it off leaves nothing listening.
createEffect(() => {
if (!enabled()) return untrack(end)
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
if (toggle !== button) corridor = undefined
toggle = button
if (shown()) return show()
if (!input.available() || opening?.from === "toggle") return
start("toggle", () => !!toggle?.matches(":hover"))
},
{ 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?.from !== "toggle" || !(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"
cancelOpen()
},
{ 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()
})
// Resizing the window from its start edge is no reason to peek.
makeEventListener(window, "resize", () => {
relayout()
cancelOpen()
})
makeEventListener(document, "dragstart", cancelOpen, { passive: true, capture: true })
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()
})
const aside = input.aside()
if (!aside) return
// A titlebar or panel that moves the sidebar's row changes where the edge strip starts.
const observer = new ResizeObserver(relayout)
observer.observe(aside.parentElement ?? aside)
onCleanup(() => observer.disconnect())
// Keyboard focus leaving the peek closes it; pointer clicks elsewhere already close it by leaving.
const leave = (event: FocusEvent) => {
const next = event.relatedTarget
@@ -247,34 +384,57 @@ export function createSidebarPeek(input: {
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 }))
// Docking keeps the peeked sidebar where it is, sliding the rest of the way in if it hadn't arrived, while the layout
// makes room; the docked sidebar takes over once it lands. Closing again before then slides the peek back out.
createEffect(
on(
input.open,
(open) => {
if (!open) {
handedOff = false
createEffect(() => {
if (!sidebarPeekEnabled()) end()
})
return hide()
}
handedOff = shown()
if (!handedOff) return end()
cancelOpen()
cancelClose()
clearInterval(watch)
watch = undefined
run(100)
// After the docked sidebar's own effect has started its flight, which effects don't order.
queueMicrotask(() => {
if (!input.open() || !shown()) return
if (!input.continues()) {
handedOff = false
return end()
}
input.whenDocked(() => {
if (input.open()) end()
})
})
},
{ defer: true },
),
)
onCleanup(motionTuning.subscribe(() => setEnabled(sidebarPeekEnabled())))
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
return {
shown,
/** Whether the sidebar last docked from a peek, which already shows it. */
handedOff: () => handedOff,
}
points.push(1)
return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` }
}
+10 -9
View File
@@ -11,14 +11,12 @@ 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.
// Whether the closed sidebar peeks, which the toggle's unread dot and tooltip follow.
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.
// The toggle'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}>
@@ -58,6 +56,9 @@ const slotWidth = 32
// The shell's 8px inline inset plus the session header's 10px start padding.
const contentStart = 18
/** Where the collapsed toggle's slot ends without traffic lights, in pixels from the window's start edge. */
export const collapsedSidebarToggleEnd = contentStart + slotWidth
/**
* `handoff` makes this one of the two vertical-tabs toggles that read as one icon: "out" sits in the sidebar's header
* and shows only while the sidebar rests open; "in" shows otherwise, travelling from the header's spot to the
@@ -124,7 +125,7 @@ export function SidebarToggle(props: { handoff?: "out" | "in" }) {
<span class="relative flex">
<Icon ref={full} name="layout-left-full" />
<span ref={outline} class="absolute inset-0 flex">
<Icon name={closedIcon()} />
<Icon name="layout-left" />
<UnreadBadge show={unread()} ring="base" />
</span>
</span>
@@ -132,7 +133,7 @@ export function SidebarToggle(props: { handoff?: "out" | "in" }) {
<span class="relative flex">
<Icon
name={
side === "out" || (!side && settings.appearance.sidebarOpen()) ? "layout-left-full" : closedIcon()
side === "out" || (!side && settings.appearance.sidebarOpen()) ? "layout-left-full" : "layout-left"
}
/>
<UnreadBadge show={!side && unread()} ring="deep" />
@@ -189,14 +190,14 @@ const travel: { header?: HTMLElement; toggle?: HTMLElement; x: number; y: number
/**
* Measures, in the frame loop's read phase of the sidebar's motion, how far the header toggle rests from the collapsed
* one. The header toggle rides the sidebar's edge, `width × closed` from where it rests open.
* one. The header toggle rides the sidebar's contents, `offset` pixels short of where it rests open.
*/
export function measureSidebarToggles(width: number) {
export function measureSidebarToggles(offset: number) {
if (!travel.header?.isConnected || !travel.toggle?.isConnected) return
const header = travel.header.getBoundingClientRect()
const toggle = travel.toggle.getBoundingClientRect()
const direction = document.documentElement.dir === "rtl" ? -1 : 1
travel.x = header.left + direction * width * collapsed.closed - (toggle.left - travel.dx)
travel.x = header.left + direction * offset - (toggle.left - travel.dx)
travel.y = header.top - (toggle.top - travel.dy)
}
@@ -0,0 +1,93 @@
import { afterEach, expect, it } from "bun:test"
import { createRoot } from "solid-js"
import { motionTuning } from "@opencode/ui/motion-tuning"
import { createSidebarPeek, sidebarPeekEnabled } from "../src/shell/sidebar-peek"
const cleanups: (() => void)[] = []
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup()
document.body.innerHTML = ""
setEnabled(true)
})
function setEnabled(on: boolean) {
const knob = motionTuning.list().find((item) => item.id === "sidebar.peek")
if (knob?.kind === "toggle") motionTuning.set(knob, on)
}
function mount() {
const row = document.createElement("div")
const aside = document.createElement("aside")
const panel = document.createElement("div")
const content = document.createElement("main")
aside.append(panel)
row.append(aside, content)
document.body.append(row)
const peek = createRoot((dispose) => {
cleanups.push(dispose)
return createSidebarPeek({
available: () => true,
open: () => false,
aside: () => aside,
panel: () => panel,
paint: () => {},
continues: () => true,
whenDocked: (run) => run(),
})
})
return { peek, content }
}
// Rests the pointer on the window's start edge, below the header band.
function restOnEdge(target: Element) {
target.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 2, clientY: 300, pointerType: "mouse" }),
)
}
const afterDwell = () => Bun.sleep(160)
it("peeks once the pointer rests on the window's start edge", async () => {
const view = mount()
restOnEdge(view.content)
await afterDwell()
expect(view.peek.shown()).toBe(true)
})
it("a press, a drag, a dialog or a resize clears the pending peek", async () => {
const interruptions = [
() => document.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" })),
() => document.dispatchEvent(new Event("dragstart", { bubbles: true })),
() => window.dispatchEvent(new Event("resize")),
() => {
const dialog = document.createElement("div")
dialog.setAttribute("role", "dialog")
document.body.append(dialog)
},
]
for (const interrupt of interruptions) {
const view = mount()
restOnEdge(view.content)
interrupt()
await afterDwell()
expect(view.peek.shown()).toBe(false)
for (const cleanup of cleanups.splice(0)) cleanup()
document.body.innerHTML = ""
}
})
it("listens to nothing while the experiment is off", async () => {
setEnabled(false)
expect(sidebarPeekEnabled()).toBe(false)
const view = mount()
restOnEdge(view.content)
await afterDwell()
expect(view.peek.shown()).toBe(false)
})