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:
committed by
GitHub
parent
12606b9e53
commit
47c943b487
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user