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,