From 10851bd7acf8f37e767326992821d2d19a86c8ca Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 25 Feb 2026 14:32:23 +0200 Subject: [PATCH] feat: move projects to sidebar rail and speed up session switching (#506) * feat: move projects from header tabs to left sidebar rail * refactor: remove variant from git generation session context * feat: implemented drandrop action for navrails * refactor: improve sidebar drag-and-drop smoothness and remove floating overlay * fix: prevent mobile nav rail touch from closing drawer and opening menu * fix: hide header tabs on desktop * fix: prevent session flicker when switching projects * style: soften chat panel divider borders * style: improve icon contrast across core navigation * fix: stabilize session selection during project switching * style: unify sidebar surfaces and transparent section layers * style: remove UI shadows and keep only scroll shadow * perf: speed up project switching with cached session loading * perf: speed up Git changes view and background refresh * perf: make session switching lighter and less aggressive * perf: optimized sessions list loading while project switching * perf: smooth chat rendering and reduce interaction spikes * fix: restore reliable load older messages visibility --- packages/ui/src/App.css | 4 +- .../chat/AgentMentionAutocomplete.tsx | 4 +- .../ui/src/components/chat/ChatContainer.tsx | 17 +- packages/ui/src/components/chat/ChatInput.tsx | 8 +- .../ui/src/components/chat/ChatMessage.tsx | 2 +- .../components/chat/CommandAutocomplete.tsx | 4 +- .../chat/FileMentionAutocomplete.tsx | 4 +- .../src/components/chat/MarkdownRenderer.tsx | 4 +- .../ui/src/components/chat/MessageList.tsx | 86 +- .../src/components/chat/SkillAutocomplete.tsx | 2 +- packages/ui/src/components/chat/StatusRow.tsx | 2 +- .../chat/contexts/TurnGroupingContext.tsx | 225 ++++- .../chat/message/TextSelectionMenu.tsx | 2 +- .../components/comments/InlineCommentCard.tsx | 2 +- .../comments/InlineCommentInput.tsx | 2 +- .../components/desktop/OpenInAppButton.tsx | 2 +- .../ui/src/components/layout/ContextPanel.tsx | 3 +- packages/ui/src/components/layout/Header.tsx | 884 ++---------------- .../ui/src/components/layout/MainLayout.tsx | 27 +- packages/ui/src/components/layout/NavRail.tsx | 690 ++++++++++++++ .../ui/src/components/layout/RightSidebar.tsx | 2 +- .../components/layout/RightSidebarTabs.tsx | 4 +- packages/ui/src/components/layout/Sidebar.tsx | 159 +--- .../components/layout/SidebarFilesTree.tsx | 6 +- .../components/multirun/ModelMultiSelect.tsx | 2 +- .../session/DirectoryAutocomplete.tsx | 2 +- .../src/components/session/SessionSidebar.tsx | 346 ++++--- packages/ui/src/components/ui/HelpDialog.tsx | 2 +- .../ui/src/components/ui/animated-tabs.tsx | 2 +- .../ui/src/components/ui/dropdown-menu.tsx | 4 +- packages/ui/src/components/ui/select.tsx | 2 +- packages/ui/src/components/ui/slider.tsx | 4 +- packages/ui/src/components/ui/switch.tsx | 4 +- packages/ui/src/components/views/GitView.tsx | 48 +- .../src/components/views/SettingsWindow.tsx | 2 +- .../components/views/git/AIHighlightsBox.tsx | 2 +- .../ui/src/components/views/git/ChangeRow.tsx | 18 +- .../components/views/git/ChangesSection.tsx | 100 +- .../src/components/views/git/CommitInput.tsx | 2 +- .../ui/src/components/views/git/GitHeader.tsx | 4 +- .../views/git/PullRequestSection.tsx | 6 +- packages/ui/src/hooks/useGitPollingHook.ts | 2 +- packages/ui/src/index.css | 57 ++ packages/ui/src/lib/gitApi.ts | 10 - .../ui/src/lib/theme/themes/flexoki-dark.json | 16 +- packages/ui/src/stores/messageStore.ts | 15 +- packages/ui/src/stores/sessionStore.ts | 407 +++++--- packages/ui/src/stores/useGitStore.ts | 148 ++- packages/ui/src/stores/useProjectsStore.ts | 20 + packages/ui/src/stores/useSessionStore.ts | 23 +- packages/ui/src/styles/fireworks.css | 1 - 51 files changed, 1995 insertions(+), 1399 deletions(-) create mode 100644 packages/ui/src/components/layout/NavRail.tsx diff --git a/packages/ui/src/App.css b/packages/ui/src/App.css index b9d355df..6ee5bff3 100644 --- a/packages/ui/src/App.css +++ b/packages/ui/src/App.css @@ -12,10 +12,10 @@ transition: filter 300ms; } .logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); + filter: none; } .logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); + filter: none; } @keyframes logo-spin { diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx index 05fc0acf..3a1aa39d 100644 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx @@ -182,7 +182,7 @@ export const AgentMentionAutocomplete = React.forwardRef {showTabs ? (
@@ -198,7 +198,7 @@ export const AgentMentionAutocomplete = React.forwardRef { diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a9f9aeb3..5741e882 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -176,8 +176,8 @@ export const ChatContainer: React.FC = () => { const [turnStart, setTurnStart] = React.useState(0); const turnHandleRef = React.useRef(null); const turnIdleRef = React.useRef(false); - const TURN_INIT = 20; - const TURN_BATCH = 20; + const TURN_INIT = 5; + const TURN_BATCH = 8; const userTurnIndexes = React.useMemo(() => { const indexes: number[] = []; @@ -304,7 +304,7 @@ export const ChatContainer: React.FC = () => { const hasMoreAbove = React.useMemo(() => { if (!memoryState) { - return false; + return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES; } if (memoryState.historyComplete === true) { return false; @@ -323,6 +323,13 @@ export const ChatContainer: React.FC = () => { return false; }, [memoryState, sessionMessages.length]); + + const hasHistoryMetadata = React.useMemo(() => { + if (!memoryState) { + return false; + } + return memoryState.hasMoreAbove !== undefined || memoryState.historyComplete !== undefined; + }, [memoryState]); const [isLoadingOlder, setIsLoadingOlder] = React.useState(false); React.useEffect(() => { setIsLoadingOlder(false); @@ -384,7 +391,7 @@ export const ChatContainer: React.FC = () => { } const hasSessionMessages = hasSessionMessagesEntry; - if (hasSessionMessages) { + if (hasSessionMessages && hasHistoryMetadata) { return; } @@ -410,7 +417,7 @@ export const ChatContainer: React.FC = () => { }; void load(); - }, [currentSessionId, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]); + }, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]); if (!currentSessionId && !draftOpen) { return ( diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 8afddffd..22389f94 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1820,7 +1820,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const stopIconSizeClass = isMobile ? 'h-6 w-6' : (isVSCode ? 'h-4 w-4' : 'h-5 w-5'); const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]'); - const iconButtonBaseClass = 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'; + const iconButtonBaseClass = 'flex items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0'; const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass); // Send button - respects queue mode setting @@ -1981,8 +1981,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo type="button" className={cn( footerIconButtonClass, - 'rounded-md text-muted-foreground', - 'hover:bg-interactive-hover/40 hover:text-foreground' + 'rounded-md', + 'hover:bg-interactive-hover/40' )} onPointerDownCapture={(event) => { if (event.pointerType === 'touch') { @@ -2288,7 +2288,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 'rounded-md', isExpandedInput ? 'text-primary' - : 'text-muted-foreground hover:bg-[var(--interactive-hover)]/40 hover:text-foreground' + : 'text-foreground hover:bg-[var(--interactive-hover)]/40' )} onMouseDown={(event) => { event.preventDefault(); diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 74cac807..93204349 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -920,7 +920,7 @@ const ChatMessage: React.FC = ({ displayParts.length === 0 ? null : (
-
+
{showTabs ? ( @@ -269,7 +269,7 @@ export const CommandAutocomplete = React.forwardRef { diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 3c3ad059..fa5fa84a 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -368,7 +368,7 @@ export const FileMentionAutocomplete = React.forwardRef {showTabs ? ( @@ -385,7 +385,7 @@ export const FileMentionAutocomplete = React.forwardRef { diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 1a41daf3..8d4e80f6 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -303,7 +303,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject : } {showMenu && ( -
+
{showMenu && ( -
+
@@ -1504,196 +961,19 @@ export const Header: React.FC = ({ - {/* Project tabs */} - {showProjectTabs && ( -
- - - - - Add project - -
- {/* Left fade */} - {projectTabsOverflow.left && ( -
- )} - {/* Right fade */} - {projectTabsOverflow.right && ( -
- )} -
-
- {/* Sliding indicator */} -
- {displayProjects.map((project) => { - const isActive = project.id === activeProjectId; - const isDragged = draggingProjectId === project.id; - const sessionIndicator = projectTabSessionIndicators.get(project.id); - const showProjectStreaming = sessionIndicator?.hasStreaming === true; - const showProjectUnread = !showProjectStreaming - && project.id !== activeProjectId - && sessionIndicator?.hasNeedsAttention === true; - const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; - const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; - - const statusMarker = showProjectStreaming - ? ( - - ) - : showProjectUnread - ? ( - - {Array.from({ length: 9 }, (_, i) => ( - ATTENTION_DIAMOND_INDICES.has(i) ? ( - - ) : ( - - ) - ))} - - ) - : null; - - return ( -
{ - if (el) { projectTabRefs.current.set(project.id, el); } - else { projectTabRefs.current.delete(project.id); } - }} - role="tab" - tabIndex={0} - aria-selected={isActive} - onPointerDown={(e) => handleProjectTabPointerDown(e, project.id)} - onPointerMove={handleProjectTabPointerMove} - onPointerUp={handleProjectTabPointerUp} - onPointerCancel={handleProjectTabPointerCancel} - onContextMenu={(e) => { - e.preventDefault(); - setProjectTabMenuOpen(project.id); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - if (project.id !== activeProjectId) { - setActiveProject(project.id); - } - } - }} - className={cn( - 'relative z-10 flex h-8 shrink-0 items-center gap-1 rounded-lg pr-1 text-[0.9375rem] font-medium whitespace-nowrap group', - isDragged - ? 'opacity-30 scale-[0.97]' - : 'cursor-pointer', - statusMarker ? 'pl-[9px]' : 'pl-[7px]', - isActive - ? 'text-foreground' - : 'text-muted-foreground hover:text-foreground', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background' - )} - style={{ touchAction: 'none' }} - title={project.path} - > - - {statusMarker && ( - - {statusMarker} - - )} - - {ProjectIcon && ( - - )} - {formatProjectTabLabel(project)} - setProjectTabMenuOpen(open ? project.id : null)} - > - - - - - handleOpenProjectEdit(project.id)} - className="gap-2" - > - - Edit project - - handleCloseProject(project.id)} - className="text-destructive focus:text-destructive gap-2" - > - - Close project - - - -
- ); - })} -
-
-
+ {activeProjectLabel && ( +
+ {activeProjectLabel}
)} - {!showProjectTabs && ( + {tabs.length > 0 && (
{tabs.map((tab) => renderTab(tab))}
)} - {!showProjectTabs &&
} +
{showDesktopHeaderContextUsage && stableDesktopContextUsage && ( @@ -1719,9 +999,9 @@ export const Header: React.FC = ({ type="button" aria-label="Open plan" onClick={handleOpenContextPlan} - className={cn(headerIconButtonClass, isContextPlanActive && 'bg-[var(--interactive-hover)] text-foreground')} + className={cn(desktopHeaderIconButtonClass, isContextPlanActive && 'bg-[var(--interactive-hover)]')} > - + @@ -1751,13 +1031,13 @@ export const Header: React.FC = ({ ? `Open instance, usage and MCP (current: ${currentInstanceLabel})` : 'Open services, usage and MCP'} className={cn( - headerIconButtonClass, + desktopHeaderIconButtonClass, isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' - : 'h-9 w-9' + : 'h-8 w-8' )} > - + {isDesktopApp && {currentInstanceLabel}} @@ -1994,9 +1274,9 @@ export const Header: React.FC = ({ type="button" onClick={toggleBottomTerminal} aria-label="Toggle terminal panel" - className={headerIconButtonClass} + className={desktopHeaderIconButtonClass} > - + @@ -2010,9 +1290,9 @@ export const Header: React.FC = ({ type="button" onClick={toggleRightSidebar} aria-label="Toggle right sidebar" - className={headerIconButtonClass} + className={desktopHeaderIconButtonClass} > - + @@ -2027,8 +1307,8 @@ export const Header: React.FC = ({ @@ -2098,7 +1378,7 @@ export const Header: React.FC = ({ ) : (
{githubAvatarUrl ? ( @@ -2110,7 +1390,7 @@ export const Header: React.FC = ({ referrerPolicy="no-referrer" /> ) : ( - + )}
) @@ -2128,7 +1408,7 @@ export const Header: React.FC = ({ type="button" onClick={onToggleLeftDrawer} className={cn( - headerIconButtonClass, + mobileHeaderIconButtonClass, leftDrawerOpen && 'bg-interactive-selection text-interactive-selection-foreground' )} aria-label={leftDrawerOpen ? 'Close sessions' : 'Open sessions'} @@ -2190,7 +1470,7 @@ export const Header: React.FC = ({ aria-selected={isActive} role="tab" className={cn( - headerIconButtonClass, + mobileHeaderIconButtonClass, 'relative rounded-lg', isActive && 'bg-interactive-selection text-interactive-selection-foreground' )} @@ -2242,7 +1522,7 @@ export const Header: React.FC = ({ @@ -2481,7 +1761,7 @@ export const Header: React.FC = ({ type="button" onClick={onToggleRightDrawer} className={cn( - headerIconButtonClass, + mobileHeaderIconButtonClass, 'relative', rightDrawerOpen && 'bg-interactive-selection text-interactive-selection-foreground' )} @@ -2502,30 +1782,18 @@ export const Header: React.FC = ({ ); const headerClassName = cn( - 'header-safe-area border-b border-border/50 relative z-10', + 'header-safe-area relative z-10', + isMobile && 'border-b border-border/50', isDesktopApp ? 'bg-background' : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80' ); return ( - <> -
- {isMobile ? renderMobile() : renderDesktop()} -
- {editingProject && ( - { if (!open) setEditingProject(null); }} - projectName={editingProject.name} - projectPath={editingProject.path} - initialIcon={editingProject.icon} - initialColor={editingProject.color} - onSave={handleSaveProjectEdit} - /> - )} - +
+ {isMobile ? renderMobile() : renderDesktop()} +
); }; diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index bba27b53..d0baad9a 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -1,9 +1,9 @@ import React, { useRef, useEffect } from 'react'; import { motion, useMotionValue, animate } from 'motion/react'; -import { RiSettings3Line } from '@remixicon/react'; import { Header } from './Header'; import { BottomTerminalDock } from './BottomTerminalDock'; import { Sidebar } from './Sidebar'; +import { NavRail } from './NavRail'; import { RightSidebar } from './RightSidebar'; import { RightSidebarTabs } from './RightSidebarTabs'; import { ContextPanel } from './ContextPanel'; @@ -659,25 +659,15 @@ export const MainLayout: React.FC = () => { )} aria-hidden={!mobileLeftDrawerOpen} > -
-
+
+
e.stopPropagation()}> + +
+
-
- -
@@ -720,7 +710,7 @@ export const MainLayout: React.FC = () => { )} aria-hidden={!isRightSidebarOpen} > -
+
@@ -775,6 +765,8 @@ export const MainLayout: React.FC = () => {
+ +
@@ -801,6 +793,7 @@ export const MainLayout: React.FC = () => {
+
diff --git a/packages/ui/src/components/layout/NavRail.tsx b/packages/ui/src/components/layout/NavRail.tsx new file mode 100644 index 00000000..ef1a337b --- /dev/null +++ b/packages/ui/src/components/layout/NavRail.tsx @@ -0,0 +1,690 @@ +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, +} 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 } from '@/lib/projectMeta'; +import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop'; +import { useLongPress } from '@/hooks/useLongPress'; +import { sessionEvents } from '@/lib/sessionEvents'; +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; + +/** 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, +}) => ( + + {colorVar && ( + + )} + + {children} + + +); + +/** 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 ( + + {letter} + + ); +}; + +const ProjectStatusDots: React.FC<{ + color: string; + variant?: 'streaming' | 'attention' | 'none'; + size?: 'sm' | 'md'; +}> = ({ color, variant = 'none', size = 'md' }) => ( +