Add right-sidebar edge peek (extracted from #7423)

Arc-style hover peek: when the right sidebar is closed, hovering the
window's right edge reveals it as a floating overlay without reflowing
the editor; moving away dismisses it, and an explicit open pins it.
Extracted unchanged from PR #7423 so it can be reviewed on its own.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
hmrserver
2026-07-09 21:27:28 -07:00
committed by brennanb2025
co-authored by Orca
parent 41edc493b8
commit ea9ffbaea5
5 changed files with 429 additions and 13 deletions
+6 -1
View File
@@ -55,6 +55,7 @@ import {
useSystemPrefersDark
} from './components/terminal-pane/use-system-prefers-dark'
import RightSidebar from './components/right-sidebar'
import { RightSidebarEdgePeekZone } from './components/right-sidebar/right-sidebar-edge-peek'
import { StarNagCard } from './components/StarNagCard'
import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver'
import { StarNagToastHost } from './components/star-nag/StarNagToastHost'
@@ -2211,7 +2212,10 @@ function App(): React.JSX.Element {
'The app is still running. Retry the shell or use the menu to report the crash details.'
)}
>
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: relative anchors the right-sidebar edge-peek overlay to
this row, so a peek spans the same height as the docked sidebar
and ends above the status bar. */}
<div className="relative flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: the non-workspace titlebar lives inside this left+center
wrapper so it does not span over the right-sidebar column —
when the right sidebar is open, its own header anchors at the
@@ -2428,6 +2432,7 @@ function App(): React.JSX.Element {
Its heavy panels disconnect while closed so workspace wake stays
responsive. Unmount on the tasks view since that surface is
intentionally distraction-free. */}
{showRightSidebarControls ? <RightSidebarEdgePeekZone /> : null}
{showRightSidebarControls ? (
<RecoverableRenderErrorBoundary
boundaryId="right-sidebar"
@@ -39,6 +39,7 @@ import {
} from './right-sidebar-width'
import { translate } from '@/i18n/i18n'
import { RightSidebarPanelContent } from './right-sidebar-panel-content'
import { useRightSidebarEdgePeekDismiss } from './right-sidebar-edge-peek'
import { useMeasuredWidth } from './right-sidebar-measured-width'
import { normalizeRightSidebarRoute } from '@/store/right-sidebar-route'
import { AgentSessionHistoryIcon } from './agent-session-history-icon'
@@ -62,6 +63,8 @@ function RightSidebarInner(): React.JSX.Element {
const checksShortcut = useShortcutLabel('sidebar.checks.toggle')
const portsShortcut = useShortcutLabel('sidebar.ports.toggle')
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarPeek = useAppStore((s) => s.rightSidebarPeek)
const setRightSidebarPeek = useAppStore((s) => s.setRightSidebarPeek)
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
@@ -69,11 +72,17 @@ function RightSidebarInner(): React.JSX.Element {
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const showRightSidebarFiles = useAppStore((s) => s.showRightSidebarFiles)
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
const checksStatus = useAppStore((s) => (s.rightSidebarOpen ? getActiveChecksStatus(s) : null))
// An edge-hover peek shows the same content as a pinned-open sidebar; only
// the positioning differs (floating overlay vs in-flow panel).
const isPeeking = rightSidebarPeek && !rightSidebarOpen
const sidebarVisible = rightSidebarOpen || isPeeking
const checksStatus = useAppStore((s) =>
s.rightSidebarOpen || s.rightSidebarPeek ? getActiveChecksStatus(s) : null
)
const activityBarPosition = useAppStore((s) => s.activityBarPosition)
const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition)
const [topActivityStripWidth, setTopActivityStripWidth] = useState<number | null>(null)
const activeWorktreeId = useAppStore((s) => (rightSidebarOpen ? s.activeWorktreeId : null))
const activeWorktreeId = useAppStore((s) => (sidebarVisible ? s.activeWorktreeId : null))
// Why: source control and checks are meaningless for non-git folders.
// Hide those tabs so the activity bar only shows relevant actions.
const activeWorktree = useAppStore((s) =>
@@ -204,8 +213,8 @@ function RightSidebarInner(): React.JSX.Element {
windowWidth,
activityBarSideWidth
)
const { containerRef, onResizeStart } = useSidebarResize<HTMLDivElement>({
isOpen: rightSidebarOpen,
const { containerRef, isResizing, onResizeStart } = useSidebarResize<HTMLDivElement>({
isOpen: sidebarVisible,
width: renderedRightSidebarWidth,
minWidth: RIGHT_SIDEBAR_MIN_WIDTH,
maxWidth,
@@ -213,9 +222,15 @@ function RightSidebarInner(): React.JSX.Element {
renderedExtraWidth: activityBarSideWidth,
setWidth: setRightSidebarWidth
})
useRightSidebarEdgePeekDismiss({
isPeeking,
isResizing,
setPeek: setRightSidebarPeek,
overlayRef: containerRef
})
const topActivityStripRef = useMeasuredWidth(setTopActivityStripWidth)
const panelContent = rightSidebarOpen ? (
const panelContent = sidebarVisible ? (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
{/* Why: sidebar panels no longer use key={activeWorktreeId} because
the full unmount/remount cycle on every worktree switch triggered
@@ -228,7 +243,7 @@ function RightSidebarInner(): React.JSX.Element {
property) rather than in a bottom-docked dashboard panel that
competed with file Explorer/Search for vertical space. The right
sidebar is back to tab-only content. */}
<RightSidebarPanelContent effectiveTab={effectiveTab} rightSidebarOpen={rightSidebarOpen} />
<RightSidebarPanelContent effectiveTab={effectiveTab} rightSidebarOpen={sidebarVisible} />
</div>
) : null
@@ -248,7 +263,9 @@ function RightSidebarInner(): React.JSX.Element {
/>
))
const closeButton = rightSidebarOpen ? (
// During a peek the same button pins the sidebar open (toggle clears the
// peek flag alongside flipping open).
const closeButton = sidebarVisible ? (
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -277,20 +294,25 @@ function RightSidebarInner(): React.JSX.Element {
<div
ref={containerRef}
className={cn(
'relative flex-shrink-0 flex flex-row',
'flex-shrink-0 flex flex-row',
// Why: overflow-visible is needed when open so the resize handle
// on the left edge remains interactive. When closed (width 0),
// switch to overflow-hidden so the activity bar icons and panel
// content don't leak past the 0-width boundary (the component
// stays mounted for performance — see App.tsx).
rightSidebarOpen ? 'overflow-visible' : 'overflow-hidden'
sidebarVisible ? 'overflow-visible' : 'overflow-hidden',
// A peek floats over the content row (no reflow) with the
// styleguide's floating-surface shadow; pinned-open stays in flow.
isPeeking
? 'absolute inset-y-0 right-0 z-30 shadow-[0_10px_24px_rgba(0,0,0,0.18)] animate-in slide-in-from-right duration-200 motion-reduce:animate-none'
: 'relative'
)}
>
{/* Panel content area */}
<div
className="flex flex-col flex-1 min-w-0 bg-sidebar overflow-hidden"
style={{
borderLeft: rightSidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
borderLeft: sidebarVisible ? '1px solid var(--sidebar-border)' : 'none'
}}
>
{activityBarPosition === 'top' ? (
@@ -0,0 +1,225 @@
// @vitest-environment happy-dom
import { act, fireEvent, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import {
PEEK_CLOSE_DELAY_MS,
PEEK_OPEN_DELAY_MS,
RightSidebarEdgePeekZone,
useRightSidebarEdgePeekDismiss
} from './right-sidebar-edge-peek'
const initialAppState = useAppStore.getInitialState()
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
useAppStore.setState(initialAppState, true)
})
function makeOverlayRef(left: number): React.RefObject<HTMLElement | null> {
return makeMovingOverlayRef(() => left)
}
// Overlay whose measured left edge changes between reads (e.g. the slide-in
// entrance transform still translating the panel when first measured).
function makeMovingOverlayRef(readLeft: () => number): React.RefObject<HTMLElement | null> {
return {
current: { getBoundingClientRect: () => ({ left: readLeft() }) } as unknown as HTMLElement
}
}
const RIGHT_EDGE_X = window.innerWidth - 1
const BELOW_TITLEBAR_Y = 100
function moveMouse(clientX: number, clientY: number): void {
act(() => {
fireEvent.mouseMove(window, { clientX, clientY })
})
}
describe('RightSidebarEdgePeekZone', () => {
it('renders nothing (no DOM strip that could swallow edge clicks)', () => {
const { container } = render(<RightSidebarEdgePeekZone />)
expect(container.firstElementChild).toBeNull()
})
it('arms the peek after the pointer hovers the right edge for the open delay', () => {
render(<RightSidebarEdgePeekZone />)
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
expect(useAppStore.getState().rightSidebarPeek).toBe(true)
})
it('cancels an armed peek when the pointer leaves the edge before the delay', () => {
render(<RightSidebarEdgePeekZone />)
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS - 1)
moveMouse(RIGHT_EDGE_X - 200, BELOW_TITLEBAR_Y)
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
})
it('ignores the top titlebar zone so window controls stay clickable', () => {
render(<RightSidebarEdgePeekZone />)
moveMouse(RIGHT_EDGE_X, 10)
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
})
it('does not arm while the sidebar is already open', () => {
useAppStore.setState({ rightSidebarOpen: true })
render(<RightSidebarEdgePeekZone />)
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
})
it('clears an active peek when the zone unmounts on a view change', () => {
const { unmount } = render(<RightSidebarEdgePeekZone />)
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
expect(useAppStore.getState().rightSidebarPeek).toBe(true)
unmount()
// A surviving flag would render a ghost peek when the user returns to a
// sidebar-capable view.
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
})
it('clears an armed timer if the sidebar opens before it fires', () => {
const { rerender } = render(<RightSidebarEdgePeekZone />)
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
act(() => {
useAppStore.setState({ rightSidebarOpen: true })
})
rerender(<RightSidebarEdgePeekZone />)
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
// The pending open timer must not flip peek on after the sidebar opened.
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
})
})
describe('useRightSidebarEdgePeekDismiss', () => {
it('dismisses after the pointer stays left of the overlay for the close delay', () => {
const setPeek = vi.fn()
renderHook(() =>
useRightSidebarEdgePeekDismiss({
isPeeking: true,
isResizing: false,
setPeek,
overlayRef: makeOverlayRef(1000)
})
)
fireEvent.mouseMove(window, { clientX: 400 })
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
expect(setPeek).toHaveBeenCalledWith(false)
})
it('keeps the peek when the pointer returns to the overlay before the delay', () => {
const setPeek = vi.fn()
renderHook(() =>
useRightSidebarEdgePeekDismiss({
isPeeking: true,
isResizing: false,
setPeek,
overlayRef: makeOverlayRef(1000)
})
)
fireEvent.mouseMove(window, { clientX: 400 })
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS - 1)
fireEvent.mouseMove(window, { clientX: 1100 })
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
expect(setPeek).not.toHaveBeenCalled()
})
it('does not dismiss when the boundary was measured mid entrance animation', () => {
const setPeek = vi.fn()
// First read happens during the slide-in: the overlay still sits near the
// window's right edge. By the time the close timer re-measures, the
// animation has settled at its real position.
let overlayLeft = 1590
renderHook(() =>
useRightSidebarEdgePeekDismiss({
isPeeking: true,
isResizing: false,
setPeek,
overlayRef: makeMovingOverlayRef(() => overlayLeft)
})
)
// The pointer moves into the settled overlay area (left of the stale
// mid-animation boundary), which schedules a close against stale geometry.
fireEvent.mouseMove(window, { clientX: 1200 })
overlayLeft = 1000
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
// The re-measure inside the close timer sees the settled boundary and
// keeps the peek open.
expect(setPeek).not.toHaveBeenCalled()
// The refreshed cache now classifies the same position as inside.
fireEvent.mouseMove(window, { clientX: 1200 })
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
expect(setPeek).not.toHaveBeenCalled()
// A genuine exit still dismisses.
fireEvent.mouseMove(window, { clientX: 400 })
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
expect(setPeek).toHaveBeenCalledWith(false)
})
it('does not dismiss while a resize drag travels past the overlay edge', () => {
const setPeek = vi.fn()
renderHook(() =>
useRightSidebarEdgePeekDismiss({
isPeeking: true,
isResizing: true,
setPeek,
overlayRef: makeOverlayRef(1000)
})
)
fireEvent.mouseMove(window, { clientX: 100 })
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
expect(setPeek).not.toHaveBeenCalled()
})
it('dismisses immediately when the window loses focus', () => {
const setPeek = vi.fn()
renderHook(() =>
useRightSidebarEdgePeekDismiss({
isPeeking: true,
isResizing: false,
setPeek,
overlayRef: makeOverlayRef(1000)
})
)
fireEvent.blur(window)
expect(setPeek).toHaveBeenCalledWith(false)
})
})
@@ -0,0 +1,155 @@
import React, { useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
// Arc-style edge peek: hovering the window's right edge while the sidebar is
// closed reveals it as a floating overlay instead of reflowing the editor.
// The enter delay keeps edge scrollbar grabs from flashing the sidebar open.
export const PEEK_OPEN_DELAY_MS = 250
export const PEEK_CLOSE_DELAY_MS = 300
// The rightmost band that arms the peek, and the top zone it excludes.
const PEEK_EDGE_TRIGGER_PX = 6
const PEEK_TITLEBAR_ZONE_PX = 36
/**
* Arms the edge peek while the sidebar is fully hidden. Detection is
* geometry-based (a window mousemove comparing clientX against the right edge)
* rather than an invisible DOM strip: a strip would swallow every mousedown in
* the rightmost pixels, blocking scrollbar drags and clicks flush against the
* window edge. Renders nothing.
*/
export function RightSidebarEdgePeekZone(): React.JSX.Element | null {
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarPeek = useAppStore((s) => s.rightSidebarPeek)
const setRightSidebarPeek = useAppStore((s) => s.setRightSidebarPeek)
useEffect(() => {
// Why: the zone unmounts when the active view loses sidebar controls; a
// peek flag surviving that would render a ghost peek (no hover behind it)
// when the user returns to a sidebar-capable view.
return () => setRightSidebarPeek(false)
}, [setRightSidebarPeek])
useEffect(() => {
// The revealed overlay covers the edge, so arming is only needed while the
// sidebar is fully hidden. Skipping the listener here also guarantees an
// armed timer can't fire after the sidebar opens (the cleanup clears it).
if (rightSidebarOpen || rightSidebarPeek) {
return
}
let openTimer: number | null = null
const clearOpenTimer = (): void => {
if (openTimer !== null) {
window.clearTimeout(openTimer)
openTimer = null
}
}
const onMouseMove = (event: MouseEvent): void => {
// Why: exclude the top 36px titlebar row so the peek never arms over
// window controls (Windows/Linux custom chrome).
const inEdgeBand =
event.clientY >= PEEK_TITLEBAR_ZONE_PX &&
event.clientX >= window.innerWidth - PEEK_EDGE_TRIGGER_PX
if (!inEdgeBand) {
clearOpenTimer()
return
}
if (openTimer === null) {
openTimer = window.setTimeout(() => {
openTimer = null
setRightSidebarPeek(true)
}, PEEK_OPEN_DELAY_MS)
}
}
window.addEventListener('mousemove', onMouseMove)
return () => {
clearOpenTimer()
window.removeEventListener('mousemove', onMouseMove)
}
}, [rightSidebarOpen, rightSidebarPeek, setRightSidebarPeek])
return null
}
/**
* Dismisses an active peek once the pointer moves left of the revealed
* overlay. Dismissal is geometry-based (window mousemove) rather than
* mouseleave-based: tooltips and menus portal outside the overlay element,
* and a mouseleave dismiss would close the peek under an open popover.
*/
export function useRightSidebarEdgePeekDismiss(args: {
isPeeking: boolean
isResizing: boolean
setPeek: (peek: boolean) => void
overlayRef: React.RefObject<HTMLElement | null>
}): void {
const { isPeeking, isResizing, setPeek, overlayRef } = args
// Why: the dismiss watcher reads resize state inside window listeners; a ref
// avoids re-subscribing them on every drag frame.
const isResizingRef = useRef(isResizing)
isResizingRef.current = isResizing
useEffect(() => {
if (!isPeeking) {
return
}
let closeTimer: number | null = null
let lastClientX: number | null = null
// Why: measuring the overlay on every mousemove forces layout. Cache its
// left edge and invalidate only when it can move (a resize drag or a window
// resize), so the common per-frame move reads no geometry.
let cachedOverlayLeft: number | null = null
const invalidateOverlayLeft = (): void => {
cachedOverlayLeft = null
}
const cancelClose = (): void => {
if (closeTimer !== null) {
window.clearTimeout(closeTimer)
closeTimer = null
}
}
const scheduleClose = (): void => {
if (closeTimer === null) {
closeTimer = window.setTimeout(() => {
closeTimer = null
// Why: the cached boundary may have been measured mid slide-in (the
// entrance transform puts the rect near the window edge). Re-measure
// and re-check here so a stale cache self-heals instead of
// dismissing the peek under the cursor.
const overlayLeft = overlayRef.current?.getBoundingClientRect().left ?? null
cachedOverlayLeft = overlayLeft
if (overlayLeft !== null && lastClientX !== null && lastClientX >= overlayLeft) {
return
}
setPeek(false)
}, PEEK_CLOSE_DELAY_MS)
}
}
const onMouseMove = (event: MouseEvent): void => {
lastClientX = event.clientX
// A resize drag legitimately travels left of the overlay edge.
if (isResizingRef.current) {
invalidateOverlayLeft()
cancelClose()
return
}
if (cachedOverlayLeft === null) {
cachedOverlayLeft = overlayRef.current?.getBoundingClientRect().left ?? null
}
if (cachedOverlayLeft === null || event.clientX >= cachedOverlayLeft) {
cancelClose()
} else {
scheduleClose()
}
}
const onWindowBlur = (): void => setPeek(false)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('blur', onWindowBlur)
window.addEventListener('resize', invalidateOverlayLeft)
return () => {
cancelClose()
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('blur', onWindowBlur)
window.removeEventListener('resize', invalidateOverlayLeft)
}
}, [isPeeking, overlayRef, setPeek])
}
+11 -2
View File
@@ -430,6 +430,9 @@ export type EditorSlice = {
// Right sidebar
rightSidebarOpen: boolean
// Transient edge-hover reveal (Arc-style). Never persisted: a peek is a
// pointer gesture, not a saved layout choice like rightSidebarOpen.
rightSidebarPeek: boolean
rightSidebarWidth: number
rightSidebarTab: ActiveRightSidebarTab
rightSidebarExplorerView: RightSidebarExplorerView
@@ -439,6 +442,7 @@ export type EditorSlice = {
activityBarPosition: ActivityBarPosition
toggleRightSidebar: () => void
setRightSidebarOpen: (open: boolean) => void
setRightSidebarPeek: (peek: boolean) => void
setRightSidebarWidth: (width: number) => void
setRightSidebarTab: (tab: ActiveRightSidebarTab) => void
setRightSidebarExplorerView: (view: RightSidebarExplorerView) => void
@@ -1439,6 +1443,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// Right sidebar
rightSidebarOpen: false,
rightSidebarPeek: false,
rightSidebarWidth: 280,
rightSidebarTab: 'explorer',
rightSidebarExplorerView: 'files',
@@ -1446,8 +1451,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
rightSidebarTabByWorktree: {},
rightSidebarExplorerViewByWorktree: {},
activityBarPosition: 'top',
toggleRightSidebar: () => set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen })),
setRightSidebarOpen: (open) => set({ rightSidebarOpen: open }),
// Why: an explicit open/close supersedes a hover peek — clearing the flag
// here keeps "toggle while peeking" meaning "pin it open".
toggleRightSidebar: () =>
set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen, rightSidebarPeek: false })),
setRightSidebarOpen: (open) => set({ rightSidebarOpen: open, rightSidebarPeek: false }),
setRightSidebarPeek: (peek) => set({ rightSidebarPeek: peek }),
setRightSidebarWidth: (width) => set({ rightSidebarWidth: width }),
setRightSidebarTab: (tab) =>
set((s) => ({