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/index.css.test.ts b/web/src/index.css.test.ts index aa69585283..b38b69b970 100644 --- a/web/src/index.css.test.ts +++ b/web/src/index.css.test.ts @@ -196,7 +196,7 @@ describe("index.css sidebar canvas", () => { /:root:not\(\.dark\)\[data-theme\] \.conversations-sidebar,[^{]*\.dark\[data-theme\] \.conversations-sidebar \{[^}]*\}/, )?.[0]; const lightEdgeRule = cssSource.match( - /html:not\(\.dark\) \.conversations-sidebar \{[^}]*\}/, + /html:not\(\.dark\) \.conversations-sidebar(?::not\(\.is-peek\))? \{[^}]*\}/, )?.[0]; const darkEdgeRule = cssSource.match(/\.dark \.conversations-sidebar \{[^}]*\}/)?.[0]; diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx index 5aad6cfc20..30e4b573c3 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(); @@ -987,6 +989,15 @@ export function AppShell() { setRightPanelOpen(next); }; + // The hotkey (⌘⌥[) and command-palette toggle for the left sidebar. A peeking + // sidebar counts as open, so toggling collapses it; either way peek is + // cleared so we never leave `sidebarOpen` and `sidebarPeek` both true (a + // floating-card layout the rest of the shell treats as a pushing panel). + const toggleLeftSidebar = () => { + setSidebarOpen(!(sidebarOpen || sidebarPeek)); + setSidebarPeek(false); + }; + // Toggle the workspace rail's full-screen (maximized) state. Entering // collapses the left sidebar (the maximized rail wants the full width) after // stashing its prior open-state; exiting restores that state. The sidebar @@ -1009,7 +1020,7 @@ export function AppShell() { // ⌘⌥[ / ⌘⌥] (Ctrl+Alt on Win/Linux) toggle the left and right sidebars. Bound // here where both panels' open-state lives. useSidebarToggleHotkeys({ - onToggleLeft: () => setSidebarOpen((prev) => !prev), + onToggleLeft: toggleLeftSidebar, onToggleRight: toggleRightPanel, }); @@ -1415,8 +1426,16 @@ export function AppShell() { )} { + setSidebarOpen(true); + setSidebarPeek(false); + }} + peek={sidebarPeek} dragProgress={sidebarDragProgress} - onClose={() => setSidebarOpen(false)} + onClose={() => { + setSidebarOpen(false); + setSidebarPeek(false); + }} onOpenSearch={() => setCommandPaletteOpen(true)} /> @@ -1446,8 +1465,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} @@ -1712,7 +1739,7 @@ export function AppShell() { setSidebarOpen((prev) => !prev)} + onToggleLeftSidebar={toggleLeftSidebar} onToggleRightSidebar={toggleRightPanel} /> {/* Transient toasts (e.g. "session archived"). Mounted once here so 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.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index ce570a7b71..850572a9b6 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -999,3 +999,52 @@ describe("sharing kill switch", () => { expect(screen.getByTestId("share-conversation")).not.toHaveAttribute("data-disabled"); }); }); + +describe("peek mode row menu", () => { + // Peek render variant: the menu content portals OUTSIDE the