From 9a2af4c4d993d7bc78f31eed9bf6422fdaf39600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ng=C3=B4=20Th=C6=B0=E1=BB=A3ng?= <83950837+nguyenngothuong@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:11:08 +0700 Subject: [PATCH] feat(nav-rail): add expand/collapse toggle with project names and settings control (#511) * feat(nav-rail): add expand/collapse toggle with project names and settings control The NavRail introduced in v1.7.5 only shows project icons without names, making it difficult to identify which project is active when multiple projects share similar icons or use letter avatars. Changes: - Add expandable NavRail that shows full project names alongside icons - Default to expanded when multiple projects are open - Auto-collapse to icon-only mode with a single project (nothing to differentiate) - Add toggle button at bottom of rail with Cmd+Shift+E keyboard shortcut - Highlight active project with interactive.selection background for clarity - Show keyboard shortcut hints in tooltip and expanded label - Add 'Expand project rail' checkbox in Settings > Appearance > Navigation - Persist expansion state across sessions Fixes: NavRail project icons are ambiguous without visible project names * feat: improve nav rail toggle and navigation settings - Make nav rail collapsed by default with smoother text fade behavior - Move nav rail and terminal quick keys controls into a dedicated Navigation section - Update keyboard shortcuts for logs/status and nav rail expand/collapse --------- Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/components/layout/NavRail.tsx | 448 +++++++++++------- .../sections/openchamber/OpenChamberPage.tsx | 4 +- .../openchamber/OpenChamberVisualSettings.tsx | 147 ++++-- packages/ui/src/hooks/useKeyboardShortcuts.ts | 8 + packages/ui/src/lib/shortcuts.ts | 9 +- packages/ui/src/stores/useUIStore.ts | 12 + 6 files changed, 402 insertions(+), 226 deletions(-) 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 ( <> @@ -687,4 +787,4 @@ export const NavRail: React.FC = ({ className, mobile }) => { ); }; -export { NAV_RAIL_WIDTH }; +export { NAV_RAIL_WIDTH, NAV_RAIL_EXPANDED_WIDTH }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index b9c29731..4c942f61 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -97,9 +97,9 @@ const ShortcutsSectionContent: React.FC = () => { return ; }; -// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile) +// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile), Nav Rail const VisualSectionContent: React.FC = () => { - return ; + return ; }; // Chat section: Default Tool Output, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 82db99cf..07766e65 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -83,7 +83,7 @@ const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [ }, ]; -export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft'; +export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -119,6 +119,8 @@ export const OpenChamberVisualSettings: React.FC const setQueueMode = useMessageQueueStore(state => state.setQueueMode); const persistChatDraft = useUIStore(state => state.persistChatDraft); const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); + const isNavRailExpanded = useUIStore(state => state.isNavRailExpanded); + const setNavRailExpanded = useUIStore(state => state.setNavRailExpanded); const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar); const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar); const { @@ -170,6 +172,7 @@ export const OpenChamberVisualSettings: React.FC const hasAppearanceSettings = shouldShow('theme') && !isVSCodeRuntime(); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset'); + const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile); const hasBehaviorSettings = shouldShow('toolOutput') || shouldShow('diffLayout') || (shouldShow('mobileStatusBar') && isMobile) @@ -177,8 +180,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('reasoning') || shouldShow('queueMode') || shouldShow('textJustificationActivity') - || shouldShow('persistDraft') - || (shouldShow('terminalQuickKeys') && !isMobile); + || shouldShow('persistDraft'); return (
@@ -244,24 +246,28 @@ export const OpenChamberVisualSettings: React.FC
- { + const startedAt = Date.now(); setThemesReloading(true); try { await reloadCustomThemes(); } finally { + const elapsed = Date.now() - startedAt; + if (elapsed < 500) { + await new Promise((resolve) => { + window.setTimeout(resolve, 500 - elapsed); + }); + } setThemesReloading(false); } }} - className="!font-normal" + className="inline-flex items-center typography-ui-label font-normal text-foreground underline decoration-[1px] underline-offset-2 hover:text-foreground/80 disabled:cursor-not-allowed disabled:text-muted-foreground/60" > - - Reload themes - + {themesReloading ? 'Reloading themes...' : 'Reload themes'} +
)} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 689c680d..0d358889 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -15,6 +15,7 @@ export const useKeyboardShortcuts = () => { toggleCommandPalette, toggleHelpDialog, toggleSidebar, + toggleNavRail, toggleRightSidebar, setRightSidebarOpen, setRightSidebarTab, @@ -138,6 +139,12 @@ export const useKeyboardShortcuts = () => { return; } + if (eventMatchesShortcut(e, combo('toggle_nav_rail'))) { + e.preventDefault(); + toggleNavRail(); + return; + } + if (eventMatchesShortcut(e, combo('focus_input'))) { e.preventDefault(); const textarea = document.querySelector('textarea[data-chat-input="true"]'); @@ -382,6 +389,7 @@ export const useKeyboardShortcuts = () => { toggleCommandPalette, toggleHelpDialog, toggleSidebar, + toggleNavRail, toggleRightSidebar, setRightSidebarOpen, setRightSidebarTab, diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 925fe7e6..16e696cd 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -120,7 +120,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ }, { id: 'open_status', - defaultCombo: 'mod+shift+l', + defaultCombo: 'mod+shift+o', label: 'Open OpenCode status', description: 'Open the OpenCode status dialog', }, @@ -158,6 +158,13 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ description: 'Toggle the session sidebar', customizable: true, }, + { + id: 'toggle_nav_rail', + defaultCombo: 'mod+shift+l', + label: 'Toggle project rail', + description: 'Expand or collapse the project navigation rail', + customizable: true, + }, { id: 'toggle_right_sidebar', defaultCombo: 'mod+b', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 528abfee..bf90df64 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -145,6 +145,7 @@ interface UIStore { isBottomTerminalExpanded: boolean; bottomTerminalHeight: number; hasManuallyResizedBottomTerminal: boolean; + isNavRailExpanded: boolean; isSessionSwitcherOpen: boolean; activeMainTab: MainTab; mainTabGuard: MainTabGuard | null; @@ -247,6 +248,8 @@ interface UIStore { setBottomTerminalOpen: (open: boolean) => void; setBottomTerminalExpanded: (expanded: boolean) => void; setBottomTerminalHeight: (height: number) => void; + setNavRailExpanded: (expanded: boolean) => void; + toggleNavRail: () => void; setSessionSwitcherOpen: (open: boolean) => void; setActiveMainTab: (tab: MainTab) => void; setMainTabGuard: (guard: MainTabGuard | null) => void; @@ -348,6 +351,7 @@ export const useUIStore = create()( isBottomTerminalExpanded: false, bottomTerminalHeight: 300, hasManuallyResizedBottomTerminal: false, + isNavRailExpanded: false, isSessionSwitcherOpen: false, activeMainTab: 'chat', mainTabGuard: null, @@ -733,6 +737,13 @@ export const useUIStore = create()( set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true }); }, + setNavRailExpanded: (expanded) => { + set({ isNavRailExpanded: expanded }); + }, + toggleNavRail: () => { + set({ isNavRailExpanded: !get().isNavRailExpanded }); + }, + setSessionSwitcherOpen: (open) => { set({ isSessionSwitcherOpen: open }); }, @@ -1325,6 +1336,7 @@ export const useUIStore = create()( isBottomTerminalOpen: state.isBottomTerminalOpen, isBottomTerminalExpanded: state.isBottomTerminalExpanded, bottomTerminalHeight: state.bottomTerminalHeight, + isNavRailExpanded: state.isNavRailExpanded, isSessionSwitcherOpen: state.isSessionSwitcherOpen, activeMainTab: state.activeMainTab, sidebarSection: state.sidebarSection,