Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%);
}
Expand Down
2 changes: 1 addition & 1 deletion web/src/index.css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
37 changes: 32 additions & 5 deletions web/src/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -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,
});

Expand Down Expand Up @@ -1396,8 +1407,16 @@ export function AppShell() {
)}
<Sidebar
open={sidebarOpen}
onOpen={() => {
setSidebarOpen(true);
setSidebarPeek(false);
}}
peek={sidebarPeek}
dragProgress={sidebarDragProgress}
onClose={() => setSidebarOpen(false)}
onClose={() => {
setSidebarOpen(false);
setSidebarPeek(false);
}}
onOpenSearch={() => setCommandPaletteOpen(true)}
/>

Expand Down Expand Up @@ -1427,8 +1446,16 @@ export function AppShell() {
}
>
<ChatHeader
sidebarOpen={sidebarOpen}
onOpenSidebar={() => 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}
Expand Down Expand Up @@ -1693,7 +1720,7 @@ export function AppShell() {
<CommandPalette
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
onToggleLeftSidebar={() => setSidebarOpen((prev) => !prev)}
onToggleLeftSidebar={toggleLeftSidebar}
onToggleRightSidebar={toggleRightPanel}
/>
{/* Transient toasts (e.g. "session archived"). Mounted once here so
Expand Down
31 changes: 29 additions & 2 deletions web/src/shell/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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<ReturnType<typeof setTimeout> | 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 `<vendor>-native-ui` row, so its agent name is an internal
// the server itself hides (`public_agent_name`). Name the product instead,
Expand Down Expand Up @@ -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}
>
<PanelLeftIcon className="size-4" />
</Button>
Expand Down
127 changes: 101 additions & 26 deletions web/src/shell/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
SquarePenIcon,
Trash2Icon,
XIcon,
PanelLeftOpenIcon,
} from "lucide-react";
import {
DndContext,
Expand Down Expand Up @@ -247,6 +248,12 @@ type SelectionScope = "sessions" | "projects";
interface SidebarProps {
open: boolean;
onClose: () => void;
/**
* Pin a peeking sidebar fully open (the in-sidebar toggle shown while
* peeking). Optional (defaults to a no-op) so the sidebar renders standalone
* in tests.
*/
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
Expand All @@ -262,6 +269,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;
}

/**
Expand Down Expand Up @@ -445,7 +456,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
Expand Down Expand Up @@ -636,11 +654,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<ReturnType<typeof setTimeout> | null>(null);
const cancelPeekClose = useCallback(() => {
if (peekCloseTimer.current) {
clearTimeout(peekCloseTimer.current);
peekCloseTimer.current = null;
}
}, []);
useEffect(() => cancelPeekClose, [cancelPeekClose]);

return (
<aside
aria-label="Conversations"
onPointerEnter={cancelPeekClose}
onPointerLeave={() => {
if (peek) {
cancelPeekClose();
peekCloseTimer.current = setTimeout(() => {
onClose();
}, 200);
}
}}
className={cn(
// Base: bg + flex column. No transition — expand/collapse snaps
// instantly (animating the width also lagged drag-to-resize).
Expand Down Expand Up @@ -671,8 +710,17 @@ export function Sidebar({ open, onClose, dragProgress = null, onOpenSearch }: Si
// divider — no outer margin or rounding. Width (the user-resizable
// variable) animates →0 to push main; when closed the border
// collapses too so nothing lingers.
"md:relative md:inset-auto md:translate-x-0 md:overflow-hidden",
open ? "md:m-0 md:w-[var(--sidebar-width)] " : "md:m-0 md:w-0 md:border-0",
"md:translate-x-0 md:overflow-hidden",
// Normal desktop flow: relative panel that pushes main. Suppressed while
// peeking so its `md:inset-auto`/`md:relative` don't override the
// floating-card positioning below (same `md:` layer, source order wins).
!peek && "md:relative md:inset-auto",
open || peek ? "md:m-0 md:w-[var(--sidebar-width)] " : "md:m-0 md:w-0 md:border-0",
// Peek: float as a card 4px off the viewport edge (capped at 300px wide),
// ringed and shadowed, sliding+fading in from the left so it reads as an
// overlay rather than a push.
peek &&
"is-peek md:absolute md:inset-2 p-0 md:max-w-[400px] ring-1 ring-border rounded-xl md:shadow-xl animate-in fade-in slide-in-from-left-4 duration-200 ease-out",
)}
style={
{
Expand All @@ -695,11 +743,15 @@ export function Sidebar({ open, onClose, dragProgress = null, onOpenSearch }: Si
{/* Right-edge resize handle (desktop only), mirroring the right rail's
left-edge handle. Hidden on mobile, where the sidebar is a
full-screen overlay with no resize affordance; the parent's ``inert``
when closed also keeps it from being draggable while collapsed. */}
<div
{...resizeHandleProps}
className="absolute inset-y-0 right-0 z-10 hidden w-1 cursor-col-resize transition-colors hover:bg-primary/30 active:bg-primary/50 md:block"
/>
when closed also keeps it from being draggable while collapsed.
Hidden while peeking too — the peek card is a fixed-width flyout, not
a resizable panel. */}
{!peek && (
<div
{...resizeHandleProps}
className="absolute inset-y-0 right-0 z-10 hidden w-1 cursor-col-resize transition-colors hover:bg-primary/30 active:bg-primary/50 md:block"
/>
)}
{inSettings ? (
<SettingsSidebarBody onNavClick={onNavClick} />
) : (
Expand Down Expand Up @@ -758,26 +810,49 @@ export function Sidebar({ open, onClose, dragProgress = null, onOpenSearch }: Si
</TooltipTrigger>
<TooltipContent side="bottom">Settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Close sidebar"
onClick={onClose}
className="size-6 text-muted-foreground hover:text-foreground"
>
{/* panel-right-open while the sidebar IS open — this button
{!peek ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Close sidebar"
onClick={onClose}
className="size-6 text-muted-foreground hover:text-foreground"
>
{/* panel-right-open while the sidebar IS open — this button
only renders in the open state (ChatHeader's PanelLeftIcon
covers the collapsed state). */}
<PanelRightOpenIcon className="ui-icon" />
</Button>
</TooltipTrigger>
{/* Bottom placement keeps the tooltip clear of the macOS
<PanelRightOpenIcon className="ui-icon" />
</Button>
</TooltipTrigger>
{/* Bottom placement keeps the tooltip clear of the macOS
Electron shell's traffic lights at the window's top edge. */}
<TooltipContent side="bottom">Collapse sidebar</TooltipContent>
</Tooltip>
<TooltipContent side="bottom">Collapse sidebar</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Open sidebar"
onClick={onOpen}
className="size-6 text-muted-foreground hover:text-foreground"
>
{/* panel-right-open while the sidebar IS open — this button
only renders in the open state (ChatHeader's PanelLeftIcon
covers the collapsed state). */}
<PanelLeftOpenIcon className="ui-icon" />
</Button>
</TooltipTrigger>
{/* Bottom placement keeps the tooltip clear of the macOS
Electron shell's traffic lights at the window's top edge. */}
<TooltipContent side="bottom">Open sidebar</TooltipContent>
</Tooltip>
)}
</div>
</div>

Expand Down
Loading