feat(ui): redesign workspace shell with context panel, tabbed sidebars, and faster diff UX (#433)

* feat: tabbed right sidebar, context panel, floating diff comments

* fix: auto-close left sidebar when context panel opens

- Increase default context panel width from 520 to 600 pixels
- Increase sidebar minimum width from 200 to 300 pixels
- Replace collapsible component with custom button in diff view

* refactoring: rework sidebars, tabs, and file tree layout

- Rewrite AnimatedTabs as segment-style with sliding indicator
- Upgrade SidebarFilesTree to match FilesView features (context menus,
  git status, file icons, CRUD dialogs, fuzzy search ranking)
- Restructure FilesView header: tabs row + actions row, remove breadcrumbs
- Show relative path in context panel header, track active tab
- Allow left sidebar to stay open alongside context panel
- Hide diff/files tabs from header on desktop (mobile-only)
- Move chevron after group name in session sidebar
- Compact tab heights in right sidebar and git view
- Size PreviewToggleButton to match other action buttons
- Remove directory loading spinner from folder icons

* feat: add project icon and color customization

- Enable users to assign custom icons to projects
- Allow users to choose accent colors for projects
- Stabilize repo status UI during project switching

* feat: add scroll fade indicators to editor tabs

* style: reduce spacing and icon sizes in header

* style: adjust tab component padding from uniform to vertical-horizontal

* feat: Add session state indicators to project tabs

* feat: Enhance session status handling and improve UI responsiveness

* fix: preserve upstream tracking on branch rename

* fix: improve initial remote selection for pull requests

- Uses saved remote name from previous session when available
- Selects remote based on tracking branch when possible
- Falls back to origin or first available remote

* perf(diff): faster highlight, stable stacked scroll

- split/unified Pierre worker pools; prefer shiki-wasm
- align diff CSS line-height; disable scroll anchoring; drop WebKit compositing hacks
- harden stacked pin/align (cancel on user scroll/input); prevent overscroll
- make overlay scrollbar MutationObserver optional; disable for diff container

* feat: handle binary files in diff view

* fix: adjust project tabs layout and drag regions

* style: update drag overlay visual styling

* feat: enable number keys to switch projects in the sidebar

* fix: recognize octet-stream as text-based MIME type

* feat: add keyboard navigation to context panel

* feat: add session pinning to sidebar

- Pin important sessions to keep them at the top
- Pinned sessions persist across browser sessions

* refactor: move context usage display from chat input to header
This commit is contained in:
Bohdan Triapitsyn
2026-02-16 14:15:19 +02:00
committed by GitHub
parent 12606b9e53
commit 47c943b487
42 changed files with 4874 additions and 1163 deletions
@@ -0,0 +1,229 @@
import React from 'react';
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { DiffView, FilesView } from '@/components/views';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useUIStore } from '@/stores/useUIStore';
const CONTEXT_PANEL_MIN_WIDTH = 360;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const normalizeDirectoryKey = (value: string): string => {
if (!value) return '';
const raw = value.replace(/\\/g, '/');
const hadUncPrefix = raw.startsWith('//');
let normalized = raw.replace(/\/+$/g, '');
normalized = normalized.replace(/\/+/g, '/');
if (hadUncPrefix && !normalized.startsWith('//')) {
normalized = `/${normalized}`;
}
if (normalized === '') {
return raw.startsWith('/') ? '/' : '';
}
return normalized;
};
const clampWidth = (width: number): number => {
if (!Number.isFinite(width)) {
return CONTEXT_PANEL_DEFAULT_WIDTH;
}
return Math.min(CONTEXT_PANEL_MAX_WIDTH, Math.max(CONTEXT_PANEL_MIN_WIDTH, Math.round(width)));
};
const getRelativePathLabel = (filePath: string | null, directory: string): string => {
if (!filePath) {
return '';
}
const normalizedFile = filePath.replace(/\\/g, '/');
const normalizedDir = directory.replace(/\\/g, '/').replace(/\/+$/, '');
if (normalizedDir && normalizedFile.startsWith(normalizedDir + '/')) {
return normalizedFile.slice(normalizedDir.length + 1);
}
return normalizedFile;
};
export const ContextPanel: React.FC = () => {
const effectiveDirectory = useEffectiveDirectory() ?? '';
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded);
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
const isOpen = Boolean(panelState?.isOpen && panelState?.mode);
const isExpanded = Boolean(isOpen && panelState?.expanded);
const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(width);
const panelRef = React.useRef<HTMLElement | null>(null);
const wasOpenRef = React.useRef(false);
React.useEffect(() => {
if (!isOpen || wasOpenRef.current) {
wasOpenRef.current = isOpen;
return;
}
const frame = window.requestAnimationFrame(() => {
panelRef.current?.focus({ preventScroll: true });
});
wasOpenRef.current = true;
return () => window.cancelAnimationFrame(frame);
}, [isOpen]);
React.useEffect(() => {
if (!isResizing || !directoryKey) {
return;
}
const handlePointerMove = (event: PointerEvent) => {
const delta = startXRef.current - event.clientX;
setContextPanelWidth(directoryKey, startWidthRef.current + delta);
};
const handlePointerUp = () => {
setIsResizing(false);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp, { once: true });
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
}, [directoryKey, isResizing, setContextPanelWidth]);
const handleResizeStart = React.useCallback((event: React.PointerEvent) => {
if (!isOpen || isExpanded || !directoryKey) {
return;
}
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = width;
event.preventDefault();
}, [directoryKey, isExpanded, isOpen, width]);
const handleClose = React.useCallback(() => {
if (!directoryKey) {
return;
}
closeContextPanel(directoryKey);
}, [closeContextPanel, directoryKey]);
const handleToggleExpanded = React.useCallback(() => {
if (!directoryKey) {
return;
}
toggleContextPanelExpanded(directoryKey);
}, [directoryKey, toggleContextPanelExpanded]);
const handlePanelKeyDownCapture = React.useCallback((event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Escape') {
return;
}
event.preventDefault();
event.stopPropagation();
handleClose();
}, [handleClose]);
const activeFilePath = useFilesViewTabsStore((state) => (directoryKey ? (state.byRoot[directoryKey]?.selectedPath ?? null) : null));
const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : 'Panel';
const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : (panelState?.targetPath ?? null);
const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory);
const content = panelState?.mode === 'diff'
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate />
: panelState?.mode === 'file'
? <FilesView mode="editor-only" />
: null;
const header = (
<header className="flex h-10 items-center gap-2 border-b border-border/40 px-2.5">
<div className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
<span>{panelTitle}</span>
{pathLabel ? <span className="ml-2 text-muted-foreground">{pathLabel}</span> : null}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleToggleExpanded}
className="h-6 w-6 p-0"
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
>
{isExpanded ? <RiFullscreenExitLine className="h-3.5 w-3.5" /> : <RiFullscreenLine className="h-3.5 w-3.5" />}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
className="h-6 w-6 p-0"
title="Close panel"
aria-label="Close panel"
>
<RiCloseLine className="h-3.5 w-3.5" />
</Button>
</header>
);
if (!isOpen) {
return null;
}
return (
<aside
ref={panelRef}
data-context-panel="true"
tabIndex={-1}
className={cn(
'flex min-h-0 flex-col overflow-hidden border-l border-border bg-background',
isExpanded
? 'absolute inset-0 z-20 min-w-0'
: 'relative h-full flex-shrink-0',
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
)}
onKeyDownCapture={handlePanelKeyDownCapture}
style={isExpanded
? undefined
: {
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
}}
>
{!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'
)}
onPointerDown={handleResizeStart}
role="separator"
aria-orientation="vertical"
aria-label="Resize context panel"
/>
)}
{header}
<div className="min-h-0 flex-1 overflow-hidden">{content}</div>
</aside>
);
};