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
+10 -8
View File
@@ -25,7 +25,7 @@ interface DirectoryGitState {
branches: GitBranch | null;
log: GitLogResponse | null;
identity: GitIdentitySummary | null;
diffCache: Map<string, { original: string; modified: string; fetchedAt: number }>;
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>;
lastStatusFetch: number;
lastStatusChange: number;
lastLogFetch: number;
@@ -55,8 +55,8 @@ interface GitStore {
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean }) => Promise<void>;
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string }) => void;
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
clearDiffCache: (directory: string) => void;
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
@@ -72,6 +72,7 @@ interface GitFileDiffResponse {
original: string;
modified: string;
path: string;
isBinary?: boolean;
}
interface GitAPI {
@@ -98,10 +99,10 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
// LRU eviction helper for diff cache
const evictDiffCacheIfNeeded = (
diffCache: Map<string, { original: string; modified: string; fetchedAt: number }>,
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>,
maxEntries: number = DIFF_CACHE_MAX_ENTRIES,
maxTotalSize: number = DIFF_CACHE_MAX_TOTAL_SIZE_BYTES
): Map<string, { original: string; modified: string; fetchedAt: number }> => {
): Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }> => {
// Calculate total size
let totalSize = 0;
for (const entry of diffCache.values()) {
@@ -117,7 +118,7 @@ const evictDiffCacheIfNeeded = (
const entries = Array.from(diffCache.entries())
.sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
const newCache = new Map<string, { original: string; modified: string; fetchedAt: number }>();
const newCache = new Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>();
let newTotalSize = 0;
// Keep entries from newest to oldest until limits are reached
@@ -441,6 +442,7 @@ export const useGitStore = create<GitStore>()(
// Pre-fetch all diffs so they're ready when user opens Diff tab
void get().fetchAllDiffs(directory, git);
},
getDiff: (directory, filePath) => {
@@ -481,7 +483,7 @@ export const useGitStore = create<GitStore>()(
if (limitedFilesToFetch.length === 0) return;
let nextIndex = 0;
const results: Array<{ path: string; diff: { original: string; modified: string } }> = [];
const results: Array<{ path: string; diff: { original: string; modified: string; isBinary?: boolean } }> = [];
const takeNext = () => {
const current = nextIndex;
@@ -497,7 +499,7 @@ export const useGitStore = create<GitStore>()(
const response = await Promise.race([fetchPromise, timeoutPromise]);
return {
path: filePath,
diff: { original: response.original ?? '', modified: response.modified ?? '' },
diff: { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary },
};
};
+23 -1
View File
@@ -23,6 +23,7 @@ interface ProjectsStore {
setActiveProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
renameProject: (id: string, label: string) => void;
updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null }) => void;
reorderProjects: (fromIndex: number, toIndex: number) => void;
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
@@ -72,7 +73,8 @@ const deriveProjectLabel = (path: string): string => {
return 'Root';
}
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1] || normalized;
const raw = segments[segments.length - 1] || normalized;
return raw.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
};
const createProjectId = (): string => {
@@ -377,6 +379,26 @@ export const useProjectsStore = create<ProjectsStore>()(
persistProjects(nextProjects, activeProjectId);
},
updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null }) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
const nextProjects = projects.map((project) => {
if (project.id !== id) return project;
const updated = { ...project };
if (meta.label !== undefined) {
const trimmed = meta.label.trim();
if (trimmed) updated.label = trimmed;
}
if (meta.icon !== undefined) updated.icon = meta.icon;
if (meta.color !== undefined) updated.color = meta.color;
return updated;
});
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
},
reorderProjects: (fromIndex: number, toIndex: number) => {
if (vscodeWorkspace) {
return;
+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,