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
+238 -75
View File
@@ -5,6 +5,17 @@ import { getSafeStorage } from './utils/safeStorage';
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
export type RightSidebarTab = 'git' | 'files';
export type ContextPanelMode = 'diff' | 'file';
type ContextPanelDirectoryState = {
isOpen: boolean;
expanded: boolean;
mode: ContextPanelMode | null;
targetPath: string | null;
width: number;
touchedAt: number;
};
export type MainTabGuard = (nextTab: MainTab) => boolean;
export type EventStreamStatus =
@@ -51,6 +62,71 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
);
};
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const CONTEXT_PANEL_MIN_WIDTH = 360;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const LEFT_SIDEBAR_MIN_WIDTH = 300;
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
const normalizeDirectoryPath = (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 clampContextPanelWidth = (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 touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanelDirectoryState => {
if (prev) {
return { ...prev, touchedAt: Date.now() };
}
return {
isOpen: false,
expanded: false,
mode: null,
targetPath: null,
width: CONTEXT_PANEL_DEFAULT_WIDTH,
touchedAt: Date.now(),
};
};
const clampContextPanelRoots = (
byDirectory: Record<string, ContextPanelDirectoryState>,
maxRoots: number
): Record<string, ContextPanelDirectoryState> => {
const entries = Object.entries(byDirectory);
if (entries.length <= maxRoots) {
return byDirectory;
}
entries.sort((a, b) => (b[1]?.touchedAt ?? 0) - (a[1]?.touchedAt ?? 0));
const next: Record<string, ContextPanelDirectoryState> = {};
for (const [directory, state] of entries.slice(0, maxRoots)) {
next[directory] = state;
}
return next;
};
interface UIStore {
theme: 'light' | 'dark' | 'system';
@@ -62,7 +138,10 @@ interface UIStore {
isRightSidebarOpen: boolean;
rightSidebarWidth: number;
hasManuallyResizedRightSidebar: boolean;
rightSidebarTab: RightSidebarTab;
contextPanelByDirectory: Record<string, ContextPanelDirectoryState>;
isBottomTerminalOpen: boolean;
isBottomTerminalExpanded: boolean;
bottomTerminalHeight: number;
hasManuallyResizedBottomTerminal: boolean;
isSessionSwitcherOpen: boolean;
@@ -142,8 +221,15 @@ interface UIStore {
toggleRightSidebar: () => void;
setRightSidebarOpen: (open: boolean) => void;
setRightSidebarWidth: (width: number) => void;
setRightSidebarTab: (tab: RightSidebarTab) => void;
openContextDiff: (directory: string, filePath: string) => void;
openContextFile: (directory: string, filePath: string) => void;
closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, width: number) => void;
toggleBottomTerminal: () => void;
setBottomTerminalOpen: (open: boolean) => void;
setBottomTerminalExpanded: (expanded: boolean) => void;
setBottomTerminalHeight: (height: number) => void;
setSessionSwitcherOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
@@ -221,12 +307,15 @@ export const useUIStore = create<UIStore>()(
isMultiRunLauncherOpen: false,
multiRunLauncherPrefillPrompt: '',
isSidebarOpen: true,
sidebarWidth: 264,
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
hasManuallyResizedLeftSidebar: false,
isRightSidebarOpen: false,
rightSidebarWidth: 420,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
hasManuallyResizedRightSidebar: false,
rightSidebarTab: 'git',
contextPanelByDirectory: {},
isBottomTerminalOpen: false,
isBottomTerminalExpanded: false,
bottomTerminalHeight: 300,
hasManuallyResizedBottomTerminal: false,
isSessionSwitcherOpen: false,
@@ -303,12 +392,10 @@ export const useUIStore = create<UIStore>()(
set((state) => {
const newOpen = !state.isSidebarOpen;
if (newOpen && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
if (newOpen && !state.hasManuallyResizedLeftSidebar) {
return {
isSidebarOpen: newOpen,
sidebarWidth: proportionalWidth,
hasManuallyResizedLeftSidebar: false
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
};
}
return { isSidebarOpen: newOpen };
@@ -316,14 +403,11 @@ export const useUIStore = create<UIStore>()(
},
setSidebarOpen: (open) => {
set(() => {
if (open && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
set((state) => {
if (open && !state.hasManuallyResizedLeftSidebar) {
return {
isSidebarOpen: open,
sidebarWidth: proportionalWidth,
hasManuallyResizedLeftSidebar: false
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
};
}
return { isSidebarOpen: open };
@@ -338,12 +422,10 @@ export const useUIStore = create<UIStore>()(
set((state) => {
const newOpen = !state.isRightSidebarOpen;
if (newOpen && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
if (newOpen && !state.hasManuallyResizedRightSidebar) {
return {
isRightSidebarOpen: newOpen,
rightSidebarWidth: proportionalWidth,
hasManuallyResizedRightSidebar: false,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
};
}
return { isRightSidebarOpen: newOpen };
@@ -351,13 +433,11 @@ export const useUIStore = create<UIStore>()(
},
setRightSidebarOpen: (open) => {
set(() => {
if (open && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
set((state) => {
if (open && !state.hasManuallyResizedRightSidebar) {
return {
isRightSidebarOpen: open,
rightSidebarWidth: proportionalWidth,
hasManuallyResizedRightSidebar: false,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
};
}
return { isRightSidebarOpen: open };
@@ -368,6 +448,125 @@ export const useUIStore = create<UIStore>()(
set({ rightSidebarWidth: width, hasManuallyResizedRightSidebar: true });
},
setRightSidebarTab: (tab) => {
set({ rightSidebarTab: tab });
},
openContextDiff: (directory, filePath) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedFilePath = (filePath || '').trim();
if (!normalizedDirectory || !normalizedFilePath) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
isOpen: true,
mode: 'diff' as const,
targetPath: normalizedFilePath,
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
get().setPendingDiffFile(normalizedFilePath);
},
openContextFile: (directory, filePath) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedFilePath = (filePath || '').trim();
if (!normalizedDirectory || !normalizedFilePath) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
isOpen: true,
mode: 'file' as const,
targetPath: normalizedFilePath,
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
},
closeContextPanel: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
if (!prev || !prev.isOpen) {
return state;
}
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...touchContextPanelState(prev),
isOpen: false,
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
},
toggleContextPanelExpanded: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
expanded: !current.expanded,
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
},
setContextPanelWidth: (directory, width) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
width: clampContextPanelWidth(width),
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
},
toggleBottomTerminal: () => {
set((state) => {
const newOpen = !state.isBottomTerminalOpen;
@@ -400,6 +599,10 @@ export const useUIStore = create<UIStore>()(
});
},
setBottomTerminalExpanded: (expanded) => {
set({ isBottomTerminalExpanded: expanded });
},
setBottomTerminalHeight: (height) => {
set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true });
},
@@ -420,36 +623,7 @@ export const useUIStore = create<UIStore>()(
if (guard && !guard(tab)) {
return;
}
const state = get();
const currentTab = state.activeMainTab;
const fullscreenTabs: MainTab[] = ['files', 'diff'];
const isEnteringFullscreen = fullscreenTabs.includes(tab) && !fullscreenTabs.includes(currentTab);
const isLeavingFullscreen = !fullscreenTabs.includes(tab) && fullscreenTabs.includes(currentTab);
if (isEnteringFullscreen) {
// Save current sidebar state and close it
set({
activeMainTab: tab,
sidebarOpenBeforeFullscreenTab: state.isSidebarOpen,
isSidebarOpen: false,
});
} else if (isLeavingFullscreen) {
// Restore sidebar state if it was open before
const shouldRestore = state.sidebarOpenBeforeFullscreenTab === true;
set({
activeMainTab: tab,
sidebarOpenBeforeFullscreenTab: null,
...(shouldRestore && typeof window !== 'undefined'
? {
isSidebarOpen: true,
sidebarWidth: Math.floor(window.innerWidth * 0.2),
hasManuallyResizedLeftSidebar: false,
}
: {}),
});
} else {
set({ activeMainTab: tab });
}
set({ activeMainTab: tab });
},
setPendingDiffFile: (filePath) => {
@@ -461,21 +635,7 @@ export const useUIStore = create<UIStore>()(
if (guard && !guard('diff')) {
return;
}
const state = get();
const currentTab = state.activeMainTab;
const fullscreenTabs: MainTab[] = ['files', 'diff'];
const isEnteringFullscreen = !fullscreenTabs.includes(currentTab);
if (isEnteringFullscreen) {
set({
pendingDiffFile: filePath,
activeMainTab: 'diff',
sidebarOpenBeforeFullscreenTab: state.isSidebarOpen,
isSidebarOpen: false,
});
} else {
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
}
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
},
consumePendingDiffFile: () => {
@@ -770,14 +930,6 @@ export const useUIStore = create<UIStore>()(
set((state) => {
const updates: Partial<UIStore> = {};
if (state.isSidebarOpen && !state.hasManuallyResizedLeftSidebar) {
updates.sidebarWidth = Math.floor(window.innerWidth * 0.2);
}
if (state.isRightSidebarOpen && !state.hasManuallyResizedRightSidebar) {
updates.rightSidebarWidth = Math.floor(window.innerWidth * 0.28);
}
if (state.isBottomTerminalOpen && !state.hasManuallyResizedBottomTerminal) {
updates.bottomTerminalHeight = Math.floor(window.innerHeight * 0.32);
}
@@ -865,7 +1017,7 @@ export const useUIStore = create<UIStore>()(
{
name: 'ui-store',
storage: createJSONStorage(() => getSafeStorage()),
version: 3,
version: 4,
migrate: (persistedState, version) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState;
@@ -905,6 +1057,14 @@ export const useUIStore = create<UIStore>()(
delete state.memoryLimitActiveSession;
}
if (typeof state.rightSidebarTab !== 'string' || (state.rightSidebarTab !== 'git' && state.rightSidebarTab !== 'files')) {
state.rightSidebarTab = 'git';
}
if (!state.contextPanelByDirectory || typeof state.contextPanelByDirectory !== 'object') {
state.contextPanelByDirectory = {};
}
return state;
},
partialize: (state) => ({
@@ -913,7 +1073,10 @@ export const useUIStore = create<UIStore>()(
sidebarWidth: state.sidebarWidth,
isRightSidebarOpen: state.isRightSidebarOpen,
rightSidebarWidth: state.rightSidebarWidth,
rightSidebarTab: state.rightSidebarTab,
contextPanelByDirectory: state.contextPanelByDirectory,
isBottomTerminalOpen: state.isBottomTerminalOpen,
isBottomTerminalExpanded: state.isBottomTerminalExpanded,
bottomTerminalHeight: state.bottomTerminalHeight,
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab,