diff --git a/packages/ui/src/components/layout/NavRail.tsx b/packages/ui/src/components/layout/NavRail.tsx
index ddb768eb..caedf856 100644
--- a/packages/ui/src/components/layout/NavRail.tsx
+++ b/packages/ui/src/components/layout/NavRail.tsx
@@ -22,6 +22,8 @@ import {
RiInformationLine,
RiPencilLine,
RiCloseLine,
+ RiMenuFoldLine,
+ RiMenuUnfoldLine,
} from '@remixicon/react';
import {
DropdownMenu,
@@ -44,6 +46,7 @@ import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { useLongPress } from '@/hooks/useLongPress';
+import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { sessionEvents } from '@/lib/sessionEvents';
import type { ProjectEntry } from '@/lib/api/types';
@@ -54,6 +57,10 @@ const normalize = (value: string): string => {
};
const NAV_RAIL_WIDTH = 56;
+const NAV_RAIL_EXPANDED_WIDTH = 200;
+const NAV_RAIL_TEXT_FADE_MS = 180;
+const PROJECT_TEXT_FADE_IN_DELAY_MS = 24;
+const ACTION_TEXT_FADE_IN_DELAY_MS = 60;
/** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */
const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({
@@ -129,10 +136,12 @@ const ProjectTile: React.FC<{
hasStreaming: boolean;
hasUnread: boolean;
label: string;
+ expanded: boolean;
+ projectTextVisible: boolean;
onClick: () => void;
onEdit: () => void;
onClose: () => void;
-}> = ({ project, isActive, hasStreaming, hasUnread, label, onClick, onEdit, onClose }) => {
+}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
const [menuOpen, setMenuOpen] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
@@ -144,94 +153,112 @@ const ProjectTile: React.FC<{
onTap: onClick,
});
+ const iconElement = (
+
+
+
+ {ProjectIcon ? (
+
+ ) : (
+
+ )}
+
+ {showStreamingDots && (
+
+
+
+ )}
+ {showAttentionDots && (
+
+
+
+ )}
+
+
+ );
+
+ const tileButton = (
+
+ );
+
return (
<>
-
-
- {
- // Only handle right-click (desktop), not long-tap (mobile)
- if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
- e.preventDefault();
- setMenuOpen(true);
- }
- }}
- >
- {hasStreaming ? (
-
- ) : (
-
- )}
-
-
-
-
- {label}
-
-
+ {expanded ? (
+
{
+ if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
+ e.preventDefault();
+ setMenuOpen(true);
+ }
+ }}
+ >
+ {tileButton}
+
+ ) : (
+
+
+ {
+ if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
+ e.preventDefault();
+ setMenuOpen(true);
+ }
+ }}
+ >
+ {tileButton}
+
+
+
+ {label}
+
+
+ )}
Project options
@@ -308,6 +335,45 @@ export const NavRail: React.FC = ({ className, mobile }) => {
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
+ const isNavRailExpanded = useUIStore((s) => s.isNavRailExpanded);
+ const toggleNavRail = useUIStore((s) => s.toggleNavRail);
+ const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
+ const expanded = !mobile && isNavRailExpanded;
+ const [showExpandedContent, setShowExpandedContent] = React.useState(expanded);
+ const [projectTextVisible, setProjectTextVisible] = React.useState(expanded);
+ const [actionTextVisible, setActionTextVisible] = React.useState(expanded);
+
+ React.useEffect(() => {
+ if (expanded) {
+ setShowExpandedContent(true);
+ setProjectTextVisible(false);
+ setActionTextVisible(false);
+ const projectTimer = window.setTimeout(() => {
+ setProjectTextVisible(true);
+ }, PROJECT_TEXT_FADE_IN_DELAY_MS);
+ const actionTimer = window.setTimeout(() => {
+ setActionTextVisible(true);
+ }, ACTION_TEXT_FADE_IN_DELAY_MS);
+ return () => {
+ window.clearTimeout(projectTimer);
+ window.clearTimeout(actionTimer);
+ };
+ }
+
+ setProjectTextVisible(false);
+ setActionTextVisible(false);
+ const timer = window.setTimeout(() => {
+ setShowExpandedContent(false);
+ }, NAV_RAIL_TEXT_FADE_MS);
+
+ return () => {
+ window.clearTimeout(timer);
+ };
+ }, [expanded]);
+
+ const shortcutLabel = React.useCallback((actionId: string) => {
+ return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
+ }, [shortcutOverrides]);
const sessionStatus = useSessionStore((s) => s.sessionStatus);
const sessionAttentionStates = useSessionStore((s) => s.sessionAttentionStates);
@@ -504,21 +570,80 @@ export const NavRail: React.FC = ({ className, mobile }) => {
);
const navRailActionButtonClass = cn(
- 'flex h-8 w-8 items-center justify-center rounded-lg',
- 'text-foreground hover:bg-interactive-hover',
+ 'group relative flex h-8 items-center rounded-lg',
+ showExpandedContent ? 'w-full justify-start gap-2.5 pr-2 pl-2' : 'w-8 justify-center',
+ showExpandedContent
+ ? 'text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)]'
+ : 'text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)]/50 hover:text-[var(--surface-foreground)]',
'transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
);
- const navRailActionIconClass = 'h-4.5 w-4.5';
+ const navRailActionIconClass = 'h-4.5 w-4.5 shrink-0';
+
+ const ActionButton: React.FC<{
+ onClick: () => void;
+ ariaLabel: string;
+ icon: React.ReactNode;
+ tooltipLabel: string;
+ shortcutHint?: string;
+ showExpandedShortcutHint?: boolean;
+ }> = ({ onClick, ariaLabel, icon, tooltipLabel, shortcutHint, showExpandedShortcutHint = true }) => {
+ const btn = (
+
+ );
+
+ return (
+
+ {btn}
+ {!showExpandedContent && (
+
+ {shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}
+
+ )}
+
+ );
+ };
return (
<>