From a88b104463902e19e12ddb73640e140a73b5d27b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 29 Jul 2026 20:03:15 -0700 Subject: [PATCH 1/3] feat(desktop): floating hover-peek sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the macOS desktop shell the collapsed sidebar has no width at all (`--sidebar-collapsed-width: 0`), so the only ways back were ⌘B and the title-bar toggle. Hovering that toggle now floats the sidebar in as a card over the content, inset from the window edge, and it retracts when the pointer leaves. Clicking the same control still docks it for good. The card is the existing `.sidebar-shell-outer` re-styled, not a second surface: `` is never re-mounted, so its scroll position and expansion state survive a peek. One CSS rule re-points `--sidebar-width` at a new `--sidebar-expanded-width`, and the inner shell and aside follow with no per-element overrides. Chrome is all existing tokens — `--radius`, `--border`, `shadow-overlay`, `--z-modal` — and the fill is the sidebar's own `--surface-1`, so docked and floating are the same surface. Enter/exit use the popper idiom (`fade-in-0 zoom-in-95`) that emcn's Radix surfaces already use. Also migrates the search palette from raw `z-40`/`z-50` to `--z-modal`. It was the one overlay outranked by the new card, and the raw values also put it below `--z-toast`, inverting the order globals.css documents. Settings drive-bys, all pre-existing violations in files this touches: tokenize two literal pixel text sizes in passwords-view, route its cards through `SettingsResourceRow` instead of four re-derived chrome constants, and collapse three `{null}`-bodied `SettingsSection`s in browser settings into one section of real rows. --- apps/sim/app/_styles/globals.css | 19 + apps/sim/app/layout.tsx | 32 +- .../use-sidebar-peek.test.tsx | 417 ++++++++++++++++++ .../workspace-chrome/use-sidebar-peek.ts | 234 ++++++++++ .../workspace-chrome/workspace-chrome.tsx | 146 +++++- .../components/browser/browser.test.tsx | 15 +- .../settings/components/browser/browser.tsx | 39 +- .../passwords-view/passwords-view.test.tsx | 2 + .../passwords-view/passwords-view.tsx | 44 +- .../components/search-modal/search-modal.tsx | 4 +- .../w/components/sidebar/sidebar.tsx | 62 ++- apps/sim/stores/sidebar/store.test.ts | 66 ++- apps/sim/stores/sidebar/store.ts | 39 +- 13 files changed, 993 insertions(+), 126 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index f8f3ce85322..87c1a884809 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -12,6 +12,7 @@ :root { --sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */ --sidebar-collapsed-width: 51px; /* icon rail on web; desktop overrides to 0 before first paint */ + --sidebar-expanded-width: 248px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */ --desktop-title-bar-height: 0px; /* macOS traffic-light lane; desktop overrides before first paint */ --desktop-title-bar-inset-x: 0px; /* clearance past the traffic lights; desktop overrides */ --desktop-title-bar-control-offset: 0px; /* centres a lane control; desktop overrides */ @@ -154,6 +155,24 @@ html[data-sim-desktop-title-bar="inset"] --sidebar-width: var(--sidebar-collapsed-width); } +/** + * Hover-peek: the shell floats out of flow as a card, so its subtree reads the restore + * width instead of the collapsed one. Re-declaring the same variable the rule above + * sets carries the inner shell and the aside along with no per-element overrides. + * + * Lives here rather than on the component because a Tailwind arbitrary property is one + * class (0,1,0) and would lose to that rule's (0,2,0) selector. + */ +.sidebar-shell-outer[data-collapsed][data-peek] { + --sidebar-width: var(--sidebar-expanded-width); +} + +/* The card appears at full width, so the aside's own width transition would animate + 0 -> expanded inside it. */ +.sidebar-shell-outer[data-peek] .sidebar-container { + transition: none; +} + .sidebar-container span, .sidebar-container .text-small { transition: opacity 120ms ease; diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 035405a5ae6..3a42fca1bc8 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -112,22 +112,26 @@ export default function RootLayout({ children }: { children: React.ReactNode }) document.cookie = 'sidebar_collapsed=' + (collapsed ? '1' : '0') + '; path=/; max-age=31536000; samesite=lax'; } - if (collapsed) { - document.documentElement.style.setProperty( - '--sidebar-width', - collapsedSidebarWidth + 'px' - ); - } else { - var width = state && state.sidebarWidth; - var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); - var finalWidth = - typeof width === 'number' && isFinite(width) - ? Math.min(Math.max(width, 248), maxSidebarWidth) - : defaultSidebarWidth; - document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px'); - } + // The expanded width is published unconditionally, even while + // collapsed, because the desktop hover-peek renders the sidebar at + // its restore width while --sidebar-width still reads collapsed. + var width = state && state.sidebarWidth; + var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); + var expandedWidth = + typeof width === 'number' && isFinite(width) + ? Math.min(Math.max(width, 248), maxSidebarWidth) + : defaultSidebarWidth; + document.documentElement.style.setProperty( + '--sidebar-expanded-width', + expandedWidth + 'px' + ); + document.documentElement.style.setProperty( + '--sidebar-width', + (collapsed ? collapsedSidebarWidth : expandedWidth) + 'px' + ); } catch (e) { document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px'); + document.documentElement.style.setProperty('--sidebar-expanded-width', defaultSidebarWidth + 'px'); } // Panel width and active tab diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx new file mode 100644 index 00000000000..9cc206c1601 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx @@ -0,0 +1,417 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PEEK_CLOSE_DELAY_MS as CLOSE_DELAY_MS, + PEEK_EXIT_DURATION_MS as EXIT_DURATION_MS, + PEEK_OPEN_DELAY_MS as OPEN_DELAY_MS, + PEEK_POINTER_SAMPLE_MS as POINTER_SAMPLE_MS, + useSidebarPeek, +} from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek' + +/** + * Screen geometry the hook hit-tests against. jsdom gives every element a zero + * rect, so the harness stubs these — without them every coordinate falls inside + * the card's 0×0 box (padded by the gap tolerance) and the peek never retracts. + * Values mirror the real desktop layout: a 32px toggle in the title-bar lane, and + * the card inset 8px from the left starting below that lane. + */ +const TRIGGER_RECT = { left: 78, top: 4, right: 110, bottom: 36 } +const CARD_RECT = { left: 8, top: 40, right: 258, bottom: 852 } + +/** Points used by the tests, in client coordinates. */ +const POINT = { + onTrigger: [94, 20], + inCard: [120, 300], + inGap: [100, 38], + onContent: [800, 400], +} as const + +function stubRect( + element: HTMLElement, + rect: { left: number; top: number; right: number; bottom: number } +) { + element.getBoundingClientRect = () => + ({ + ...rect, + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + toJSON: () => rect, + }) as DOMRect +} + +interface Harness { + state: () => { isPeekActive: boolean; isPeekOpen: boolean } + triggerEnter: () => void + triggerLeave: () => void + setEnabled: (enabled: boolean) => void + setDismissed: (dismissed: boolean) => void + unmount: () => void +} + +/** + * Minimal dependency-free hook harness (the repo has no `@testing-library/react`). + * Mounts the hook in a real React root under jsdom so the refs point at live nodes, + * then stubs their rects — the close hit-test reads geometry off those refs. + */ +function renderPeek(initialEnabled: boolean): Harness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + + let latest = { isPeekActive: false, isPeekOpen: false } + let onTriggerEnter = () => {} + let onTriggerLeave = () => {} + + function Probe({ enabled, dismissed }: { enabled: boolean; dismissed: boolean }) { + const peek = useSidebarPeek(enabled, dismissed) + latest = { isPeekActive: peek.isPeekActive, isPeekOpen: peek.isPeekOpen } + onTriggerEnter = peek.onTriggerEnter + onTriggerLeave = peek.onTriggerLeave + return ( +
+
+
+
+ ) + } + + let props = { enabled: initialEnabled, dismissed: false } + const render = (next: Partial) => { + props = { ...props, ...next } + act(() => { + root.render() + }) + } + render({}) + + const query = (name: string) => + container.querySelector(`[data-probe="${name}"]`) as HTMLElement + + stubRect(query('card'), CARD_RECT) + stubRect(query('trigger'), TRIGGER_RECT) + + return { + state: () => latest, + triggerEnter: () => onTriggerEnter(), + triggerLeave: () => onTriggerLeave(), + setEnabled: (enabled: boolean) => render({ enabled }), + setDismissed: (dismissed: boolean) => render({ dismissed }), + unmount: () => { + act(() => root.unmount()) + container.remove() + }, + } +} + +/** + * Dispatches a pointer move at client coordinates. `target` only matters for the + * portal check — the card and trigger are matched by geometry. Each call advances the + * clock past the hook's sample floor so consecutive moves are never coalesced. + */ +function movePointerTo([x, y]: readonly [number, number], target: Element = document.body) { + act(() => { + vi.advanceTimersByTime(POINTER_SAMPLE_MS) + target.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: x, clientY: y })) + }) +} + +function openPeek(harness: Harness) { + act(() => { + harness.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS) + }) +} + +let active: Harness | null = null +const appended: Element[] = [] + +/** + * Appends a portal-like node to `document.body`, standing in for a menu or tooltip + * the sidebar renders outside the card. Tracked for teardown so a failing assertion + * can never leak a node into the next test. + */ +async function appendToBody(tag: string, attributes: Record) { + const node = document.createElement(tag) + for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value) + appended.push(node) + await act(async () => { + document.body.appendChild(node) + }) + return node +} + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal( + 'matchMedia', + vi.fn().mockReturnValue({ matches: false }) as unknown as typeof matchMedia + ) +}) + +afterEach(() => { + active?.unmount() + active = null + for (const node of appended.splice(0)) node.remove() + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('useSidebarPeek', () => { + it('opens only after the hover dwell elapses', () => { + active = renderPeek(true) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS - 1) + }) + expect(active.state().isPeekActive).toBe(false) + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(active.state().isPeekActive).toBe(true) + expect(active.state().isPeekOpen).toBe(true) + }) + + it('does not open when the pointer leaves before the dwell elapses', () => { + active = renderPeek(true) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS - 10) + active?.triggerLeave() + vi.advanceTimersByTime(OPEN_DELAY_MS) + }) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('never opens while disabled', () => { + active = renderPeek(false) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS * 5) + }) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('stays open while the pointer is inside the card', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.inCard) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 3) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('stays open while the pointer is still over the toggle that opened it', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.onTrigger) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 3) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('stays open while the pointer is over a portalled popper', async () => { + active = renderPeek(true) + openPeek(active) + + const overlay = await appendToBody('div', { 'data-radix-popper-content-wrapper': '' }) + + movePointerTo(POINT.onContent, overlay) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 3) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('retracts after the grace period once the pointer moves to content', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.onContent) + expect(active.state().isPeekOpen).toBe(true) + + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS) + }) + expect(active.state().isPeekOpen).toBe(false) + // Stays mounted so the fade-out can play. + expect(active.state().isPeekActive).toBe(true) + + act(() => { + vi.advanceTimersByTime(EXIT_DURATION_MS) + }) + expect(active.state().isPeekActive).toBe(false) + }) + + it('stays open while crossing the gap between the toggle and the card', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.inGap) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 2) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('cancels a pending retraction when the pointer returns', () => { + active = renderPeek(true) + openPeek(active) + + movePointerTo(POINT.onContent) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS - 20) + }) + movePointerTo(POINT.inCard) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 2) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('does not open on hover while a modal is already open', () => { + active = renderPeek(true) + active.setDismissed(true) + + openPeek(active) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('retracts when a modal opens, even with the pointer inside', () => { + active = renderPeek(true) + openPeek(active) + movePointerTo(POINT.inCard) + + active.setDismissed(true) + + expect(active.state().isPeekOpen).toBe(false) + }) + + it('keeps the peek open while the pointer is over a non-modal popper', async () => { + active = renderPeek(true) + openPeek(active) + + const popper = await appendToBody('div', { 'data-radix-popper-content-wrapper': '' }) + movePointerTo(POINT.onContent, popper) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS * 2) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + /** + * Regression: a modal's scrim is `fixed inset-0` and carries emcn's + * `data-native-surface-overlay` marker, so matching that marker made every pointer + * position read as "inside a popper" and pinned the card open for good. Only Radix's + * popper wrapper counts. + */ + it('retracts even while a full-screen modal scrim is present', () => { + active = renderPeek(true) + openPeek(active) + + const scrim = document.createElement('div') + scrim.setAttribute('data-native-surface-overlay', '') + appended.push(scrim) + document.body.appendChild(scrim) + + movePointerTo(POINT.onContent, scrim) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS) + }) + + expect(active.state().isPeekOpen).toBe(false) + }) + + it('leaves Escape to an open popper rather than retracting', async () => { + active = renderPeek(true) + openPeek(active) + // Radix nests the state-bearing content inside the wrapper; only an *open* one + // claims Escape, so a menu animating closed still lets the card retract. + const wrapper = await appendToBody('div', { 'data-radix-popper-content-wrapper': '' }) + const content = document.createElement('div') + content.setAttribute('data-state', 'open') + wrapper.appendChild(content) + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + + expect(active.state().isPeekOpen).toBe(true) + }) + + it('retracts on Escape when a popper is only animating closed', async () => { + active = renderPeek(true) + openPeek(active) + const wrapper = await appendToBody('div', { 'data-radix-popper-content-wrapper': '' }) + const content = document.createElement('div') + content.setAttribute('data-state', 'closed') + wrapper.appendChild(content) + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + + expect(active.state().isPeekOpen).toBe(false) + }) + + it('retracts on Escape', () => { + active = renderPeek(true) + openPeek(active) + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + + expect(active.state().isPeekOpen).toBe(false) + }) + + it('drops the peek immediately when it stops being enabled', () => { + active = renderPeek(true) + openPeek(active) + + active.setEnabled(false) + + expect(active.state().isPeekOpen).toBe(false) + expect(active.state().isPeekActive).toBe(false) + }) + + it('opens under reduced motion', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockReturnValue({ matches: true }) as unknown as typeof matchMedia + ) + active = renderPeek(true) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS) + }) + + expect(active.state().isPeekActive).toBe(true) + expect(active.state().isPeekOpen).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts new file mode 100644 index 00000000000..e93acd2c15f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts @@ -0,0 +1,234 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +/** Hover dwell before the card appears, so a cursor passing over the control doesn't trigger it. */ +export const PEEK_OPEN_DELAY_MS = 90 + +/** Grace period after the pointer leaves, so a small overshoot doesn't retract the card. */ +export const PEEK_CLOSE_DELAY_MS = 180 + +/** How long the card stays mounted while its exit animation runs. */ +export const PEEK_EXIT_DURATION_MS = 150 + +/** Floor between pointer hit-tests, so a fast drag doesn't measure rects every event. */ +export const PEEK_POINTER_SAMPLE_MS = 16 + +/** + * Slack around the trigger and the card when hit-testing the pointer. The trigger sits + * in the title-bar lane and the card starts below it, so a straight line between them + * crosses a few pixels belonging to neither. + */ +const PEEK_GAP_TOLERANCE_PX = 12 + +/** + * A transient floating layer — the wrapper Radix puts around popper content, so this + * covers every menu, context menu, select, and popover the sidebar opens. Same + * predicate emcn's own modal uses for "is a floating layer open" (`modal.tsx`). + * + * The pointer over one of these counts as inside the peek — otherwise reaching for a + * context-menu item would retract the card underneath it. + * + * Deliberately NOT the broader `data-native-surface-overlay` marker: emcn stamps that + * on the modal *scrim* too, which is `fixed inset-0`, so a `:not([aria-modal])` filter + * cannot exclude it (Radix puts `aria-modal` on the content, a different node) and any + * open modal would match at every pointer position and pin the card open. Tooltips are + * `pointer-events-none`, so they are never an event target and need no entry here. + */ +const POPPER_SELECTOR = '[data-radix-popper-content-wrapper]' + +/** + * An *open* popper. The `data-state` filter ignores one animating closed, so pressing + * Escape during a menu's exit still reaches the card. Mirrors emcn `modal.tsx`. + */ +const OPEN_POPPER_SELECTOR = `${POPPER_SELECTOR} [data-state="open"]` + +type PeekPhase = 'closed' | 'open' | 'exiting' + +function clearTimer(ref: React.MutableRefObject | null>) { + if (ref.current) { + clearTimeout(ref.current) + ref.current = null + } +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} + +/** + * Whether a client point falls inside an element, padded outward by `pad`. + * + * Geometric rather than DOM containment on purpose: the trigger is wrapped in a + * tooltip whose subtree re-renders, and a containment check against a stale or + * detached node reads as "outside" and retracts the card from under the pointer. + * Coordinates cannot go stale. + */ +function containsPoint(element: Element | null, x: number, y: number, pad: number): boolean { + if (!element) return false + const rect = element.getBoundingClientRect() + return ( + x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad + ) +} + +export interface SidebarPeekResult { + /** Card is mounted as a floating overlay — drives positioning, chrome, and the expanded width. */ + isPeekActive: boolean + /** Card is settled open. Goes false first on exit, so the exit animation can play. */ + isPeekOpen: boolean + /** Attach to the floating card so the pointer hit-test can recognise it. */ + cardRef: React.RefObject + /** + * Attach to the control that opens the peek (the title-bar sidebar toggle). It + * counts as inside the peek once open, so travelling from the control into the + * card never reads as leaving. + */ + triggerRef: React.RefObject + onTriggerEnter: () => void + onTriggerLeave: () => void +} + +/** + * Drives the desktop sidebar's hover-peek: hovering the title-bar sidebar toggle + * floats the collapsed sidebar in over the content, and it retracts once the pointer + * leaves. Clicking that same control still docks the sidebar for good. + * + * The `exiting` phase keeps the card mounted for {@link PEEK_EXIT_DURATION_MS} so its + * exit animation can play; unmounting immediately would snap it away mid-animation. + * + * Retraction is detected from a document-level `pointermove` hit-test rather than + * `mouseleave`, because the menus and tooltips the sidebar opens live in body + * portals. A `mouseleave`-driven peek would retract the instant the pointer crossed + * into one of those, so {@link POPPER_SELECTOR} counts as inside. + * + * @param enabled Whether the peek is available at all (collapsed, on the desktop shell). + * Also masks the returned flags, so a consumer never sees a stale open card for the + * render in which the peek became unavailable. + * @param dismissed Force the card closed — a modal is open and owns the screen. + */ +export function useSidebarPeek(enabled: boolean, dismissed = false): SidebarPeekResult { + const cardRef = useRef(null) + const triggerRef = useRef(null) + const openTimerRef = useRef | null>(null) + const closeTimerRef = useRef | null>(null) + const exitTimerRef = useRef | null>(null) + + const [phase, setPhase] = useState('closed') + + const open = useCallback(() => { + clearTimer(closeTimerRef) + clearTimer(exitTimerRef) + setPhase('open') + }, []) + + const close = useCallback(() => { + clearTimer(openTimerRef) + clearTimer(closeTimerRef) + clearTimer(exitTimerRef) + setPhase((current) => (current === 'open' ? 'exiting' : current)) + exitTimerRef.current = setTimeout( + () => { + exitTimerRef.current = null + setPhase('closed') + }, + prefersReducedMotion() ? 0 : PEEK_EXIT_DURATION_MS + ) + }, []) + + const onTriggerEnter = useCallback(() => { + // `dismissed` is checked here too, not just in the effect below: that effect only + // fires on the transition, so with a modal already open a hover would otherwise + // float the card over it. + if (!enabled || dismissed) return + clearTimer(closeTimerRef) + clearTimer(openTimerRef) + openTimerRef.current = setTimeout(() => { + openTimerRef.current = null + open() + }, PEEK_OPEN_DELAY_MS) + }, [dismissed, enabled, open]) + + const onTriggerLeave = useCallback(() => { + clearTimer(openTimerRef) + }, []) + + /** Drop the card outright — no exit animation — once the peek stops being available. */ + useEffect(() => { + if (enabled) return + clearTimer(openTimerRef) + clearTimer(closeTimerRef) + clearTimer(exitTimerRef) + setPhase('closed') + }, [enabled]) + + /** A modal is a destination and owns the screen, so the transient card yields to it. */ + useEffect(() => { + if (dismissed && phase === 'open') close() + }, [close, dismissed, phase]) + + useEffect(() => { + if (phase !== 'open') return + + let lastSampleAt = Number.NEGATIVE_INFINITY + + const onPointerMove = (event: PointerEvent) => { + // Sampled on the event's own clock rather than a frame callback: rAF is + // throttled in an unfocused or occluded window, which would strand the card up. + if (event.timeStamp - lastSampleAt < PEEK_POINTER_SAMPLE_MS) return + lastSampleAt = event.timeStamp + + // The tolerance bridges the few px of title-bar lane between the trigger's + // bottom edge and the card's top edge. Poppers are matched by DOM instead — + // they can be anchored anywhere on screen. + const target = event.target instanceof Element ? event.target : null + const inside = + containsPoint(cardRef.current, event.clientX, event.clientY, PEEK_GAP_TOLERANCE_PX) || + containsPoint(triggerRef.current, event.clientX, event.clientY, PEEK_GAP_TOLERANCE_PX) || + Boolean(target?.closest(POPPER_SELECTOR)) + if (inside) { + clearTimer(closeTimerRef) + return + } + if (!closeTimerRef.current) { + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null + close() + }, PEEK_CLOSE_DELAY_MS) + } + } + + const onKeyDown = (event: KeyboardEvent) => { + // An open popper owns Escape first — dismissing a context menu shouldn't also + // take the card out from under the pointer. + if (event.key === 'Escape' && !document.querySelector(OPEN_POPPER_SELECTOR)) close() + } + + document.addEventListener('pointermove', onPointerMove, { passive: true }) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('keydown', onKeyDown) + } + }, [close, phase]) + + useEffect( + () => () => { + clearTimer(openTimerRef) + clearTimer(closeTimerRef) + clearTimer(exitTimerRef) + }, + [] + ) + + return { + isPeekActive: phase !== 'closed' && enabled, + isPeekOpen: phase === 'open' && enabled, + cardRef, + triggerRef, + onTriggerEnter, + onTriggerLeave, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index a9e1e226766..b63ccfac0c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -1,12 +1,15 @@ 'use client' -import { useEffect, useLayoutEffect, useRef } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { PanelLeft } from '@sim/emcn/icons' import { usePathname } from 'next/navigation' import { getDesktopBridge } from '@/lib/desktop' +import { applyDesktopTitleBarMode, type DesktopTitleBarMode } from '@/app/_shell/desktop-title-bar' +import { useSidebarPeek } from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek' import { Sidebar, SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { useFullscreenOriginStore } from '@/stores/fullscreen-origin' +import { useSearchModalStore } from '@/stores/modals/search/store' import { useSidebarStore } from '@/stores/sidebar/store' const FULLSCREEN_SUFFIXES = ['/upgrade'] as const @@ -15,6 +18,45 @@ const FULLSCREEN_SUFFIXES = ['/upgrade'] as const const SLIDE_TRANSITION = 'duration-[175ms] ease-[cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none' +/** + * The peek card's floating chrome. + * + * Every value is an existing token: `rounded-lg` is `--radius`, matching the content + * pane it floats beside; `--border` is that pane's border; `shadow-overlay` and + * `--z-modal` are what the app's other edge-anchored panels use. The card's fill is + * the sidebar's own `--surface-1`, so docked and floating are the same surface. + * + * `w-auto` shrink-wraps the inner shell, which `[data-peek]` has already put at the + * expanded width. It must not be a length: `width` cannot interpolate to or from + * `auto`, so entering and leaving the peek snap instead of animating — otherwise the + * card widens as it appears and leaves a shrinking ghost on retract. + */ +const PEEK_CARD_CHROME = + 'absolute top-[var(--desktop-title-bar-height)] bottom-2 left-2 z-[var(--z-modal)] w-auto origin-top-left rounded-lg border border-[var(--border)] shadow-overlay' + +/** + * Peek card enter/exit — the popper idiom rather than a slide, since the card is + * anchored to the title-bar toggle and grows out of that corner exactly as emcn's + * Radix surfaces do (`dropdown-menu.tsx`: `fade-in-0 zoom-in-95`). + * + * Animations rather than transitions: an animation runs from mount, so the card needs + * no hidden "from" frame and no `requestAnimationFrame` to step into — rAF is throttled + * in an unfocused window, which would strand the card mounted-but-invisible. + * + * `duration-150` must match {@link PEEK_EXIT_DURATION_MS}. + */ +const PEEK_CARD_ENTER = cn( + PEEK_CARD_CHROME, + 'animate-in fade-in-0 zoom-in-95 duration-150 ease-out motion-reduce:animate-none' +) +const PEEK_CARD_EXIT = cn( + PEEK_CARD_CHROME, + 'pointer-events-none animate-out fade-out-0 zoom-out-95 fill-mode-forwards duration-150 ease-out motion-reduce:animate-none' +) + +/** The docked rail: in flow, width-animated by the collapse toggle. */ +const SIDEBAR_SHELL_IN_FLOW = cn('transition-[width]', SLIDE_TRANSITION) + interface WorkspaceChromeProps { children: React.ReactNode /** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */ @@ -44,6 +86,12 @@ function isFullscreenPath(pathname: string | null): boolean { * * On a direct load of a fullscreen route the wrapper mounts already collapsed, * so no slide plays (CSS transitions don't run on mount). + * + * On the macOS desktop shell, where collapsing hides the rail entirely, the same + * wrapper doubles as the hover-peek card: hovering the title-bar sidebar toggle + * takes it out of flow, floats it over the content inset from the window edge, and + * re-points `--sidebar-width` at the restore width. The sidebar is never re-mounted + * or duplicated for this — see {@link useSidebarPeek}. */ export function WorkspaceChrome({ children, @@ -56,6 +104,20 @@ export function WorkspaceChrome({ const setOrigin = useFullscreenOriginStore((s) => s.setOrigin) + /** + * Which title-bar treatment the host is using. `inset` is the macOS desktop shell, + * where collapsing hides the rail entirely (`--sidebar-collapsed-width: 0`) — the + * only host where the hover-peek applies. `null` is the web app (icon rail). + */ + const [titleBarMode, setTitleBarMode] = useState(null) + + /** + * The search palette is the one overlay reachable from the peeked card that renders + * a full-screen scrim, so the card yields to it. Read from the store rather than + * sniffed off the DOM — the store is the modal's own source of truth. + */ + const isSearchModalOpen = useSearchModalStore((s) => s.isOpen) + const storeIsCollapsed = useSidebarStore((s) => s.isCollapsed) const hasHydrated = useSidebarStore((s) => s._hasHydrated) const syncSidebarWidth = useSidebarStore((s) => s.syncWidth) @@ -71,6 +133,15 @@ export function WorkspaceChrome({ */ const isCollapsed = hasHydrated ? storeIsCollapsed : initialSidebarCollapsed + /** + * The hover-peek only exists where collapsing leaves nothing behind — the macOS + * desktop shell. The web app keeps a 51px icon rail (with its own hover flyouts), + * and native fullscreen falls back to that same rail. + */ + const peekEnabled = isCollapsed && !isFullscreen && titleBarMode === 'inset' + const { isPeekActive, isPeekOpen, cardRef, triggerRef, onTriggerEnter, onTriggerLeave } = + useSidebarPeek(peekEnabled, isSearchModalOpen) + /** * Suppresses sidebar transitions across the initial hydration window. The * pre-paint script already set the correct `--sidebar-width`, but the store @@ -125,16 +196,20 @@ export function WorkspaceChrome({ }, []) useEffect(() => { + // Seed from the attribute the pre-paint script already wrote, so the peek is armed + // on the first hover instead of waiting for the async bridge below to resolve. + const initial = document.documentElement.getAttribute('data-sim-desktop-title-bar') + if (initial === 'inset' || initial === 'fullscreen') setTitleBarMode(initial) + const windowState = getDesktopBridge()?.windowState if (!windowState) return let disposed = false const applyWindowState = ({ isFullScreen }: { isFullScreen: boolean }) => { if (!disposed) { - document.documentElement.setAttribute( - 'data-sim-desktop-title-bar', - isFullScreen ? 'fullscreen' : 'inset' - ) + const mode: DesktopTitleBarMode = isFullScreen ? 'fullscreen' : 'inset' + applyDesktopTitleBarMode(document.documentElement, mode) + setTitleBarMode(mode) } } const unsubscribe = windowState.onStateChange(applyWindowState) @@ -180,13 +255,19 @@ export function WorkspaceChrome({ )} />
{!isFullscreen && ( - - - + + +
)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx index f8b56eab31c..9fc05929352 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx @@ -25,6 +25,8 @@ vi.mock('@sim/browser-protocol', () => ({ })) vi.mock('@sim/emcn', () => ({ + /** `SettingsResourceRow` composes its tile classes with `cn`. */ + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), Chip: ({ children, disabled, @@ -292,12 +294,21 @@ describe('Browser settings', () => { ]) }) - it('lists each data type inline as a standard settings row', async () => { + it('lists each data type as a standard settings row in one section', async () => { await render() + const section = container.querySelector('section[aria-label="Browsing data"]') + expect(section).not.toBeNull() + + const labels = [...(section?.querySelectorAll('span') ?? [])].map((s) => s.textContent) for (const label of ['Cookies', 'Site data', 'Cached images and files']) { - expect(container.querySelector(`section[aria-label="${label}"]`)).not.toBeNull() + expect(labels).toContain(label) } + expect([...(section?.querySelectorAll('button') ?? [])].map((b) => b.textContent)).toEqual([ + 'Delete cookies', + 'Delete site data', + 'Delete cached images and files', + ]) }) it.each([ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx index 1327c3fdc51..cf9d3f7aa34 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx @@ -166,28 +166,25 @@ export function Browser() { - {canClearData && - DATA_ROWS.map((row) => ( - setConfirming(row)}> - {row.action} - - } - > - {null} - - ))} - {canClearData && ( -

- {siteCount === 0 - ? 'Nothing saved. Sites you sign into in the browser stay on this device.' - : `${siteCount} ${siteCount === 1 ? 'site is' : 'sites are'} signed in or holding cookies, saved on this device only.`}{' '} - Saved passwords are never deleted here. -

+ +
+ {DATA_ROWS.map((row) => ( +
+ + setConfirming(row)}> + {row.action} + +
+ ))} +

+ {siteCount === 0 + ? 'Nothing saved. Sites you sign into in the browser stay on this device.' + : `${siteCount} ${siteCount === 1 ? 'site is' : 'sites are'} signed in or holding cookies, saved on this device only.`}{' '} + Saved passwords are never deleted here. +

+
+
)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx index f43411802b3..b38ba8b8149 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx @@ -15,6 +15,8 @@ const { mockBridge, mockSearch, mockToast } = vi.hoisted(() => ({ })) vi.mock('@sim/emcn', () => ({ + /** `SettingsResourceRow` composes its tile classes with `cn`. */ + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), ArrowLeft: () => , ArrowRight: () => , ChipConfirmModal: ({ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx index 44eefc4b1c6..aad358a1fba 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx @@ -11,19 +11,16 @@ import { ArrowLeft, ArrowRight, ChipConfirmModal, Key, Plus, toast } from '@sim/ import { getDesktopBridge } from '@/lib/desktop' import { ImportModal } from '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal' import { PasswordDetail } from '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -/** The integrations page's responsive card grid and row chrome. */ +/** The integrations page's responsive card grid (see `integration-section.tsx`, `skills.tsx`). */ const CARD_GRID = '-mx-2 grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-x-2 gap-y-0.5' +/** Card hit area; the row chrome inside it comes from {@link SettingsResourceRow}. */ const CARD_CLASSES = - 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' -const CARD_TILE_CLASSES = - 'flex size-full items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)]' -const CARD_TITLE_CLASSES = 'truncate text-[14px] text-[var(--text-body)]' -const CARD_SUBTITLE_CLASSES = 'truncate text-[12px] text-[var(--text-muted)]' -const CARD_ARROW_CLASSES = 'size-4 flex-shrink-0 text-[var(--text-icon)]' + 'w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' const IMPORT_ERROR_MESSAGES: Record = { 'unsupported-platform': 'Importing from another browser is only supported on macOS.', @@ -204,30 +201,23 @@ export function PasswordsView({ credentials, onChange, onBack, onImported }: Pas className={CARD_CLASSES} onClick={() => setSelectedId(credential.id)} > -
-
- {credential.icon ? ( + + ) : ( - - )} -
-
-
- {siteLabel(credential.origin)} - - {credential.username || 'No username'} - -
- + + ) + } + iconFill + title={siteLabel(credential.origin)} + description={credential.username || 'No username'} + trailing={} + /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 6968fbc91f9..1b6c7f931d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -666,7 +666,7 @@ export function SearchModal({ <>
+ {/* The peek card already sits below the lane; reserving it again doubles the offset. */} + {!isPeeking && ( +
+ )}
-
+ className={cn( + 'relative flex flex-shrink-0 items-center px-2 pt-3', + !isPeeking && + '[[data-sim-desktop-title-bar=inset]_&]:pt-[var(--desktop-title-bar-height)]' + )} + > -
+ {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that + out-specifies the `[data-peek]` rule, stranding the card at a stale width. */} + {!isPeeking && ( +
+ )}
{ document.cookie = 'sidebar_collapsed=; path=/; max-age=0' }) @@ -32,3 +41,56 @@ describe('readCollapsedCookie', () => { expect(readCollapsedCookie()).toBe(false) }) }) + +describe('sidebar width CSS variables', () => { + beforeEach(() => { + document.documentElement.style.removeProperty('--sidebar-width') + document.documentElement.style.removeProperty('--sidebar-expanded-width') + useSidebarStore.setState({ isCollapsed: false, sidebarWidth: SIDEBAR_WIDTH.DEFAULT }) + }) + + it('publishes both variables when the width changes while expanded', () => { + useSidebarStore.getState().setSidebarWidth(300) + expect(widthVars()).toEqual({ width: '300px', expanded: '300px' }) + }) + + it('keeps the expanded variable at the restore width while collapsed', () => { + useSidebarStore.getState().setSidebarWidth(300) + useSidebarStore.getState().toggleCollapsed() + + expect(useSidebarStore.getState().isCollapsed).toBe(true) + expect(widthVars()).toEqual({ + width: `${SIDEBAR_WIDTH.COLLAPSED}px`, + expanded: '300px', + }) + }) + + it('restores the collapsed width from the expanded variable on expand', () => { + useSidebarStore.getState().setSidebarWidth(300) + useSidebarStore.getState().toggleCollapsed() + useSidebarStore.getState().toggleCollapsed() + + expect(widthVars()).toEqual({ width: '300px', expanded: '300px' }) + }) + + it('holds the expanded width across a syncWidth while collapsed', () => { + useSidebarStore.getState().setSidebarWidth(300) + useSidebarStore.getState().toggleCollapsed() + document.documentElement.style.removeProperty('--sidebar-expanded-width') + + useSidebarStore.getState().syncWidth() + + expect(widthVars()).toEqual({ + width: `${SIDEBAR_WIDTH.COLLAPSED}px`, + expanded: '300px', + }) + }) + + it('clamps a below-minimum persisted width into the expanded variable', () => { + useSidebarStore.setState({ isCollapsed: true, sidebarWidth: 10 }) + + useSidebarStore.getState().syncWidth() + + expect(widthVars().expanded).toBe(`${SIDEBAR_WIDTH.MIN}px`) + }) +}) diff --git a/apps/sim/stores/sidebar/store.ts b/apps/sim/stores/sidebar/store.ts index bf63a663d3e..015e4f2ed96 100644 --- a/apps/sim/stores/sidebar/store.ts +++ b/apps/sim/stores/sidebar/store.ts @@ -18,10 +18,23 @@ function clampSidebarWidth(width: number): number { return Math.min(Math.max(width, SIDEBAR_WIDTH.MIN), max) } -function applySidebarWidth(width: number) { +/** + * Publishes both sidebar widths, owning the collapsed mapping so callers don't repeat it. + * + * `--sidebar-width` is the width the rail currently occupies (the collapsed width while + * collapsed), whereas `--sidebar-expanded-width` always holds the width to restore to. + * The desktop hover-peek needs the latter: it renders the sidebar at full width while + * the rail itself is still collapsed to zero. + */ +function applySidebarWidths(expandedWidth: number, collapsed: boolean) { if (typeof window === 'undefined') return - const value = Number.isFinite(width) ? width : SIDEBAR_WIDTH.DEFAULT - document.documentElement.style.setProperty('--sidebar-width', `${value}px`) + const expanded = Number.isFinite(expandedWidth) ? expandedWidth : SIDEBAR_WIDTH.DEFAULT + const root = document.documentElement + root.style.setProperty('--sidebar-expanded-width', `${expanded}px`) + root.style.setProperty( + '--sidebar-width', + `${collapsed ? getCollapsedSidebarWidth() : expanded}px` + ) } /** Reads the host-specific collapsed width established by the pre-paint layout script. */ @@ -63,26 +76,21 @@ export const useSidebarStore = create()( if (get().isCollapsed) return const clampedWidth = clampSidebarWidth(width) set({ sidebarWidth: clampedWidth }) - applySidebarWidth(clampedWidth) + applySidebarWidths(clampedWidth, false) }, toggleCollapsed: () => { const { isCollapsed, sidebarWidth } = get() const nextCollapsed = !isCollapsed + const expandedWidth = clampSidebarWidth(sidebarWidth) set({ isCollapsed: nextCollapsed }) applyCollapsedCookie(nextCollapsed) - applySidebarWidth( - nextCollapsed ? getCollapsedSidebarWidth() : clampSidebarWidth(sidebarWidth) - ) + applySidebarWidths(expandedWidth, nextCollapsed) }, syncWidth: () => { const { isCollapsed, sidebarWidth } = get() - if (isCollapsed) { - applySidebarWidth(getCollapsedSidebarWidth()) - return - } const clampedWidth = clampSidebarWidth(sidebarWidth) - if (clampedWidth !== sidebarWidth) set({ sidebarWidth: clampedWidth }) - applySidebarWidth(clampedWidth) + if (!isCollapsed && clampedWidth !== sidebarWidth) set({ sidebarWidth: clampedWidth }) + applySidebarWidths(clampedWidth, isCollapsed) }, setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }), }), @@ -99,10 +107,7 @@ export const useSidebarStore = create()( onRehydrateStorage: () => (state) => { if (state) { state.setHasHydrated(true) - const width = state.isCollapsed - ? getCollapsedSidebarWidth() - : clampSidebarWidth(state.sidebarWidth) - applySidebarWidth(width) + applySidebarWidths(clampSidebarWidth(state.sidebarWidth), state.isCollapsed) } }, /** Only width is persisted; collapse lives in the cookie. */ From 3ec18bc8460eac0ae3fcead0cb9b008d359f090f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 29 Jul 2026 20:12:35 -0700 Subject: [PATCH 2/3] fix(desktop): close the peek's three timer races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (Cursor Bugbot) found three ways the card could flicker: - A modal opening mid-dwell left the open timer pending, so the card mounted over the modal before the dismiss path could run. - A modal opening while the card was already animating out let it keep animating on top of the modal. - Re-hovering the toggle late in the exit window scheduled a fresh dwell without clearing the exit timer, so the card unmounted and then re-mounted — a visible gap with the pointer never leaving the toggle. The first two collapse into one fix: merge the two reset effects so an unavailable peek and a screen-owning modal both drop the card immediately, unconditionally clearing all three timers. Instant is also the right look, since the modal's scrim covers the card's position on the same frame. For the third, a re-hover during the exit now re-opens synchronously rather than scheduling another dwell — the card is already on screen, so the dwell that guards against accidental opens from a cold state does not apply. One regression test each. --- .../use-sidebar-peek.test.tsx | 53 +++++++++++++++++++ .../workspace-chrome/use-sidebar-peek.ts | 28 ++++++---- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx index 9cc206c1601..320c266fcb2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx @@ -300,6 +300,59 @@ describe('useSidebarPeek', () => { expect(active.state().isPeekActive).toBe(false) }) + it('never mounts the card when a modal opens mid-dwell', () => { + active = renderPeek(true) + + act(() => { + active?.triggerEnter() + vi.advanceTimersByTime(OPEN_DELAY_MS - 20) + }) + active.setDismissed(true) + act(() => { + vi.advanceTimersByTime(OPEN_DELAY_MS * 2) + }) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('drops an already-exiting card the instant a modal opens', () => { + active = renderPeek(true) + openPeek(active) + movePointerTo(POINT.onContent) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS) + }) + expect(active.state().isPeekActive).toBe(true) + + active.setDismissed(true) + + expect(active.state().isPeekActive).toBe(false) + }) + + it('snaps a card that is animating out back open on re-hover', () => { + active = renderPeek(true) + openPeek(active) + movePointerTo(POINT.onContent) + act(() => { + vi.advanceTimersByTime(CLOSE_DELAY_MS) + }) + // Mid-exit: mounted but no longer open. + expect(active.state().isPeekActive).toBe(true) + expect(active.state().isPeekOpen).toBe(false) + + // Re-hover late in the exit window; the pending exit timer must not win. + act(() => { + vi.advanceTimersByTime(EXIT_DURATION_MS - 20) + active?.triggerEnter() + }) + expect(active.state().isPeekOpen).toBe(true) + + act(() => { + vi.advanceTimersByTime(EXIT_DURATION_MS * 2) + }) + expect(active.state().isPeekOpen).toBe(true) + }) + it('retracts when a modal opens, even with the pointer inside', () => { active = renderPeek(true) openPeek(active) diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts index e93acd2c15f..5eedee7a713 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts @@ -145,29 +145,39 @@ export function useSidebarPeek(enabled: boolean, dismissed = false): SidebarPeek if (!enabled || dismissed) return clearTimer(closeTimerRef) clearTimer(openTimerRef) + // Still on screen and animating out: snap it back instead of waiting out another + // dwell, which the pending exit timer would win — unmounting the card and then + // re-mounting it, a visible flicker with the pointer never leaving the toggle. + if (phase === 'exiting') { + open() + return + } openTimerRef.current = setTimeout(() => { openTimerRef.current = null open() }, PEEK_OPEN_DELAY_MS) - }, [dismissed, enabled, open]) + }, [dismissed, enabled, open, phase]) const onTriggerLeave = useCallback(() => { clearTimer(openTimerRef) }, []) - /** Drop the card outright — no exit animation — once the peek stops being available. */ + /** + * Drop the card outright — no exit animation — the moment the peek stops being + * available (⌘B, fullscreen) or a modal takes the screen. + * + * Unconditional rather than gated on the current phase, because every phase needs + * clearing: a pending dwell would otherwise fire and mount the card over the modal, + * and an in-flight exit would keep animating on top of it. Instant is also right + * visually — the modal's own scrim covers the card's position on the same frame. + */ useEffect(() => { - if (enabled) return + if (enabled && !dismissed) return clearTimer(openTimerRef) clearTimer(closeTimerRef) clearTimer(exitTimerRef) setPhase('closed') - }, [enabled]) - - /** A modal is a destination and owns the screen, so the transient card yields to it. */ - useEffect(() => { - if (dismissed && phase === 'open') close() - }, [close, dismissed, phase]) + }, [dismissed, enabled]) useEffect(() => { if (phase !== 'open') return From abf1bde709ad22eee959d107c1a0f39042e1ab35 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 29 Jul 2026 20:23:12 -0700 Subject: [PATCH 3/3] fix(desktop): arm the peek before first paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2: the title-bar seed ran in a passive effect, which lands after paint — long enough for a `mouseenter` on the toggle to be dropped while the peek still read disabled, with nothing to retry it until the pointer left and returned. A regression from folding the seed into the window-state effect during cleanup. Promoted to a layout effect, which runs before paint and before any mouse event can be dispatched. The body is cheap synchronous reads plus a subscription; the bridge's getState stays async and blocks nothing. --- .../workspace-chrome/workspace-chrome.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index b63ccfac0c7..7f206c26da2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -195,9 +195,16 @@ export function WorkspaceChrome({ }) }, []) - useEffect(() => { - // Seed from the attribute the pre-paint script already wrote, so the peek is armed - // on the first hover instead of waiting for the async bridge below to resolve. + /** + * A layout effect, not a passive one: the seed below arms the peek, and a passive + * effect lands after paint — long enough for a `mouseenter` on the toggle to be + * dropped while the peek still reads disabled, with nothing to retry it until the + * pointer leaves and returns. Everything here is a cheap synchronous read plus a + * subscription; the bridge's `getState` stays async and blocks nothing. + */ + useLayoutEffect(() => { + // Seed from the attribute the pre-paint script already wrote, rather than waiting + // for the async bridge below to resolve. const initial = document.documentElement.getAttribute('data-sim-desktop-title-bar') if (initial === 'inset' || initial === 'fullscreen') setTitleBarMode(initial)