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 <artmore@protonmail.com>
This commit is contained in:
Nguyễn Ngô Thượng
2026-02-26 00:11:08 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a59bf010bb
commit 9a2af4c4d9
6 changed files with 402 additions and 226 deletions
+274 -174
View File
@@ -22,6 +22,8 @@ import {
RiInformationLine, RiInformationLine,
RiPencilLine, RiPencilLine,
RiCloseLine, RiCloseLine,
RiMenuFoldLine,
RiMenuUnfoldLine,
} from '@remixicon/react'; } from '@remixicon/react';
import { import {
DropdownMenu, DropdownMenu,
@@ -44,6 +46,7 @@ import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta'; import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { useLongPress } from '@/hooks/useLongPress'; import { useLongPress } from '@/hooks/useLongPress';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import type { ProjectEntry } from '@/lib/api/types'; import type { ProjectEntry } from '@/lib/api/types';
@@ -54,6 +57,10 @@ const normalize = (value: string): string => {
}; };
const NAV_RAIL_WIDTH = 56; 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 */ /** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */
const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({ const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({
@@ -129,10 +136,12 @@ const ProjectTile: React.FC<{
hasStreaming: boolean; hasStreaming: boolean;
hasUnread: boolean; hasUnread: boolean;
label: string; label: string;
expanded: boolean;
projectTextVisible: boolean;
onClick: () => void; onClick: () => void;
onEdit: () => void; onEdit: () => void;
onClose: () => 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 [menuOpen, setMenuOpen] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
@@ -144,94 +153,112 @@ const ProjectTile: React.FC<{
onTap: onClick, onTap: onClick,
}); });
const iconElement = (
<TileBackground colorVar={projectColorVar}>
<span className="relative h-full w-full leading-none">
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
{ProjectIcon ? (
<ProjectIcon
className="h-4 w-4 shrink-0"
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }}
/>
) : (
<LetterAvatar label={label} color={project.color} />
)}
</span>
{showStreamingDots && (
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
<ProjectStatusDots color="var(--primary)" variant="streaming" />
</span>
)}
{showAttentionDots && (
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
<ProjectStatusDots color="var(--status-info)" variant="attention" />
</span>
)}
</span>
</TileBackground>
);
const tileButton = (
<button
type="button"
{...longPressHandlers}
className={cn(
'group relative flex items-center rounded-lg overflow-hidden cursor-default',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
expanded ? 'h-9 w-full gap-2.5 pr-1.5 pl-[7px]' : 'h-9 w-9 justify-center',
!expanded && (
isActive
? 'bg-transparent border border-[var(--surface-foreground)]'
: 'bg-transparent border border-transparent hover:bg-[var(--interactive-hover)]/50 hover:border-[var(--interactive-border)]'
),
!expanded && menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
)}
>
{expanded && (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg border transition-colors',
isActive
? 'bg-[var(--interactive-selection)] border-[var(--interactive-border)]'
: 'bg-transparent border-transparent group-hover:bg-[var(--interactive-hover)]/50 group-hover:border-[var(--interactive-border)]',
menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
)}
/>
)}
<span className="flex size-[34px] basis-[34px] shrink-0 grow-0 items-center justify-center">
{iconElement}
</span>
<span
aria-hidden={!projectTextVisible}
className={cn(
'min-w-0 truncate text-left text-[13px] leading-tight transition-opacity duration-[180ms] ease-in-out',
expanded ? 'flex-1' : 'w-0 flex-none',
projectTextVisible ? 'opacity-100' : 'opacity-0',
isActive && expanded ? 'font-medium text-[var(--interactive-selection-foreground)]' : 'text-[var(--surface-foreground)]',
)}
>
{label}
</span>
</button>
);
return ( return (
<> <>
<Tooltip delayDuration={400}> {expanded ? (
<TooltipTrigger asChild> <div
<div className="relative w-full"
className="relative" onContextMenu={(e) => {
onContextMenu={(e) => { if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
// Only handle right-click (desktop), not long-tap (mobile) e.preventDefault();
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) { setMenuOpen(true);
e.preventDefault(); }
setMenuOpen(true); }}
} >
}} {tileButton}
> </div>
{hasStreaming ? ( ) : (
<button <Tooltip delayDuration={400}>
type="button" <TooltipTrigger asChild>
{...longPressHandlers} <div
className={cn( className="relative"
'flex h-9 w-9 items-center justify-center rounded-lg overflow-hidden cursor-default', onContextMenu={(e) => {
isActive if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
? 'bg-transparent border border-[var(--surface-foreground)]' e.preventDefault();
: 'bg-transparent border border-transparent hover:bg-[var(--interactive-hover)]/50 hover:border-[var(--interactive-border)]', setMenuOpen(true);
menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]', }
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]', }}
)} >
> {tileButton}
<TileBackground colorVar={projectColorVar}> </div>
<span className="relative h-full w-full leading-none"> </TooltipTrigger>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center"> <TooltipContent side="right" sideOffset={8}>
{ProjectIcon ? ( {label}
<ProjectIcon </TooltipContent>
className="h-4 w-4 shrink-0" </Tooltip>
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }} )}
/>
) : (
<LetterAvatar label={label} color={project.color} />
)}
</span>
{showStreamingDots && (
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
<ProjectStatusDots color="var(--primary)" variant="streaming" />
</span>
)}
</span>
</TileBackground>
</button>
) : (
<button
type="button"
{...longPressHandlers}
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg overflow-hidden cursor-default',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
isActive
? 'bg-transparent border border-[var(--surface-foreground)]'
: 'bg-transparent border border-transparent hover:bg-[var(--interactive-hover)]/50 hover:border-[var(--interactive-border)]',
menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
)}
>
<TileBackground colorVar={projectColorVar}>
<span className="relative h-full w-full leading-none">
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
{ProjectIcon ? (
<ProjectIcon
className="h-4 w-4 shrink-0"
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }}
/>
) : (
<LetterAvatar label={label} color={project.color} />
)}
</span>
{showAttentionDots && (
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
<ProjectStatusDots color="var(--status-info)" variant="attention" />
</span>
)}
</span>
</TileBackground>
</button>
)}
</div>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<span className="sr-only">Project options</span> <span className="sr-only">Project options</span>
@@ -308,6 +335,45 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen); const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); 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 sessionStatus = useSessionStore((s) => s.sessionStatus);
const sessionAttentionStates = useSessionStore((s) => s.sessionAttentionStates); const sessionAttentionStates = useSessionStore((s) => s.sessionAttentionStates);
@@ -504,21 +570,80 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
); );
const navRailActionButtonClass = cn( const navRailActionButtonClass = cn(
'flex h-8 w-8 items-center justify-center rounded-lg', 'group relative flex h-8 items-center rounded-lg',
'text-foreground hover:bg-interactive-hover', 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', 'transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]', '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 = (
<button
type="button"
onClick={onClick}
className={navRailActionButtonClass}
aria-label={ariaLabel}
>
{showExpandedContent && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg bg-transparent transition-colors group-hover:bg-[var(--interactive-hover)]/50"
/>
)}
<span className="relative z-10 flex size-8 basis-8 shrink-0 grow-0 items-center justify-center">
{icon}
</span>
<span
aria-hidden={!actionTextVisible}
className={cn(
'relative z-10 min-w-0 flex items-center justify-between gap-1 overflow-hidden transition-opacity duration-[180ms] ease-in-out',
showExpandedContent ? 'flex-1' : 'w-0 flex-none',
actionTextVisible ? 'opacity-100' : 'opacity-0',
)}
>
<span className="truncate text-left text-[13px]">{tooltipLabel}</span>
{shortcutHint && showExpandedShortcutHint && (
<span className="shrink-0 text-[10px] text-[var(--surface-mutedForeground)] opacity-70">
{shortcutHint}
</span>
)}
</span>
</button>
);
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
{!showExpandedContent && (
<TooltipContent side="right" sideOffset={8}>
<p>{shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}</p>
</TooltipContent>
)}
</Tooltip>
);
};
return ( return (
<> <>
<nav <nav
className={cn( className={cn(
'flex h-full w-14 shrink-0 flex-col items-center bg-[var(--surface-background)] overflow-hidden', 'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
showExpandedContent ? 'items-stretch' : 'items-center',
className, className,
)} )}
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
aria-label="Project navigation" aria-label="Project navigation"
> >
{/* Projects list */} {/* Projects list */}
@@ -530,7 +655,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
modifiers={[restrictToYAxis]} modifiers={[restrictToYAxis]}
> >
<SortableContext items={projectIds} strategy={verticalListSortingStrategy}> <SortableContext items={projectIds} strategy={verticalListSortingStrategy}>
<div className="flex flex-col items-center gap-3 px-1 pt-1 pb-3"> <div className={cn('flex flex-col gap-3 pt-1 pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
{projects.map((project) => { {projects.map((project) => {
const isActive = project.id === activeProjectId; const isActive = project.id === activeProjectId;
const indicators = projectIndicators.get(project.id); const indicators = projectIndicators.get(project.id);
@@ -542,6 +667,8 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
hasStreaming={indicators?.hasStreaming ?? false} hasStreaming={indicators?.hasStreaming ?? false}
hasUnread={indicators?.hasUnread ?? false} hasUnread={indicators?.hasUnread ?? false}
label={formatLabel(project)} label={formatLabel(project)}
expanded={showExpandedContent}
projectTextVisible={projectTextVisible}
onClick={() => { onClick={() => {
if (project.id !== activeProjectId) { if (project.id !== activeProjectId) {
setActiveProjectIdOnly(project.id); setActiveProjectIdOnly(project.id);
@@ -559,100 +686,73 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
</DndContext> </DndContext>
{/* Add project button */} {/* Add project button */}
<div className="flex flex-col items-center px-1 pb-3"> <div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
<Tooltip delayDuration={400}> <ActionButton
<TooltipTrigger asChild> onClick={handleAddProject}
<button ariaLabel="Add project"
type="button" icon={<RiFolderAddLine className={navRailActionIconClass} />}
onClick={handleAddProject} tooltipLabel="Add project"
className={navRailActionButtonClass} />
aria-label="Add project"
>
<RiFolderAddLine className={navRailActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Add project
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
{/* Bottom actions */} {/* Bottom actions */}
<div className="shrink-0 w-full pt-3 pb-4 flex flex-col items-center gap-2"> <div className={cn(
'shrink-0 w-full pt-3 pb-4 flex flex-col gap-1',
showExpandedContent ? 'items-stretch px-1' : 'items-center',
)}>
{(updateAvailable || updateDownloaded) && ( {(updateAvailable || updateDownloaded) && (
<Tooltip delayDuration={400}> <ActionButton
<TooltipTrigger asChild> onClick={() => setUpdateDialogOpen(true)}
<button ariaLabel="Update available"
type="button" icon={<RiDownloadLine className={navRailActionIconClass} />}
onClick={() => setUpdateDialogOpen(true)} tooltipLabel="Update available"
className={cn( />
'flex h-8 w-8 items-center justify-center rounded-lg',
'bg-[var(--primary)]/10 text-[var(--primary)]',
'hover:bg-[var(--primary)]/20 transition-colors',
)}
aria-label="Update available"
>
<RiDownloadLine className={navRailActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Update available
</TooltipContent>
</Tooltip>
)} )}
{!isDesktopApp && !(updateAvailable || updateDownloaded) && ( {!isDesktopApp && !(updateAvailable || updateDownloaded) && (
<Tooltip delayDuration={400}> <ActionButton
<TooltipTrigger asChild> onClick={() => setAboutDialogOpen(true)}
<button ariaLabel="About"
type="button" icon={<RiInformationLine className={navRailActionIconClass} />}
onClick={() => setAboutDialogOpen(true)} tooltipLabel="About OpenChamber"
className={navRailActionButtonClass} />
aria-label="About"
>
<RiInformationLine className={navRailActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
About OpenChamber
</TooltipContent>
</Tooltip>
)} )}
{!mobile && ( {!mobile && (
<Tooltip delayDuration={400}> <ActionButton
<TooltipTrigger asChild> onClick={toggleHelpDialog}
<button ariaLabel="Keyboard shortcuts"
type="button" icon={<RiQuestionLine className={navRailActionIconClass} />}
onClick={toggleHelpDialog} tooltipLabel="Shortcuts"
className={navRailActionButtonClass} shortcutHint={shortcutLabel('open_help')}
aria-label="Keyboard shortcuts" showExpandedShortcutHint={false}
> />
<RiQuestionLine className={navRailActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Keyboard shortcuts
</TooltipContent>
</Tooltip>
)} )}
<Tooltip delayDuration={400}> <ActionButton
<TooltipTrigger asChild> onClick={() => setSettingsDialogOpen(true)}
<button ariaLabel="Settings"
type="button" icon={<RiSettings3Line className={navRailActionIconClass} />}
onClick={() => setSettingsDialogOpen(true)} tooltipLabel="Settings"
className={navRailActionButtonClass} shortcutHint={shortcutLabel('open_settings')}
aria-label="Settings" showExpandedShortcutHint={false}
> />
<RiSettings3Line className={navRailActionIconClass} />
</button> {/* Toggle expand/collapse (desktop only) */}
</TooltipTrigger> {!mobile && (
<TooltipContent side="right" sideOffset={8}> <ActionButton
Settings onClick={toggleNavRail}
</TooltipContent> ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
</Tooltip> icon={expanded
? <RiMenuFoldLine className={navRailActionIconClass} />
: <RiMenuUnfoldLine className={navRailActionIconClass} />
}
tooltipLabel={expanded ? 'Collapse' : 'Expand'}
shortcutHint={shortcutLabel('toggle_nav_rail')}
showExpandedShortcutHint={false}
/>
)}
</div> </div>
</nav> </nav>
@@ -687,4 +787,4 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
); );
}; };
export { NAV_RAIL_WIDTH }; export { NAV_RAIL_WIDTH, NAV_RAIL_EXPANDED_WIDTH };
@@ -97,9 +97,9 @@ const ShortcutsSectionContent: React.FC = () => {
return <KeyboardShortcutsSettings />; return <KeyboardShortcutsSettings />;
}; };
// 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 = () => { const VisualSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys']} />; return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys', 'navRail']} />;
}; };
// Chat section: Default Tool Output, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft // Chat section: Default Tool Output, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
@@ -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 { interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */ /** Which settings to show. If undefined, shows all. */
@@ -119,6 +119,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setQueueMode = useMessageQueueStore(state => state.setQueueMode); const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
const persistChatDraft = useUIStore(state => state.persistChatDraft); const persistChatDraft = useUIStore(state => state.persistChatDraft);
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); 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 showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar); const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
const { const {
@@ -170,6 +172,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasAppearanceSettings = shouldShow('theme') && !isVSCodeRuntime(); const hasAppearanceSettings = shouldShow('theme') && !isVSCodeRuntime();
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset'); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile);
const hasBehaviorSettings = shouldShow('toolOutput') const hasBehaviorSettings = shouldShow('toolOutput')
|| shouldShow('diffLayout') || shouldShow('diffLayout')
|| (shouldShow('mobileStatusBar') && isMobile) || (shouldShow('mobileStatusBar') && isMobile)
@@ -177,8 +180,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('reasoning') || shouldShow('reasoning')
|| shouldShow('queueMode') || shouldShow('queueMode')
|| shouldShow('textJustificationActivity') || shouldShow('textJustificationActivity')
|| shouldShow('persistDraft') || shouldShow('persistDraft');
|| (shouldShow('terminalQuickKeys') && !isMobile);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -244,24 +246,28 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 py-1.5"> <div className="flex items-center gap-2 py-1.5">
<ButtonSmall <button
type="button" type="button"
variant="outline"
size="xs"
disabled={customThemesLoading || themesReloading} disabled={customThemesLoading || themesReloading}
onClick={async () => { onClick={async () => {
const startedAt = Date.now();
setThemesReloading(true); setThemesReloading(true);
try { try {
await reloadCustomThemes(); await reloadCustomThemes();
} finally { } finally {
const elapsed = Date.now() - startedAt;
if (elapsed < 500) {
await new Promise<void>((resolve) => {
window.setTimeout(resolve, 500 - elapsed);
});
}
setThemesReloading(false); 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"
> >
<RiRestartLine className={cn('h-3.5 w-3.5', themesReloading && 'animate-spin')} /> {themesReloading ? 'Reloading themes...' : 'Reload themes'}
Reload themes </button>
</ButtonSmall>
<Tooltip delayDuration={700}> <Tooltip delayDuration={700}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@@ -285,9 +291,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{hasLayoutSettings && ( {hasLayoutSettings && (
<div className="mb-8 space-y-3"> <div className="mb-8 space-y-3">
<section className="p-2 space-y-0.5"> <section className="p-2 space-y-0.5">
<h4 className="typography-ui-header font-medium text-foreground">Spacing & Layout</h4>
<div className="pl-2">
{shouldShow('fontSize') && !isMobile && ( {shouldShow('fontSize') && !isMobile && (
<div className="flex items-center gap-8 py-1.5"> <div className="flex items-center gap-8 py-1">
<div className="flex min-w-0 flex-col w-56 shrink-0"> <div className="flex min-w-0 flex-col w-56 shrink-0">
<span className="typography-ui-label text-foreground">Interface Font Size</span> <span className="typography-ui-label text-foreground">Interface Font Size</span>
</div> </div>
@@ -317,7 +325,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)} )}
{shouldShow('terminalFontSize') && ( {shouldShow('terminalFontSize') && (
<div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}> <div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<span className="typography-ui-label text-foreground">Terminal Font Size</span> <span className="typography-ui-label text-foreground">Terminal Font Size</span>
</div> </div>
@@ -346,7 +354,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)} )}
{shouldShow('spacing') && ( {shouldShow('spacing') && (
<div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}> <div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<span className="typography-ui-label text-foreground">Spacing Density</span> <span className="typography-ui-label text-foreground">Spacing Density</span>
</div> </div>
@@ -375,7 +383,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)} )}
{shouldShow('cornerRadius') && ( {shouldShow('cornerRadius') && (
<div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}> <div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<span className="typography-ui-label text-foreground">Corner Radius</span> <span className="typography-ui-label text-foreground">Corner Radius</span>
</div> </div>
@@ -404,7 +412,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)} )}
{shouldShow('inputBarOffset') && ( {shouldShow('inputBarOffset') && (
<div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}> <div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">Input Bar Offset</span> <span className="typography-ui-label text-foreground">Input Bar Offset</span>
@@ -442,6 +450,81 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div> </div>
)} )}
</div>
</section>
</div>
)}
{/* --- Navigation --- */}
{hasNavigationSettings && (
<div className="space-y-3">
<section className="px-2 pb-2 pt-0">
<h4 className="typography-ui-header font-medium text-foreground">Navigation</h4>
{shouldShow('navRail') && !isMobile && (
<div
className="group mt-1.5 flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
onClick={() => setNavRailExpanded(!isNavRailExpanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setNavRailExpanded(!isNavRailExpanded);
}
}}
>
<Checkbox
checked={isNavRailExpanded}
onChange={setNavRailExpanded}
ariaLabel="Expand project rail by default"
/>
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label text-foreground">Expand project rail</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Show project names in the left rail when multiple projects are open. Auto-collapses with a single project.
</TooltipContent>
</Tooltip>
</div>
</div>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showTerminalQuickKeysOnDesktop}
onClick={() => setShowTerminalQuickKeysOnDesktop(!showTerminalQuickKeysOnDesktop)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowTerminalQuickKeysOnDesktop(!showTerminalQuickKeysOnDesktop);
}
}}
>
<Checkbox
checked={showTerminalQuickKeysOnDesktop}
onChange={setShowTerminalQuickKeysOnDesktop}
ariaLabel="Terminal quick keys"
/>
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label text-foreground">Terminal Quick Keys</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Show Esc, Ctrl, Arrows in terminal view
</TooltipContent>
</Tooltip>
</div>
</div>
)}
</section> </section>
</div> </div>
)} )}
@@ -699,40 +782,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</section> </section>
)} )}
{shouldShow('terminalQuickKeys') && !isMobile && (
<section className="p-2">
<div
className="group flex cursor-pointer items-center gap-2 rounded-md py-1.5 transition-colors hover:bg-[var(--interactive-hover)]/30"
role="button"
tabIndex={0}
aria-pressed={showTerminalQuickKeysOnDesktop}
onClick={() => setShowTerminalQuickKeysOnDesktop(!showTerminalQuickKeysOnDesktop)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowTerminalQuickKeysOnDesktop(!showTerminalQuickKeysOnDesktop);
}
}}
>
<Checkbox
checked={showTerminalQuickKeysOnDesktop}
onChange={setShowTerminalQuickKeysOnDesktop}
ariaLabel="Terminal quick keys"
/>
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label text-foreground">Terminal Quick Keys</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Show Esc, Ctrl, Arrows in terminal view
</TooltipContent>
</Tooltip>
</div>
</div>
</section>
)}
</div> </div>
)} )}
@@ -15,6 +15,7 @@ export const useKeyboardShortcuts = () => {
toggleCommandPalette, toggleCommandPalette,
toggleHelpDialog, toggleHelpDialog,
toggleSidebar, toggleSidebar,
toggleNavRail,
toggleRightSidebar, toggleRightSidebar,
setRightSidebarOpen, setRightSidebarOpen,
setRightSidebarTab, setRightSidebarTab,
@@ -138,6 +139,12 @@ export const useKeyboardShortcuts = () => {
return; return;
} }
if (eventMatchesShortcut(e, combo('toggle_nav_rail'))) {
e.preventDefault();
toggleNavRail();
return;
}
if (eventMatchesShortcut(e, combo('focus_input'))) { if (eventMatchesShortcut(e, combo('focus_input'))) {
e.preventDefault(); e.preventDefault();
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]'); const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
@@ -382,6 +389,7 @@ export const useKeyboardShortcuts = () => {
toggleCommandPalette, toggleCommandPalette,
toggleHelpDialog, toggleHelpDialog,
toggleSidebar, toggleSidebar,
toggleNavRail,
toggleRightSidebar, toggleRightSidebar,
setRightSidebarOpen, setRightSidebarOpen,
setRightSidebarTab, setRightSidebarTab,
+8 -1
View File
@@ -120,7 +120,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
}, },
{ {
id: 'open_status', id: 'open_status',
defaultCombo: 'mod+shift+l', defaultCombo: 'mod+shift+o',
label: 'Open OpenCode status', label: 'Open OpenCode status',
description: 'Open the OpenCode status dialog', description: 'Open the OpenCode status dialog',
}, },
@@ -158,6 +158,13 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
description: 'Toggle the session sidebar', description: 'Toggle the session sidebar',
customizable: true, 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', id: 'toggle_right_sidebar',
defaultCombo: 'mod+b', defaultCombo: 'mod+b',
+12
View File
@@ -145,6 +145,7 @@ interface UIStore {
isBottomTerminalExpanded: boolean; isBottomTerminalExpanded: boolean;
bottomTerminalHeight: number; bottomTerminalHeight: number;
hasManuallyResizedBottomTerminal: boolean; hasManuallyResizedBottomTerminal: boolean;
isNavRailExpanded: boolean;
isSessionSwitcherOpen: boolean; isSessionSwitcherOpen: boolean;
activeMainTab: MainTab; activeMainTab: MainTab;
mainTabGuard: MainTabGuard | null; mainTabGuard: MainTabGuard | null;
@@ -247,6 +248,8 @@ interface UIStore {
setBottomTerminalOpen: (open: boolean) => void; setBottomTerminalOpen: (open: boolean) => void;
setBottomTerminalExpanded: (expanded: boolean) => void; setBottomTerminalExpanded: (expanded: boolean) => void;
setBottomTerminalHeight: (height: number) => void; setBottomTerminalHeight: (height: number) => void;
setNavRailExpanded: (expanded: boolean) => void;
toggleNavRail: () => void;
setSessionSwitcherOpen: (open: boolean) => void; setSessionSwitcherOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void; setActiveMainTab: (tab: MainTab) => void;
setMainTabGuard: (guard: MainTabGuard | null) => void; setMainTabGuard: (guard: MainTabGuard | null) => void;
@@ -348,6 +351,7 @@ export const useUIStore = create<UIStore>()(
isBottomTerminalExpanded: false, isBottomTerminalExpanded: false,
bottomTerminalHeight: 300, bottomTerminalHeight: 300,
hasManuallyResizedBottomTerminal: false, hasManuallyResizedBottomTerminal: false,
isNavRailExpanded: false,
isSessionSwitcherOpen: false, isSessionSwitcherOpen: false,
activeMainTab: 'chat', activeMainTab: 'chat',
mainTabGuard: null, mainTabGuard: null,
@@ -733,6 +737,13 @@ export const useUIStore = create<UIStore>()(
set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true }); set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true });
}, },
setNavRailExpanded: (expanded) => {
set({ isNavRailExpanded: expanded });
},
toggleNavRail: () => {
set({ isNavRailExpanded: !get().isNavRailExpanded });
},
setSessionSwitcherOpen: (open) => { setSessionSwitcherOpen: (open) => {
set({ isSessionSwitcherOpen: open }); set({ isSessionSwitcherOpen: open });
}, },
@@ -1325,6 +1336,7 @@ export const useUIStore = create<UIStore>()(
isBottomTerminalOpen: state.isBottomTerminalOpen, isBottomTerminalOpen: state.isBottomTerminalOpen,
isBottomTerminalExpanded: state.isBottomTerminalExpanded, isBottomTerminalExpanded: state.isBottomTerminalExpanded,
bottomTerminalHeight: state.bottomTerminalHeight, bottomTerminalHeight: state.bottomTerminalHeight,
isNavRailExpanded: state.isNavRailExpanded,
isSessionSwitcherOpen: state.isSessionSwitcherOpen, isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab, activeMainTab: state.activeMainTab,
sidebarSection: state.sidebarSection, sidebarSection: state.sidebarSection,