Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)
## Summary Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements. ## Key Changes **Sidebar & Navigation Redesign** - Redesigned sessions sidebar layout with unified button primitives - Added activity sections with project grouping and improved session organization - Refined sidebar corners, spacing, and visual hierarchy - Removed NavRail component in favor of streamlined sidebar - Stabilized sessions bar toggle position in fullscreen mode **Performance Optimizations** - Reduced chat streaming CPU usage and storage churn - Optimized task tool polling and live timers with debouncing - Prevented chat state races and reduced background request load - Debounced draft writes and coalesced session reloads - Optimized message store updates and turn tracking **Theme & Visual System** - Added theme-aware window corners (desktop) and border radius tokens - Introduced glassmorphism effects on desktop sidebar - Added backdrop blur to UI elements **Chat Experience** - Added session-based permission auto-accept toggle in chat input - Polished permission shield UX with improved icon sizing and spacing - Fixed chat scroll-to-bottom behavior and timeline tracking - Enhanced tool output display with better path label detection - Removed duplicate draft context details in chat header - Added text selection menu to chat messages **Git Improvements** - Refreshed git history visual design with cleaner dividers - Added remote removal action in sync selector - Stabilized git polling to prevent excessive requests - Improved tool output rendering for git operations **Settings & Panels** - Fixed mobile scrolling on settings pages - Made outside-click settings close instantly - Reduced settings load churn and CPU spikes - Improved services dropdown layout and spacing - Softened panel resize handles **Desktop Integration** - Synced macOS window theme with app theme - Restored window dragging in sidebar header zones - Fixed system window corners on macOS - Improved header session metadata and action controls **Button & Component Standardization** - Unified button primitives across all components - Standardized destructive action patterns - Removed unused button variants (button-large, button-small) - Aligned context tab close hit areas --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
359879153a
commit
321cc7252a
@@ -48,7 +48,7 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
if (!parentHeight || parentHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
const next = Math.round(parentHeight);
|
||||
const next = Math.max(0, Math.round(parentHeight));
|
||||
setFullscreenHeight((prev) => (prev === next ? prev : next));
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
}
|
||||
|
||||
const appliedHeight = isOpen
|
||||
? (isFullscreen ? Math.max(standardHeight, fullscreenHeight ?? standardHeight) : standardHeight)
|
||||
? (isFullscreen ? Math.max(0, fullscreenHeight ?? standardHeight) : standardHeight)
|
||||
: 0;
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
@@ -146,8 +146,8 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
{isOpen && !isFullscreen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-20 h-[4px] w-full cursor-row-resize hover:bg-primary/50 transition-colors',
|
||||
isResizing && 'bg-primary'
|
||||
'absolute left-0 top-0 z-20 h-[3px] w-full cursor-row-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
|
||||
isResizing && 'bg-[var(--interactive-border)]'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
role="separator"
|
||||
|
||||
@@ -427,7 +427,7 @@ export const ContextPanel: React.FC = () => {
|
||||
const isFileTabActive = activeTab?.mode === 'file';
|
||||
|
||||
const header = (
|
||||
<header className="flex h-8 items-stretch border-b border-border/40">
|
||||
<header className="flex h-10 items-stretch border-b border-transparent">
|
||||
<SortableTabsStrip
|
||||
items={tabItems}
|
||||
activeId={activeTab?.id ?? null}
|
||||
@@ -450,8 +450,11 @@ export const ContextPanel: React.FC = () => {
|
||||
reorderContextPanelTabs(directoryKey, activeTabID, overTabID);
|
||||
}}
|
||||
layoutMode="scrollable"
|
||||
variant="active-pill"
|
||||
activePillLowercase={false}
|
||||
activePillInsetClassName="gap-0.5 pt-0.5 pb-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-1 px-1.5">
|
||||
<div className="flex items-end gap-1 px-1.5 pb-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -515,8 +518,8 @@ export const ContextPanel: React.FC = () => {
|
||||
{!isExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize transition-colors hover:bg-primary/50',
|
||||
isResizing && 'bg-primary'
|
||||
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
|
||||
isResizing && 'bg-[var(--interactive-border)]'
|
||||
)}
|
||||
onPointerDown={handleResizeStart}
|
||||
onPointerMove={handleResizeMove}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,8 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { motion, useMotionValue, animate } from 'motion/react';
|
||||
import { Header } from './Header';
|
||||
import { BottomTerminalDock } from './BottomTerminalDock';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { NavRail } from './NavRail';
|
||||
import { RightSidebar } from './RightSidebar';
|
||||
import { Sidebar, SIDEBAR_CONTENT_WIDTH } from './Sidebar';
|
||||
import { RightSidebar, RIGHT_SIDEBAR_CONTENT_WIDTH } from './RightSidebar';
|
||||
import { RightSidebarTabs } from './RightSidebarTabs';
|
||||
import { ContextPanel } from './ContextPanel';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
@@ -22,11 +21,16 @@ import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
|
||||
|
||||
// Mobile drawer width as screen percentage
|
||||
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
|
||||
const DESKTOP_SIDEBAR_MIN_WIDTH = 250;
|
||||
const DESKTOP_SIDEBAR_MAX_WIDTH = 500;
|
||||
const DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH = 400;
|
||||
const DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH = 860;
|
||||
|
||||
const normalizeDirectoryKey = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -69,6 +73,10 @@ export const MainLayout: React.FC = () => {
|
||||
} = useUIStore();
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
|
||||
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
|
||||
const [desktopRightSidebarActionsHost, setDesktopRightSidebarActionsHost] = React.useState<HTMLDivElement | null>(null);
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
|
||||
const isContextPanelOpen = useUIStore((state) => {
|
||||
@@ -590,6 +598,14 @@ export const MainLayout: React.FC = () => {
|
||||
}, [activeMainTab]);
|
||||
|
||||
const isChatActive = activeMainTab === 'chat';
|
||||
const visibleSidebarWidth = React.useMemo(() => {
|
||||
const rawWidth = sidebarWidth || SIDEBAR_CONTENT_WIDTH;
|
||||
return Math.min(DESKTOP_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_SIDEBAR_MIN_WIDTH, rawWidth));
|
||||
}, [sidebarWidth]);
|
||||
const visibleRightSidebarWidth = React.useMemo(() => {
|
||||
const rawWidth = rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH;
|
||||
return Math.min(DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH, rawWidth));
|
||||
}, [rightSidebarWidth]);
|
||||
|
||||
return (
|
||||
<DiffWorkerProvider>
|
||||
@@ -597,7 +613,7 @@ export const MainLayout: React.FC = () => {
|
||||
className={cn(
|
||||
'main-content-safe-area h-[100dvh]',
|
||||
isMobile ? 'flex flex-col' : 'flex',
|
||||
'bg-background'
|
||||
isDesktopShellRuntime ? 'bg-transparent' : 'bg-background'
|
||||
)}
|
||||
>
|
||||
<CommandPalette />
|
||||
@@ -654,7 +670,7 @@ export const MainLayout: React.FC = () => {
|
||||
opacity: mobileLeftDrawerOpen || isRightSidebarOpen ? 1 : 0,
|
||||
pointerEvents: mobileLeftDrawerOpen || isRightSidebarOpen ? 'auto' : 'none',
|
||||
}}
|
||||
className="fixed inset-0 z-40 bg-black/50 cursor-default"
|
||||
className="fixed left-0 right-0 bottom-0 top-[var(--oc-header-height,56px)] z-40 bg-black/50 cursor-default"
|
||||
onClick={() => {
|
||||
setMobileLeftDrawerOpen(false);
|
||||
setRightSidebarOpen(false);
|
||||
@@ -696,15 +712,15 @@ export const MainLayout: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-50 h-full bg-transparent',
|
||||
'fixed left-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-transparent',
|
||||
'cursor-grab active:cursor-grabbing'
|
||||
)}
|
||||
aria-hidden={!mobileLeftDrawerOpen}
|
||||
>
|
||||
<div className="h-full overflow-hidden flex bg-sidebar shadow-none drawer-safe-area">
|
||||
<div onPointerDownCapture={(e) => e.stopPropagation()}>
|
||||
<NavRail className="shrink-0" mobile />
|
||||
</div>
|
||||
<div
|
||||
className="h-full overflow-hidden flex bg-[var(--surface-background)] shadow-none drawer-safe-area"
|
||||
style={{ backgroundImage: 'linear-gradient(var(--surface-muted), var(--surface-muted))' }}
|
||||
>
|
||||
<div className="flex-1 min-w-0 overflow-hidden flex flex-col">
|
||||
<ErrorBoundary>
|
||||
<SessionSidebar mobileVariant />
|
||||
@@ -747,7 +763,7 @@ export const MainLayout: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'fixed right-0 top-0 z-50 h-full bg-transparent',
|
||||
'fixed right-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-transparent',
|
||||
'cursor-grab active:cursor-grabbing'
|
||||
)}
|
||||
aria-hidden={!isRightSidebarOpen}
|
||||
@@ -765,7 +781,6 @@ export const MainLayout: React.FC = () => {
|
||||
'flex flex-1 overflow-hidden relative',
|
||||
(isSettingsDialogOpen || isMultiRunLauncherOpen) && 'hidden'
|
||||
)}
|
||||
style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
|
||||
>
|
||||
<main className="w-full h-full overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
@@ -781,7 +796,10 @@ export const MainLayout: React.FC = () => {
|
||||
|
||||
{/* Mobile multi-run launcher: full screen */}
|
||||
{isMultiRunLauncherOpen && (
|
||||
<div className="absolute inset-0 z-10 bg-background header-safe-area">
|
||||
<div
|
||||
className="absolute inset-0 z-10 bg-background"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MultiRunLauncher
|
||||
initialPrompt={multiRunLauncherPrefillPrompt}
|
||||
@@ -794,49 +812,143 @@ export const MainLayout: React.FC = () => {
|
||||
|
||||
{/* Mobile settings: full screen */}
|
||||
{isSettingsDialogOpen && (
|
||||
<div className="absolute inset-0 z-10 bg-background header-safe-area">
|
||||
<div
|
||||
className="absolute inset-0 z-10 bg-background"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</DrawerProvider>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop: Header always on top, then Sidebar + Content below */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden relative">
|
||||
{/* Normal view: Header above Sidebar + content (like SettingsView) */}
|
||||
<div className={cn('absolute inset-0 flex flex-col', isMultiRunLauncherOpen && 'invisible')}>
|
||||
<Header />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<NavRail />
|
||||
<div className="flex flex-1 min-w-0 overflow-hidden border-t border-l border-border/50 rounded-tl-xl">
|
||||
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
||||
<SessionSidebar hideProjectSelector />
|
||||
</Sidebar>
|
||||
<div className="flex flex-1 min-w-0 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<main className="flex-1 overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
{/* Desktop: Sidebar is a left column; header belongs to content column */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
<div className={cn(
|
||||
'absolute inset-0 flex overflow-hidden',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar',
|
||||
isMultiRunLauncherOpen && 'invisible'
|
||||
)}>
|
||||
{isSidebarOpen ? (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-0 z-0',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar'
|
||||
)}
|
||||
style={{
|
||||
left: `${visibleSidebarWidth}px`,
|
||||
width: 'var(--radius-md)',
|
||||
height: 'var(--radius-md)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 100% 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
maskImage: 'radial-gradient(circle at 100% 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-0 z-0',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar'
|
||||
)}
|
||||
style={{
|
||||
left: `${visibleSidebarWidth}px`,
|
||||
width: 'var(--radius-md)',
|
||||
height: 'var(--radius-md)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 100% 0%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
maskImage: 'radial-gradient(circle at 100% 0%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{isRightSidebarOpen ? (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-0 z-0',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar'
|
||||
)}
|
||||
style={{
|
||||
right: `${visibleRightSidebarWidth}px`,
|
||||
width: 'var(--radius-md)',
|
||||
height: 'var(--radius-md)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 0 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
maskImage: 'radial-gradient(circle at 0 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-0 z-0',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar'
|
||||
)}
|
||||
style={{
|
||||
right: `${visibleRightSidebarWidth}px`,
|
||||
width: 'var(--radius-md)',
|
||||
height: 'var(--radius-md)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 0 0, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
maskImage: 'radial-gradient(circle at 0 0, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Sidebar
|
||||
isOpen={isSidebarOpen}
|
||||
isMobile={isMobile}
|
||||
className="border-0"
|
||||
>
|
||||
<SessionSidebar />
|
||||
</Sidebar>
|
||||
<div className={cn(
|
||||
'relative flex flex-1 min-w-0 flex-col overflow-hidden',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar',
|
||||
isSidebarOpen && 'border-l border-border/50 rounded-tl-md rounded-bl-md',
|
||||
isRightSidebarOpen && 'border-r border-border/50 rounded-tr-md rounded-br-md'
|
||||
)}>
|
||||
<Header desktopRightSidebarActionsHost={desktopRightSidebarActionsHost} />
|
||||
<div className={cn(
|
||||
'flex flex-1 min-h-0 overflow-hidden',
|
||||
isSidebarOpen || isChatActive ? '' : 'border-l border-border/50',
|
||||
isRightSidebarOpen ? '' : 'border-r border-border/50'
|
||||
)}>
|
||||
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<main className="flex-1 overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<ContextPanel />
|
||||
</div>
|
||||
<RightSidebar isOpen={isRightSidebarOpen}>
|
||||
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
|
||||
</RightSidebar>
|
||||
)}
|
||||
</main>
|
||||
<ContextPanel />
|
||||
</div>
|
||||
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
|
||||
<ErrorBoundary><TerminalView /></ErrorBoundary>
|
||||
</BottomTerminalDock>
|
||||
</div>
|
||||
</div>
|
||||
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
|
||||
<ErrorBoundary><TerminalView /></ErrorBoundary>
|
||||
</BottomTerminalDock>
|
||||
</div>
|
||||
<RightSidebar
|
||||
isOpen={isRightSidebarOpen}
|
||||
className="border-0"
|
||||
onTopActionsHostChange={setDesktopRightSidebarActionsHost}
|
||||
>
|
||||
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
|
||||
</RightSidebar>
|
||||
</div>
|
||||
|
||||
{/* Multi-Run Launcher: replaces tabs content only */}
|
||||
|
||||
@@ -1,919 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
RiFolderAddLine,
|
||||
RiSettings3Line,
|
||||
RiQuestionLine,
|
||||
RiDownloadLine,
|
||||
RiInformationLine,
|
||||
RiPencilLine,
|
||||
RiCloseLine,
|
||||
RiMenuFoldLine,
|
||||
RiMenuUnfoldLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
|
||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
|
||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
type NavRailActionButtonProps = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
icon: React.ReactNode;
|
||||
tooltipLabel: string;
|
||||
shortcutHint?: string;
|
||||
showExpandedShortcutHint?: boolean;
|
||||
buttonClassName: string;
|
||||
showExpandedContent: boolean;
|
||||
actionTextVisible: boolean;
|
||||
};
|
||||
|
||||
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
|
||||
onClick,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
icon,
|
||||
tooltipLabel,
|
||||
shortcutHint,
|
||||
showExpandedShortcutHint = true,
|
||||
buttonClassName,
|
||||
showExpandedContent,
|
||||
actionTextVisible,
|
||||
}) => {
|
||||
const pointerTriggeredRef = React.useRef(false);
|
||||
const pointerPressRef = React.useRef<{ active: boolean; pointerId: number | null }>({
|
||||
active: false,
|
||||
pointerId: null,
|
||||
});
|
||||
|
||||
const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled || event.button !== 0) {
|
||||
pointerPressRef.current = { active: false, pointerId: null };
|
||||
return;
|
||||
}
|
||||
pointerPressRef.current = { active: true, pointerId: event.pointerId };
|
||||
}, [disabled]);
|
||||
|
||||
const clearPointerPress = React.useCallback(() => {
|
||||
pointerPressRef.current = { active: false, pointerId: null };
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
if (event.button !== 0) return;
|
||||
const pointerPress = pointerPressRef.current;
|
||||
if (!pointerPress.active || pointerPress.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
clearPointerPress();
|
||||
pointerTriggeredRef.current = true;
|
||||
onClick();
|
||||
}, [clearPointerPress, disabled, onClick]);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (disabled) return;
|
||||
if (pointerTriggeredRef.current) {
|
||||
pointerTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}, [disabled, onClick]);
|
||||
|
||||
const btn = (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={clearPointerPress}
|
||||
onPointerLeave={clearPointerPress}
|
||||
onClick={handleClick}
|
||||
className={buttonClassName}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
/** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */
|
||||
const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({
|
||||
colorVar,
|
||||
children,
|
||||
}) => (
|
||||
<span
|
||||
className="relative flex h-full w-full items-center justify-center rounded-lg overflow-hidden"
|
||||
style={{ backgroundColor: 'var(--surface-muted)' }}
|
||||
>
|
||||
{colorVar && (
|
||||
<span
|
||||
className="absolute inset-0 opacity-15"
|
||||
style={{ backgroundColor: colorVar }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center justify-center">
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
/** First-letter avatar fallback */
|
||||
const LetterAvatar: React.FC<{ label: string; color?: string | null }> = ({
|
||||
label,
|
||||
color,
|
||||
}) => {
|
||||
const letter = label.charAt(0).toUpperCase() || '?';
|
||||
const colorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
|
||||
return (
|
||||
<span
|
||||
className="flex h-4 w-4 items-center justify-center text-[15px] font-medium leading-none select-none"
|
||||
style={{ color: colorVar ?? 'var(--surface-foreground)', fontFamily: 'var(--font-mono, monospace)' }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectStatusDots: React.FC<{
|
||||
color: string;
|
||||
variant?: 'streaming' | 'attention' | 'none';
|
||||
size?: 'sm' | 'md';
|
||||
}> = ({ color, variant = 'none', size = 'md' }) => (
|
||||
<span className="inline-flex items-center justify-center gap-px" aria-hidden="true">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<span key={index} className="inline-flex h-[3px] w-[3px] items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
size === 'sm' ? 'h-[2.5px] w-[2.5px]' : 'h-[3px] w-[3px]',
|
||||
'rounded-full',
|
||||
variant === 'streaming' && 'animate-grid-pulse',
|
||||
variant === 'attention' && 'animate-attention-diamond-pulse'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
animationDelay: variant === 'streaming'
|
||||
? `${index * 150}ms`
|
||||
: variant === 'attention'
|
||||
? (index === 1 ? '0ms' : '130ms')
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
/** Single project tile in the nav rail — right-click for context menu (no visible 3-dot) */
|
||||
const ProjectTile: React.FC<{
|
||||
project: ProjectEntry;
|
||||
isActive: boolean;
|
||||
hasStreaming: boolean;
|
||||
hasUnread: boolean;
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
projectTextVisible: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onClose: () => void;
|
||||
}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [iconImageFailed, setIconImageFailed] = React.useState(false);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const projectIconImageUrl = !iconImageFailed
|
||||
? getProjectIconImageUrl(project, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
})
|
||||
: null;
|
||||
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||
const showStreamingDots = hasStreaming;
|
||||
const showAttentionDots = !hasStreaming && hasUnread;
|
||||
|
||||
React.useEffect(() => {
|
||||
setIconImageFailed(false);
|
||||
}, [project.id, project.iconImage?.updatedAt]);
|
||||
|
||||
const longPressHandlers = useLongPress({
|
||||
onLongPress: () => setMenuOpen(true),
|
||||
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">
|
||||
{projectIconImageUrl ? (
|
||||
<span
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]"
|
||||
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
||||
>
|
||||
<img
|
||||
src={projectIconImageUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
draggable={false}
|
||||
onError={() => setIconImageFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
) : 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 cursor-pointer items-center rounded-lg overflow-hidden',
|
||||
'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(
|
||||
'relative z-10 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 (
|
||||
<>
|
||||
{expanded ? (
|
||||
<div
|
||||
className="relative w-full"
|
||||
onContextMenu={(e) => {
|
||||
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tileButton}
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip delayDuration={400}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="relative"
|
||||
onContextMenu={(e) => {
|
||||
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tileButton}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span className="sr-only">Project options</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="right" sideOffset={4} className="min-w-[160px]">
|
||||
<DropdownMenuItem onClick={onEdit} className="gap-2">
|
||||
<RiPencilLine className="h-4 w-4" />
|
||||
Edit project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onClose}
|
||||
className="text-destructive focus:text-destructive gap-2"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
Close project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** Constrain drag to Y axis only */
|
||||
const restrictToYAxis: Modifier = ({ transform }) => ({
|
||||
...transform,
|
||||
x: 0,
|
||||
});
|
||||
|
||||
/** Sortable wrapper for ProjectTile */
|
||||
const SortableProjectTile: React.FC<{
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ id, children }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
className={cn(isDragging && 'opacity-30 z-50')}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NavRailProps {
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||
const setActiveProjectIdOnly = useProjectsStore((s) => s.setActiveProjectIdOnly);
|
||||
const addProject = useProjectsStore((s) => s.addProject);
|
||||
const removeProject = useProjectsStore((s) => s.removeProject);
|
||||
const reorderProjects = useProjectsStore((s) => s.reorderProjects);
|
||||
const updateProjectMeta = useProjectsStore((s) => s.updateProjectMeta);
|
||||
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
||||
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
||||
const isOverlayBlockingNavRailActions = useUIStore((s) => (
|
||||
s.isSettingsDialogOpen
|
||||
|| s.isHelpDialogOpen
|
||||
|| s.isCommandPaletteOpen
|
||||
|| s.isSessionSwitcherOpen
|
||||
|| s.isAboutDialogOpen
|
||||
|| s.isOpenCodeStatusDialogOpen
|
||||
|| s.isSessionCreateDialogOpen
|
||||
|| s.isModelSelectorOpen
|
||||
|| s.isTimelineDialogOpen
|
||||
|| s.isMultiRunLauncherOpen
|
||||
|| s.isImagePreviewOpen
|
||||
));
|
||||
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);
|
||||
const sessionsByDirectory = useSessionStore((s) => s.sessionsByDirectory);
|
||||
const getSessionsByDirectory = useSessionStore((s) => s.getSessionsByDirectory);
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const availableWorktreesByProject = useSessionStore((s) => s.availableWorktreesByProject);
|
||||
|
||||
const updateStore = useUpdateStore();
|
||||
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
|
||||
|
||||
const [editingProject, setEditingProject] = React.useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
iconBackground?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||
|
||||
const formatLabel = React.useCallback(
|
||||
(project: ProjectEntry): string => {
|
||||
return (
|
||||
project.label?.trim() ||
|
||||
formatDirectoryName(project.path, homeDirectory) ||
|
||||
project.path
|
||||
);
|
||||
},
|
||||
[homeDirectory],
|
||||
);
|
||||
|
||||
const projectIndicators = React.useMemo(() => {
|
||||
const result = new Map<string, { hasStreaming: boolean; hasUnread: boolean }>();
|
||||
for (const project of projects) {
|
||||
const projectRoot = normalize(project.path);
|
||||
if (!projectRoot) {
|
||||
result.set(project.id, { hasStreaming: false, hasUnread: false });
|
||||
continue;
|
||||
}
|
||||
|
||||
const dirs: string[] = [projectRoot];
|
||||
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
|
||||
for (const meta of worktrees) {
|
||||
const p =
|
||||
meta && typeof meta === 'object' && 'path' in meta
|
||||
? (meta as { path?: unknown }).path
|
||||
: null;
|
||||
if (typeof p === 'string' && p.trim()) {
|
||||
const normalized = normalize(p);
|
||||
if (normalized && normalized !== projectRoot) {
|
||||
dirs.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
let hasStreaming = false;
|
||||
let hasUnread = false;
|
||||
|
||||
for (const dir of dirs) {
|
||||
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
|
||||
for (const session of list) {
|
||||
if (!session?.id || seen.has(session.id)) continue;
|
||||
seen.add(session.id);
|
||||
|
||||
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
|
||||
if (statusType === 'busy' || statusType === 'retry') {
|
||||
hasStreaming = true;
|
||||
}
|
||||
|
||||
const isCurrentVisible =
|
||||
session.id === currentSessionId && project.id === activeProjectId;
|
||||
if (
|
||||
!isCurrentVisible &&
|
||||
sessionAttentionStates.get(session.id)?.needsAttention === true
|
||||
) {
|
||||
hasUnread = true;
|
||||
}
|
||||
|
||||
if (hasStreaming && hasUnread) break;
|
||||
}
|
||||
if (hasStreaming && hasUnread) break;
|
||||
}
|
||||
|
||||
result.set(project.id, { hasStreaming, hasUnread });
|
||||
}
|
||||
return result;
|
||||
}, [
|
||||
activeProjectId,
|
||||
availableWorktreesByProject,
|
||||
currentSessionId,
|
||||
getSessionsByDirectory,
|
||||
projects,
|
||||
sessionAttentionStates,
|
||||
sessionStatus,
|
||||
sessionsByDirectory,
|
||||
]);
|
||||
|
||||
const handleAddProject = React.useCallback(() => {
|
||||
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
|
||||
sessionEvents.requestDirectoryDialog();
|
||||
return;
|
||||
}
|
||||
requestDirectoryAccess('')
|
||||
.then((result) => {
|
||||
if (result.success && result.path) {
|
||||
const added = addProject(result.path, { id: result.projectId });
|
||||
if (!added) {
|
||||
toast.error('Failed to add project', {
|
||||
description: 'Please select a valid directory.',
|
||||
});
|
||||
}
|
||||
} else if (result.error && result.error !== 'Directory selection cancelled') {
|
||||
toast.error('Failed to select directory', { description: result.error });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to select directory:', error);
|
||||
toast.error('Failed to select directory');
|
||||
});
|
||||
}, [addProject, tauriIpcAvailable]);
|
||||
|
||||
const handleEditProject = React.useCallback(
|
||||
(projectId: string) => {
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (!project) return;
|
||||
setEditingProject({
|
||||
id: project.id,
|
||||
name: formatLabel(project),
|
||||
path: project.path,
|
||||
icon: project.icon,
|
||||
color: project.color,
|
||||
iconBackground: project.iconBackground,
|
||||
});
|
||||
},
|
||||
[projects, formatLabel],
|
||||
);
|
||||
|
||||
const handleSaveProjectEdit = React.useCallback(
|
||||
(data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
|
||||
if (!editingProject) return;
|
||||
updateProjectMeta(editingProject.id, data);
|
||||
setEditingProject(null);
|
||||
},
|
||||
[editingProject, updateProjectMeta],
|
||||
);
|
||||
|
||||
const handleCloseProject = React.useCallback(
|
||||
(projectId: string) => {
|
||||
removeProject(projectId);
|
||||
},
|
||||
[removeProject],
|
||||
);
|
||||
|
||||
// Cmd/Ctrl+number to switch projects
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= projects.length) {
|
||||
e.preventDefault();
|
||||
const target = projects[num - 1];
|
||||
if (target && target.id !== activeProjectId) {
|
||||
setActiveProjectIdOnly(target.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [projects, activeProjectId, setActiveProjectIdOnly]);
|
||||
|
||||
// Drag-to-reorder
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const projectIds = React.useMemo(() => projects.map((p) => p.id), [projects]);
|
||||
|
||||
const handleDragEnd = React.useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const fromIndex = projects.findIndex((p) => p.id === active.id);
|
||||
const toIndex = projects.findIndex((p) => p.id === over.id);
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
reorderProjects(fromIndex, toIndex);
|
||||
}
|
||||
},
|
||||
[projects, reorderProjects],
|
||||
);
|
||||
|
||||
const navRailActionButtonClass = cn(
|
||||
'group relative flex h-8 cursor-pointer items-center rounded-lg disabled:cursor-not-allowed',
|
||||
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 shrink-0';
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={cn(
|
||||
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
|
||||
showExpandedContent ? 'items-stretch' : 'items-center',
|
||||
navRailInteractionBlocked && 'pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
|
||||
aria-label="Project navigation"
|
||||
>
|
||||
{/* Projects list */}
|
||||
<div className="flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToYAxis]}
|
||||
>
|
||||
<SortableContext items={projectIds} strategy={verticalListSortingStrategy}>
|
||||
<div className={cn('flex flex-col gap-3 pt-1 pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
||||
{projects.map((project) => {
|
||||
const isActive = project.id === activeProjectId;
|
||||
const indicators = projectIndicators.get(project.id);
|
||||
return (
|
||||
<SortableProjectTile key={project.id} id={project.id}>
|
||||
<ProjectTile
|
||||
project={project}
|
||||
isActive={isActive}
|
||||
hasStreaming={indicators?.hasStreaming ?? false}
|
||||
hasUnread={indicators?.hasUnread ?? false}
|
||||
label={formatLabel(project)}
|
||||
expanded={showExpandedContent}
|
||||
projectTextVisible={projectTextVisible}
|
||||
onClick={() => {
|
||||
if (project.id !== activeProjectId) {
|
||||
setActiveProjectIdOnly(project.id);
|
||||
}
|
||||
}}
|
||||
onEdit={() => handleEditProject(project.id)}
|
||||
onClose={() => handleCloseProject(project.id)}
|
||||
/>
|
||||
</SortableProjectTile>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Add project button */}
|
||||
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
||||
<NavRailActionButton
|
||||
onClick={handleAddProject}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Add project"
|
||||
icon={<RiFolderAddLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="Add project"
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<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) && (
|
||||
<NavRailActionButton
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Update available"
|
||||
icon={<RiDownloadLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="Update available"
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
|
||||
<NavRailActionButton
|
||||
onClick={() => setAboutDialogOpen(true)}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="About"
|
||||
icon={<RiInformationLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="About OpenChamber"
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!mobile && (
|
||||
<NavRailActionButton
|
||||
onClick={toggleHelpDialog}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Keyboard shortcuts"
|
||||
icon={<RiQuestionLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="Shortcuts"
|
||||
shortcutHint={shortcutLabel('open_help')}
|
||||
showExpandedShortcutHint={false}
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NavRailActionButton
|
||||
onClick={() => setSettingsDialogOpen(true)}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Settings"
|
||||
icon={<RiSettings3Line className={navRailActionIconClass} />}
|
||||
tooltipLabel="Settings"
|
||||
shortcutHint={shortcutLabel('open_settings')}
|
||||
showExpandedShortcutHint={false}
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
|
||||
{/* Toggle expand/collapse (desktop only) */}
|
||||
{!mobile && (
|
||||
<NavRailActionButton
|
||||
onClick={toggleNavRail}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
icon={expanded
|
||||
? <RiMenuFoldLine className={navRailActionIconClass} />
|
||||
: <RiMenuUnfoldLine className={navRailActionIconClass} />
|
||||
}
|
||||
tooltipLabel={expanded ? 'Collapse' : 'Expand'}
|
||||
shortcutHint={shortcutLabel('toggle_nav_rail')}
|
||||
showExpandedShortcutHint={false}
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Dialogs */}
|
||||
{editingProject && (
|
||||
<ProjectEditDialog
|
||||
open={!!editingProject}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingProject(null);
|
||||
}}
|
||||
projectId={editingProject.id}
|
||||
projectName={editingProject.name}
|
||||
projectPath={editingProject.path}
|
||||
initialIcon={editingProject.icon}
|
||||
initialColor={editingProject.color}
|
||||
initialIconBackground={editingProject.iconBackground}
|
||||
onSave={handleSaveProjectEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpdateDialog
|
||||
open={updateDialogOpen}
|
||||
onOpenChange={setUpdateDialogOpen}
|
||||
info={updateStore.info}
|
||||
downloading={updateStore.downloading}
|
||||
downloaded={updateStore.downloaded}
|
||||
progress={updateStore.progress}
|
||||
error={updateStore.error}
|
||||
onDownload={updateStore.downloadUpdate}
|
||||
onRestart={updateStore.restartToUpdate}
|
||||
runtimeType={updateStore.runtimeType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { NAV_RAIL_WIDTH, NAV_RAIL_EXPANDED_WIDTH };
|
||||
@@ -153,6 +153,26 @@ const extractBestUrl = (value: string): string | null => {
|
||||
return normalized[0] ?? null;
|
||||
};
|
||||
|
||||
const formatActionButtonLabel = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return 'Action';
|
||||
}
|
||||
|
||||
const words = trimmed.split(/\s+/).filter(Boolean);
|
||||
if (words.length >= 2) {
|
||||
const first = words[0];
|
||||
const second = words[1].slice(0, 3);
|
||||
const shortTwoWord = `${first} ${second}`.trim();
|
||||
if (words.length > 2 || shortTwoWord.length < trimmed.length) {
|
||||
return `${shortTwoWord}...`;
|
||||
}
|
||||
return shortTwoWord;
|
||||
}
|
||||
|
||||
return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed;
|
||||
};
|
||||
|
||||
export const ProjectActionsButton = ({
|
||||
projectRef,
|
||||
directory,
|
||||
@@ -651,15 +671,15 @@ export const ProjectActionsButton = ({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-7 items-center gap-2 self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] pl-1.5 pr-2.5 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
|
||||
'app-region-no-drag inline-flex h-7 shrink-0 items-center gap-2 self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] px-3 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
className
|
||||
)}
|
||||
onClick={openProjectActionsSettings}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="header-open-label">Add action</span>
|
||||
<span className="header-open-label whitespace-nowrap">Add action</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -671,6 +691,7 @@ export const ProjectActionsButton = ({
|
||||
|
||||
const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
||||
const selectedButtonLabel = formatActionButtonLabel(resolvedSelected.name);
|
||||
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
|
||||
const selectedRunning = runningByKey[selectedRunKey];
|
||||
const isStoppingSelected = selectedRunning?.status === 'stopping';
|
||||
@@ -738,7 +759,7 @@ export const ProjectActionsButton = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex items-center self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'app-region-no-drag inline-flex shrink-0 items-center self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-none overflow-hidden',
|
||||
compact ? 'h-9' : 'h-7',
|
||||
className
|
||||
@@ -750,8 +771,8 @@ export const ProjectActionsButton = ({
|
||||
disabled={isLoading || isStoppingSelected}
|
||||
className={cn(
|
||||
'inline-flex h-full items-center typography-ui-label font-medium text-foreground hover:bg-interactive-hover',
|
||||
compact ? 'w-9 justify-center px-0' : 'gap-2 pl-2 pr-3',
|
||||
'transition-colors disabled:cursor-not-allowed'
|
||||
compact ? 'w-9 justify-center px-0' : 'gap-2 px-3',
|
||||
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed'
|
||||
)}
|
||||
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
|
||||
>
|
||||
@@ -762,7 +783,7 @@ export const ProjectActionsButton = ({
|
||||
? <RiStopLine className="h-4 w-4 text-[var(--status-warning)]" />
|
||||
: <SelectedIcon className="h-4 w-4" />}
|
||||
</span>
|
||||
{!compact ? <span className="header-open-label">{resolvedSelected.name}</span> : null}
|
||||
{!compact ? <span className="header-open-label whitespace-nowrap">{selectedButtonLabel}</span> : null}
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
@@ -772,14 +793,14 @@ export const ProjectActionsButton = ({
|
||||
className={cn(
|
||||
compact ? 'inline-flex h-full w-8 items-center justify-center' : 'inline-flex h-full w-7 items-center justify-center',
|
||||
'border-l border-[var(--interactive-border)] text-muted-foreground',
|
||||
'hover:bg-interactive-hover hover:text-foreground transition-colors'
|
||||
'hover:bg-interactive-hover hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
||||
)}
|
||||
aria-label="Choose project action"
|
||||
>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" alignOffset={8} className="w-52 max-h-[70vh] overflow-y-auto">
|
||||
<DropdownMenuContent align="center" className="w-52 max-h-[70vh] overflow-y-auto" style={{ translate: '-30px 0' }}>
|
||||
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">Add new action</span>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
export const RIGHT_SIDEBAR_CONTENT_WIDTH = 420;
|
||||
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
|
||||
const RIGHT_SIDEBAR_MAX_WIDTH = 860;
|
||||
|
||||
interface RightSidebarProps {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onTopActionsHostChange?: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children }) => {
|
||||
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, className, onTopActionsHostChange }) => {
|
||||
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
|
||||
const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth);
|
||||
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(rightSidebarWidth || 420);
|
||||
@@ -34,7 +39,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
}, []);
|
||||
|
||||
const appliedWidth = isOpen
|
||||
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420))
|
||||
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH))
|
||||
: 0;
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
@@ -97,13 +102,47 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
}
|
||||
}, [isResizing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
onTopActionsHostChange?.(null);
|
||||
}
|
||||
}, [isOpen, onTopActionsHostChange]);
|
||||
|
||||
const handleDragStart = React.useCallback(async (event: React.MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.app-region-no-drag')) {
|
||||
return;
|
||||
}
|
||||
if (target.closest('button, a, input, select, textarea')) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const appWindow = getCurrentWindow();
|
||||
await appWindow.startDragging();
|
||||
} catch (error) {
|
||||
console.error('Failed to start window dragging:', error);
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={cn(
|
||||
'relative flex h-full overflow-hidden border-l border-border/40 bg-sidebar/50',
|
||||
'relative flex h-full overflow-hidden border-l border-border/40',
|
||||
isOpen
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar',
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
||||
!isOpen && 'border-l-0'
|
||||
!isOpen && 'border-l-0',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: 'var(--oc-right-sidebar-width)',
|
||||
@@ -114,11 +153,23 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
}}
|
||||
aria-hidden={!isOpen || appliedWidth === 0}
|
||||
>
|
||||
{isOpen ? (
|
||||
<div
|
||||
onMouseDown={handleDragStart}
|
||||
className="app-region-drag absolute inset-x-0 top-0 z-20 flex h-[var(--oc-header-height,56px)] items-center justify-end px-3"
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
ref={onTopActionsHostChange}
|
||||
className="app-region-no-drag flex items-center gap-1"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
|
||||
isResizing && 'bg-primary'
|
||||
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
|
||||
isResizing && 'bg-[var(--interactive-border)]'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -135,6 +186,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
isResizing && 'pointer-events-none',
|
||||
!isOpen && 'pointer-events-none select-none opacity-0'
|
||||
)}
|
||||
style={isOpen ? { paddingTop: 'var(--oc-header-height, 56px)' } : undefined}
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
{isOpen ? children : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
export const SIDEBAR_CONTENT_WIDTH = 250;
|
||||
const SIDEBAR_MIN_WIDTH = 250;
|
||||
@@ -11,10 +12,12 @@ interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
isMobile: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children }) => {
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
|
||||
const { sidebarWidth, setSidebarWidth } = useUIStore();
|
||||
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
||||
@@ -115,9 +118,12 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
ref={sidebarRef}
|
||||
className={cn(
|
||||
'relative flex h-full overflow-hidden border-r border-border/40',
|
||||
'bg-sidebar/50',
|
||||
isDesktopApp
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar',
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
||||
!isOpen && 'border-r-0'
|
||||
!isOpen && 'border-r-0',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: 'var(--oc-left-sidebar-width)',
|
||||
@@ -131,8 +137,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
|
||||
isResizing && 'bg-primary'
|
||||
'absolute right-0 top-0 z-20 h-full w-[3px] cursor-col-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
|
||||
isResizing && 'bg-[var(--interactive-border)]'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
|
||||
Reference in New Issue
Block a user