Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)
## Summary Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements. ## Key Changes **Sidebar & Navigation Redesign** - Redesigned sessions sidebar layout with unified button primitives - Added activity sections with project grouping and improved session organization - Refined sidebar corners, spacing, and visual hierarchy - Removed NavRail component in favor of streamlined sidebar - Stabilized sessions bar toggle position in fullscreen mode **Performance Optimizations** - Reduced chat streaming CPU usage and storage churn - Optimized task tool polling and live timers with debouncing - Prevented chat state races and reduced background request load - Debounced draft writes and coalesced session reloads - Optimized message store updates and turn tracking **Theme & Visual System** - Added theme-aware window corners (desktop) and border radius tokens - Introduced glassmorphism effects on desktop sidebar - Added backdrop blur to UI elements **Chat Experience** - Added session-based permission auto-accept toggle in chat input - Polished permission shield UX with improved icon sizing and spacing - Fixed chat scroll-to-bottom behavior and timeline tracking - Enhanced tool output display with better path label detection - Removed duplicate draft context details in chat header - Added text selection menu to chat messages **Git Improvements** - Refreshed git history visual design with cleaner dividers - Added remote removal action in sync selector - Stabilized git polling to prevent excessive requests - Improved tool output rendering for git operations **Settings & Panels** - Fixed mobile scrolling on settings pages - Made outside-click settings close instantly - Reduced settings load churn and CPU spikes - Improved services dropdown layout and spacing - Softened panel resize handles **Desktop Integration** - Synced macOS window theme with app theme - Restored window dragging in sidebar header zones - Fixed system window corners on macOS - Improved header session metadata and action controls **Button & Component Standardization** - Unified button primitives across all components - Standardized destructive action patterns - Removed unused button variants (button-large, button-small) - Aligned context tab close hit areas --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
359879153a
commit
321cc7252a
@@ -7,10 +7,13 @@ import type {
|
||||
GitIdentitySummary,
|
||||
} from '@/lib/api/types';
|
||||
|
||||
const GIT_POLL_BASE_INTERVAL = 5000;
|
||||
const GIT_POLL_MAX_INTERVAL = 10000;
|
||||
const GIT_POLL_BASE_INTERVAL = 10000;
|
||||
const GIT_POLL_MAX_INTERVAL = 30000;
|
||||
const GIT_POLL_BUSY_BASE_INTERVAL = 15000;
|
||||
const GIT_POLL_BUSY_MAX_INTERVAL = 40000;
|
||||
const GIT_POLL_BACKOFF_STEP = 5000;
|
||||
const LOG_STALE_THRESHOLD = 10000;
|
||||
const REPO_CHECK_STALE_THRESHOLD = 60_000;
|
||||
const DIFF_PREFETCH_MAX_FILES = 25;
|
||||
const DIFF_PREFETCH_FOCUS_MAX_FILES = 40;
|
||||
const DIFF_PREFETCH_CONCURRENCY = 4;
|
||||
@@ -28,6 +31,7 @@ interface DirectoryGitState {
|
||||
log: GitLogResponse | null;
|
||||
identity: GitIdentitySummary | null;
|
||||
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>;
|
||||
lastRepoCheckAt: number;
|
||||
lastStatusFetch: number;
|
||||
lastStatusChange: number;
|
||||
lastLogFetch: number;
|
||||
@@ -48,6 +52,7 @@ interface GitStore {
|
||||
|
||||
pollIntervalId: ReturnType<typeof setTimeout> | null;
|
||||
currentPollInterval: number;
|
||||
pollingMode: 'normal' | 'busy';
|
||||
|
||||
setActiveDirectory: (directory: string | null) => void;
|
||||
getDirectoryState: (directory: string) => DirectoryGitState | null;
|
||||
@@ -67,6 +72,7 @@ interface GitStore {
|
||||
setLogMaxCount: (directory: string, maxCount: number) => void;
|
||||
|
||||
startPolling: (git: GitAPI) => void;
|
||||
setPollingMode: (mode: 'normal' | 'busy') => void;
|
||||
stopPolling: () => void;
|
||||
|
||||
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
||||
@@ -90,6 +96,7 @@ interface GitAPI {
|
||||
|
||||
const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
|
||||
const diffFetchGenerationByDirectory = new Map<string, number>();
|
||||
const inFlightStatusFetchesByDirectory = new Map<string, Promise<boolean>>();
|
||||
|
||||
const getDiffFetchGeneration = (directory: string): number =>
|
||||
diffFetchGenerationByDirectory.get(directory) ?? 0;
|
||||
@@ -117,6 +124,7 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
|
||||
log: null,
|
||||
identity: null,
|
||||
diffCache: new Map(),
|
||||
lastRepoCheckAt: 0,
|
||||
lastStatusFetch: 0,
|
||||
lastStatusChange: 0,
|
||||
lastLogFetch: 0,
|
||||
@@ -262,6 +270,20 @@ const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus |
|
||||
return changed;
|
||||
};
|
||||
|
||||
const getPollingBounds = (mode: 'normal' | 'busy') => {
|
||||
if (mode === 'busy') {
|
||||
return {
|
||||
base: GIT_POLL_BUSY_BASE_INTERVAL,
|
||||
max: GIT_POLL_BUSY_MAX_INTERVAL,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
base: GIT_POLL_BASE_INTERVAL,
|
||||
max: GIT_POLL_MAX_INTERVAL,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGitStore = create<GitStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
@@ -274,6 +296,7 @@ export const useGitStore = create<GitStore>()(
|
||||
isLoadingIdentity: false,
|
||||
pollIntervalId: null,
|
||||
currentPollInterval: GIT_POLL_BASE_INTERVAL,
|
||||
pollingMode: 'normal',
|
||||
|
||||
setActiveDirectory: (directory) => {
|
||||
const { activeDirectory, directories, recentDirectories } = get();
|
||||
@@ -304,96 +327,124 @@ export const useGitStore = create<GitStore>()(
|
||||
},
|
||||
|
||||
fetchStatus: async (directory, git, options = {}) => {
|
||||
const { silent = false } = options;
|
||||
const { directories } = get();
|
||||
let dirState = directories.get(directory);
|
||||
|
||||
if (!dirState) {
|
||||
dirState = createEmptyDirectoryState();
|
||||
const existing = inFlightStatusFetchesByDirectory.get(directory);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
set({ isLoadingStatus: true });
|
||||
}
|
||||
const fetchPromise = (async () => {
|
||||
const { silent = false } = options;
|
||||
const { directories } = get();
|
||||
let dirState = directories.get(directory);
|
||||
|
||||
let statusChanged = false;
|
||||
if (!dirState) {
|
||||
dirState = createEmptyDirectoryState();
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
set({ isLoadingStatus: true });
|
||||
}
|
||||
|
||||
let statusChanged = false;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const shouldProbeRepository =
|
||||
dirState.isGitRepo !== true ||
|
||||
now - (dirState.lastRepoCheckAt || 0) > REPO_CHECK_STALE_THRESHOLD;
|
||||
|
||||
let isRepo = dirState.isGitRepo === true;
|
||||
if (shouldProbeRepository) {
|
||||
isRepo = await git.checkIsGitRepository(directory);
|
||||
}
|
||||
|
||||
if (!isRepo) {
|
||||
const newDirectories = new Map(directories);
|
||||
newDirectories.set(directory, {
|
||||
...dirState,
|
||||
isGitRepo: false,
|
||||
status: null,
|
||||
lastRepoCheckAt: now,
|
||||
lastStatusFetch: now,
|
||||
});
|
||||
set({ directories: newDirectories, isLoadingStatus: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
const newStatus = await git.getGitStatus(directory);
|
||||
|
||||
if (hasStatusChanged(dirState.status, newStatus)) {
|
||||
statusChanged = true;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
|
||||
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
|
||||
|
||||
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
|
||||
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
|
||||
|
||||
const nextDiffCache = new Map(currentDirState.diffCache);
|
||||
|
||||
// Drop cache for removed files
|
||||
for (const oldPath of oldPaths) {
|
||||
if (!newPaths.has(oldPath)) {
|
||||
nextDiffCache.delete(oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop cache for files whose state/content changed
|
||||
for (const filePath of changedPaths) {
|
||||
nextDiffCache.delete(filePath);
|
||||
}
|
||||
|
||||
const hasFileContentChange = changedPaths.size > 0;
|
||||
if (hasFileContentChange) {
|
||||
bumpDiffFetchGeneration(directory);
|
||||
}
|
||||
|
||||
newDirectories.set(directory, {
|
||||
...currentDirState,
|
||||
isGitRepo: true,
|
||||
status: newStatus,
|
||||
diffCache: nextDiffCache,
|
||||
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
|
||||
lastStatusFetch: Date.now(),
|
||||
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
|
||||
});
|
||||
set({ directories: newDirectories });
|
||||
} else {
|
||||
|
||||
const newDirectories = new Map(get().directories);
|
||||
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, {
|
||||
...currentDirState,
|
||||
isGitRepo: true,
|
||||
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
|
||||
lastStatusFetch: Date.now(),
|
||||
lastStatusChange: currentDirState.lastStatusChange,
|
||||
});
|
||||
set({ directories: newDirectories });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch git status:', error);
|
||||
} finally {
|
||||
if (!silent) {
|
||||
set({ isLoadingStatus: false });
|
||||
}
|
||||
}
|
||||
|
||||
return statusChanged;
|
||||
})();
|
||||
|
||||
inFlightStatusFetchesByDirectory.set(directory, fetchPromise);
|
||||
|
||||
try {
|
||||
const isRepo = await git.checkIsGitRepository(directory);
|
||||
|
||||
if (!isRepo) {
|
||||
const newDirectories = new Map(directories);
|
||||
newDirectories.set(directory, {
|
||||
...dirState,
|
||||
isGitRepo: false,
|
||||
status: null,
|
||||
lastStatusFetch: Date.now(),
|
||||
});
|
||||
set({ directories: newDirectories, isLoadingStatus: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
const newStatus = await git.getGitStatus(directory);
|
||||
|
||||
if (hasStatusChanged(dirState.status, newStatus)) {
|
||||
statusChanged = true;
|
||||
const newDirectories = new Map(get().directories);
|
||||
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
|
||||
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
|
||||
|
||||
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
|
||||
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
|
||||
|
||||
const nextDiffCache = new Map(currentDirState.diffCache);
|
||||
|
||||
// Drop cache for removed files
|
||||
for (const oldPath of oldPaths) {
|
||||
if (!newPaths.has(oldPath)) {
|
||||
nextDiffCache.delete(oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop cache for files whose state/content changed
|
||||
for (const filePath of changedPaths) {
|
||||
nextDiffCache.delete(filePath);
|
||||
}
|
||||
|
||||
const hasFileContentChange = changedPaths.size > 0;
|
||||
if (hasFileContentChange) {
|
||||
bumpDiffFetchGeneration(directory);
|
||||
}
|
||||
|
||||
newDirectories.set(directory, {
|
||||
...currentDirState,
|
||||
isGitRepo: true,
|
||||
status: newStatus,
|
||||
diffCache: nextDiffCache,
|
||||
lastStatusFetch: Date.now(),
|
||||
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
|
||||
});
|
||||
set({ directories: newDirectories });
|
||||
} else {
|
||||
|
||||
const newDirectories = new Map(get().directories);
|
||||
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, {
|
||||
...currentDirState,
|
||||
isGitRepo: true,
|
||||
lastStatusFetch: Date.now(),
|
||||
lastStatusChange: currentDirState.lastStatusChange,
|
||||
});
|
||||
set({ directories: newDirectories });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch git status:', error);
|
||||
return await fetchPromise;
|
||||
} finally {
|
||||
if (!silent) {
|
||||
set({ isLoadingStatus: false });
|
||||
if (inFlightStatusFetchesByDirectory.get(directory) === fetchPromise) {
|
||||
inFlightStatusFetchesByDirectory.delete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
return statusChanged;
|
||||
},
|
||||
|
||||
fetchBranches: async (directory, git) => {
|
||||
@@ -637,6 +688,21 @@ export const useGitStore = create<GitStore>()(
|
||||
set({ directories: newDirectories });
|
||||
},
|
||||
|
||||
setPollingMode: (mode) => {
|
||||
const { pollingMode, currentPollInterval } = get();
|
||||
if (pollingMode === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = getPollingBounds(mode);
|
||||
const nextInterval = Math.min(Math.max(currentPollInterval, bounds.base), bounds.max);
|
||||
|
||||
set({
|
||||
pollingMode: mode,
|
||||
currentPollInterval: nextInterval,
|
||||
});
|
||||
},
|
||||
|
||||
startPolling: (git) => {
|
||||
const { pollIntervalId } = get();
|
||||
if (pollIntervalId) return;
|
||||
@@ -677,14 +743,15 @@ export const useGitStore = create<GitStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = getPollingBounds(get().pollingMode);
|
||||
if (anyStatusChanged) {
|
||||
// Reset to base interval on changes
|
||||
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||
set({ currentPollInterval: bounds.base });
|
||||
} else {
|
||||
// Backoff when no changes
|
||||
const newInterval = Math.min(
|
||||
currentPollInterval + GIT_POLL_BACKOFF_STEP,
|
||||
GIT_POLL_MAX_INTERVAL
|
||||
bounds.max
|
||||
);
|
||||
set({ currentPollInterval: newInterval });
|
||||
}
|
||||
@@ -699,14 +766,15 @@ export const useGitStore = create<GitStore>()(
|
||||
return timeoutId;
|
||||
};
|
||||
|
||||
set({ pollIntervalId: schedulePoll(), currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||
const bounds = getPollingBounds(get().pollingMode);
|
||||
set({ pollIntervalId: schedulePoll(), currentPollInterval: bounds.base });
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
const { pollIntervalId } = get();
|
||||
if (pollIntervalId) {
|
||||
clearTimeout(pollIntervalId);
|
||||
set({ pollIntervalId: null, currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||
set({ pollIntervalId: null, currentPollInterval: GIT_POLL_BASE_INTERVAL, pollingMode: 'normal' });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user