From 2e631ef6366d78b7f9c22eac247b4425b9242554 Mon Sep 17 00:00:00 2001 From: Hubert Zub Date: Fri, 7 Aug 2026 14:17:18 +0000 Subject: [PATCH 1/4] Sidebar peek Signed-off-by: Hubert Zub --- web/src/index.css | 2 +- web/src/shell/AppShell.tsx | 24 ++++++- web/src/shell/ChatHeader.tsx | 31 ++++++++- web/src/shell/Sidebar.tsx | 128 +++++++++++++++++++++++++++-------- 4 files changed, 152 insertions(+), 33 deletions(-) diff --git a/web/src/index.css b/web/src/index.css index 0ced4eeb50..39808b035b 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -468,7 +468,7 @@ } /* Edge treatment is mode-specific but palette-independent. */ -html:not(.dark) .conversations-sidebar { +html:not(.dark) .conversations-sidebar:not(.is-peek) { border-right: none; box-shadow: inset -8px 0 12px -8px rgb(0 0 0 / 5%); } diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx index 6d4a608480..d679866959 100644 --- a/web/src/shell/AppShell.tsx +++ b/web/src/shell/AppShell.tsx @@ -159,6 +159,8 @@ export function AppShell() { const inlinePanelMinWidth = rightRailTab === "files" && fileViewerCommentsOpen ? 720 : undefined; const [searchParams, setSearchParams] = useSearchParams(); const [sidebarOpen, setSidebarOpen] = useState(initialSidebarOpen); + const [sidebarPeek, setSidebarPeek] = useState(false); + // Reads the same module-level store Sidebar drives, so the rail's ceiling // tracks the live sidebar width (including a drag) rather than a guess. const { width: sidebarWidth } = useResizableSidebar(); @@ -1396,8 +1398,16 @@ export function AppShell() { )} { + setSidebarOpen(true); + setSidebarPeek(false); + }} + peek={sidebarPeek} dragProgress={sidebarDragProgress} - onClose={() => setSidebarOpen(false)} + onClose={() => { + setSidebarOpen(false); + setSidebarPeek(false); + }} onOpenSearch={() => setCommandPaletteOpen(true)} /> @@ -1427,8 +1437,16 @@ export function AppShell() { } > setSidebarOpen(true)} + sidebarOpen={sidebarOpen || sidebarPeek} + onOpenSidebar={(peek?: boolean) => { + if (peek) { + setSidebarPeek(true); + setSidebarOpen(false); + } else { + setSidebarOpen(true); + setSidebarPeek(false); + } + }} isChildSession={isChildSession} parentSessionId={activeSession?.parentSessionId} conversationId={conversationId} diff --git a/web/src/shell/ChatHeader.tsx b/web/src/shell/ChatHeader.tsx index 8c68ea58e5..1281b6a4bb 100644 --- a/web/src/shell/ChatHeader.tsx +++ b/web/src/shell/ChatHeader.tsx @@ -26,9 +26,11 @@ import { AgentInfoButton } from "@/components/AgentInfo"; import { nativeCodingAgentForSubagentWrapper } from "@/lib/nativeCodingAgents"; import { PresenceAvatars } from "@/components/PresenceAvatars"; import type { Agent } from "@/hooks/useAgents"; +import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; import { cn } from "@/lib/utils"; import { TAB_BADGE_BASE } from "./railTabs"; import { ViewModeToggle } from "./ViewModeToggle"; +import { useCallback, useEffect, useRef } from "react"; /** * Gating flags + handlers for the mobile-only session-menu FAB (the @@ -97,7 +99,7 @@ interface ChatHeaderProps { /** Whether the left sidebar is open (hides the open-sidebar button). */ sidebarOpen: boolean; /** Open the left sidebar. */ - onOpenSidebar: () => void; + onOpenSidebar: (peek?: boolean) => void; /** Whether the active session is a sub-agent (shows the back link). */ isChildSession: boolean; /** Parent session id for the back link's destination (when a child). */ @@ -183,6 +185,26 @@ export function ChatHeader({ onToggleRightPanel, mobileMenu, }: ChatHeaderProps) { + // Dwell on the toggle for 1s to peek the sidebar; leaving before then cancels + // the pending peek so a quick pass-over never opens it. Peek is a desktop + // hover affordance — on mobile the toggle just opens the full-screen overlay, + // so a tap's synthetic pointerenter must not trigger it. + const isMobile = useIsMobileViewport(); + const peekTimer = useRef | null>(null); + const cancelPeek = useCallback(() => { + if (peekTimer.current) { + clearTimeout(peekTimer.current); + peekTimer.current = null; + } + }, []); + const onPeekSidebar = useCallback(() => { + if (isMobile) return; + cancelPeek(); + peekTimer.current = setTimeout(() => { + onOpenSidebar(true); + }, 400); + }, [isMobile, onOpenSidebar, cancelPeek]); + useEffect(() => cancelPeek, [cancelPeek]); // A native sub-agent (a Claude Code Task, a Codex collab thread) is bound to // its parent's `-native-ui` row, so its agent name is an internal // the server itself hides (`public_agent_name`). Name the product instead, @@ -223,8 +245,13 @@ export function ChatHeader({ variant="ghost" size="icon" aria-label="Open sidebar" - onClick={onOpenSidebar} + onClick={() => { + cancelPeek(); + onOpenSidebar(false); + }} className="text-muted-foreground hover:text-foreground" + onPointerEnter={onPeekSidebar} + onPointerLeave={cancelPeek} > diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 3483ac6ae2..6db52ce568 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -51,6 +51,7 @@ import { SquarePenIcon, Trash2Icon, XIcon, + PanelLeftOpenIcon, } from "lucide-react"; import { DndContext, @@ -247,6 +248,7 @@ type SelectionScope = "sessions" | "projects"; interface SidebarProps { open: boolean; onClose: () => void; + onOpen: () => void; /** * Live open fraction (0 = closed, 1 = open) while the iOS shell's left-edge * swipe is dragging the sidebar; `null` when not dragging. When set, the @@ -262,6 +264,10 @@ interface SidebarProps { * Optional (defaults to a no-op) so the sidebar renders standalone in tests. */ onOpenSearch?: () => void; + /** + * Whether the sidebar is peeking. + */ + peek?: boolean; } /** @@ -445,7 +451,14 @@ export function useMigrateLocalPinsToServer( }, [pinnedLoaded, filterHonored]); } -export function Sidebar({ open, onClose, dragProgress = null, onOpenSearch }: SidebarProps) { +export function Sidebar({ + open, + onClose, + onOpen, + dragProgress = null, + onOpenSearch, + peek, +}: SidebarProps) { const [selectionMode, setSelectionMode] = useState(false); // Which rows the current selection targets: the flat "Sessions" list, or the // sessions nested inside project folders. Set when selection mode is entered @@ -636,11 +649,32 @@ export function Sidebar({ open, onClose, dragProgress = null, onOpenSearch }: Si // interactive even though `open` hasn't flipped yet — treat a live drag as // visually open so it isn't `inert`/`aria-hidden` mid-gesture. const dragging = dragProgress != null; - const effectiveOpen = open || dragging; + const effectiveOpen = open || dragging || peek; + + // While peeking, leaving the card closes it after a short grace period; + // re-entering before that fires cancels the close so a wobble doesn't + // dismiss it. + const peekCloseTimer = useRef | null>(null); + const cancelPeekClose = useCallback(() => { + if (peekCloseTimer.current) { + clearTimeout(peekCloseTimer.current); + peekCloseTimer.current = null; + } + }, []); + useEffect(() => cancelPeekClose, [cancelPeekClose]); return (