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:
Bohdan Triapitsyn
2026-03-20 01:01:03 +02:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 359879153a
commit 321cc7252a
222 changed files with 8575 additions and 5456 deletions
+186 -84
View File
@@ -72,7 +72,7 @@ const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
const PIN_THRESHOLD_RATIO = 0.10;
const SORTED_PIN_THRESHOLD_PX = 24;
const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150;
export const useChatScrollManager = ({
currentSessionId,
@@ -81,7 +81,6 @@ export const useChatScrollManager = ({
updateViewportAnchor,
isSyncing,
isMobile,
chatRenderMode = 'live',
onActiveTurnChange,
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
@@ -97,11 +96,8 @@ export const useChatScrollManager = ({
}, []);
const getAutoFollowThreshold = React.useCallback(() => {
if (chatRenderMode === 'sorted') {
return SORTED_PIN_THRESHOLD_PX;
}
return getPinThreshold();
}, [chatRenderMode, getPinThreshold]);
}, [getPinThreshold]);
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [isPinned, setIsPinned] = React.useState(true);
@@ -113,6 +109,11 @@ export const useChatScrollManager = ({
const isPinnedRef = React.useRef(true);
const lastScrollTopRef = React.useRef<number>(0);
const touchLastYRef = React.useRef<number | null>(null);
const pinnedSyncRafRef = React.useRef<number | null>(null);
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const lastViewportAnchorWriteAtRef = React.useRef<number>(0);
const markProgrammaticScroll = React.useCallback(() => {
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
@@ -169,6 +170,79 @@ export const useChatScrollManager = ({
setShowScrollButton(!isNearBottom(distanceFromBottom, getPinThreshold()));
}, [getDistanceFromBottom, getPinThreshold]);
const syncPinnedStateAndIndicators = React.useCallback(() => {
pinnedSyncRafRef.current = null;
updateScrollButtonVisibility();
if (!isPinnedRef.current) {
return;
}
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
}
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
const schedulePinnedStateAndIndicators = React.useCallback(() => {
if (typeof window === 'undefined') {
syncPinnedStateAndIndicators();
return;
}
if (pinnedSyncRafRef.current !== null) {
return;
}
pinnedSyncRafRef.current = window.requestAnimationFrame(() => {
syncPinnedStateAndIndicators();
});
}, [syncPinnedStateAndIndicators]);
const flushViewportAnchor = React.useCallback(() => {
if (viewportAnchorTimerRef.current !== null) {
clearTimeout(viewportAnchorTimerRef.current);
viewportAnchorTimerRef.current = null;
}
const pending = pendingViewportAnchorRef.current;
if (!pending) {
return;
}
const lastPersisted = lastViewportAnchorRef.current;
if (lastPersisted && lastPersisted.sessionId === pending.sessionId && lastPersisted.anchor === pending.anchor) {
pendingViewportAnchorRef.current = null;
return;
}
updateViewportAnchor(pending.sessionId, pending.anchor);
lastViewportAnchorRef.current = pending;
pendingViewportAnchorRef.current = null;
lastViewportAnchorWriteAtRef.current = Date.now();
}, [updateViewportAnchor]);
const queueViewportAnchor = React.useCallback((sessionId: string, anchor: number) => {
const lastPersisted = lastViewportAnchorRef.current;
if (lastPersisted && lastPersisted.sessionId === sessionId && lastPersisted.anchor === anchor) {
return;
}
pendingViewportAnchorRef.current = { sessionId, anchor };
const now = Date.now();
const elapsed = now - lastViewportAnchorWriteAtRef.current;
if (elapsed >= VIEWPORT_ANCHOR_MIN_UPDATE_MS) {
flushViewportAnchor();
return;
}
if (viewportAnchorTimerRef.current !== null) {
return;
}
viewportAnchorTimerRef.current = setTimeout(() => {
viewportAnchorTimerRef.current = null;
flushViewportAnchor();
}, VIEWPORT_ANCHOR_MIN_UPDATE_MS - elapsed);
}, [flushViewportAnchor]);
const scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => {
const container = scrollRef.current;
if (!container) return;
@@ -191,8 +265,8 @@ export const useChatScrollManager = ({
const releasePinnedScroll = React.useCallback(() => {
scrollEngine.cancelFollow();
updatePinnedState(false);
updateScrollButtonVisibility();
}, [scrollEngine, updatePinnedState, updateScrollButtonVisibility]);
schedulePinnedStateAndIndicators();
}, [schedulePinnedStateAndIndicators, scrollEngine, updatePinnedState]);
const handleScrollEvent = React.useCallback((event?: Event) => {
const container = scrollRef.current;
@@ -205,7 +279,7 @@ export const useChatScrollManager = ({
const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS;
scrollEngine.handleScroll();
updateScrollButtonVisibility();
schedulePinnedStateAndIndicators();
// Handle pin/unpin logic
const currentScrollTop = container.scrollTop;
@@ -221,7 +295,7 @@ export const useChatScrollManager = ({
// Re-pin at bottom should always work (even momentum scroll)
if (!isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (!scrollingUp && distanceFromBottom <= getPinThreshold()) {
if (distanceFromBottom <= getPinThreshold()) {
updatePinnedState(true);
}
}
@@ -231,16 +305,16 @@ export const useChatScrollManager = ({
const { scrollTop, scrollHeight, clientHeight } = container;
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
const estimatedIndex = Math.floor(position * sessionMessages.length);
updateViewportAnchor(currentSessionId, estimatedIndex);
queueViewportAnchor(currentSessionId, estimatedIndex);
}, [
currentSessionId,
getDistanceFromBottom,
getPinThreshold,
queueViewportAnchor,
schedulePinnedStateAndIndicators,
scrollEngine,
sessionMessages.length,
updatePinnedState,
updateScrollButtonVisibility,
updateViewportAnchor,
]);
const handleWheelIntent = React.useCallback((event: WheelEvent) => {
@@ -345,6 +419,8 @@ export const useChatScrollManager = ({
lastSessionIdRef.current = currentSessionId;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
flushViewportAnchor();
pendingViewportAnchorRef.current = null;
// Always start pinned at bottom on session switch
updatePinnedState(true);
@@ -355,22 +431,15 @@ export const useChatScrollManager = ({
markProgrammaticScroll();
scrollToBottomInternal({ instant: true });
}
}, [currentSessionId, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]);
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]);
// Maintain pin-to-bottom when content changes
React.useEffect(() => {
if (!isPinnedRef.current) return;
if (isSyncing) return;
const container = scrollRef.current;
if (!container) return;
// When pinned and content grows, follow bottom with fast smooth scroll
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
if (isSyncing) {
return;
}
}, [getAutoFollowThreshold, getDistanceFromBottom, isSyncing, scrollPinnedToBottom, sessionMessages]);
schedulePinnedStateAndIndicators();
}, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length]);
// Use ResizeObserver to detect content changes and maintain pin
React.useEffect(() => {
@@ -378,27 +447,14 @@ export const useChatScrollManager = ({
if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
updateScrollButtonVisibility();
// Maintain pin when content grows - fast smooth follow
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
}
}
schedulePinnedStateAndIndicators();
});
observer.observe(container);
// Also observe children for content changes
const childObserver = new MutationObserver(() => {
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
}
}
schedulePinnedStateAndIndicators();
});
childObserver.observe(container, { childList: true, subtree: true });
@@ -407,36 +463,28 @@ export const useChatScrollManager = ({
observer.disconnect();
childObserver.disconnect();
};
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
}, [schedulePinnedStateAndIndicators]);
React.useEffect(() => {
if (typeof window === 'undefined') {
updateScrollButtonVisibility();
schedulePinnedStateAndIndicators();
return;
}
const rafId = window.requestAnimationFrame(() => {
updateScrollButtonVisibility();
schedulePinnedStateAndIndicators();
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [currentSessionId, sessionMessages.length, updateScrollButtonVisibility]);
}, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
const handleMessageContentChange = React.useCallback(() => {
updateScrollButtonVisibility();
// Maintain pin when content changes - fast smooth follow
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
}
}
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
schedulePinnedStateAndIndicators();
}, [schedulePinnedStateAndIndicators]);
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const existing = animationHandlersRef.current.get(messageId);
@@ -446,27 +494,15 @@ export const useChatScrollManager = ({
const handlers: AnimationHandlers = {
onChunk: () => {
updateScrollButtonVisibility();
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
}
}
schedulePinnedStateAndIndicators();
},
onComplete: () => {
updateScrollButtonVisibility();
schedulePinnedStateAndIndicators();
},
onStreamingCandidate: () => {},
onAnimationStart: () => {},
onAnimatedHeightChange: () => {
updateScrollButtonVisibility();
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
}
}
schedulePinnedStateAndIndicators();
},
onReservationCancelled: () => {},
onReasoningBlock: () => {},
@@ -474,7 +510,22 @@ export const useChatScrollManager = ({
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
}, [schedulePinnedStateAndIndicators]);
React.useEffect(() => {
return () => {
if (pinnedSyncRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(pinnedSyncRafRef.current);
pinnedSyncRafRef.current = null;
}
flushViewportAnchor();
if (viewportAnchorTimerRef.current !== null) {
clearTimeout(viewportAnchorTimerRef.current);
viewportAnchorTimerRef.current = null;
}
};
}, [flushViewportAnchor]);
React.useEffect(() => {
if (!onActiveTurnChange) {
@@ -495,23 +546,74 @@ export const useChatScrollManager = ({
spy.setContainer(container);
const registerTurns = () => {
spy.clear();
const turnNodes = container.querySelectorAll<HTMLElement>('[data-turn-id]');
turnNodes.forEach((node) => {
const turnId = node.dataset.turnId;
if (!turnId) {
return;
}
spy.register(node, turnId);
});
spy.markDirty();
const elementByTurnId = new Map<string, HTMLElement>();
const registerTurnNode = (node: HTMLElement): boolean => {
const turnId = node.dataset.turnId;
if (!turnId) {
return false;
}
elementByTurnId.set(turnId, node);
spy.register(node, turnId);
return true;
};
registerTurns();
const unregisterTurnNode = (node: HTMLElement): boolean => {
const turnId = node.dataset.turnId;
if (!turnId) {
return false;
}
if (elementByTurnId.get(turnId) !== node) {
return false;
}
elementByTurnId.delete(turnId);
spy.unregister(turnId);
return true;
};
const mutationObserver = new MutationObserver(() => {
registerTurns();
const collectTurnNodes = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) {
return [];
}
const collected: HTMLElement[] = [];
if (node.matches('[data-turn-id]')) {
collected.push(node);
}
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((turnNode) => {
collected.push(turnNode);
});
return collected;
};
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((node) => {
registerTurnNode(node);
});
spy.markDirty();
const mutationObserver = new MutationObserver((records) => {
let changed = false;
records.forEach((record) => {
record.removedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (unregisterTurnNode(turnNode)) {
changed = true;
}
});
});
record.addedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (registerTurnNode(turnNode)) {
changed = true;
}
});
});
});
if (changed) {
spy.markDirty();
}
});
mutationObserver.observe(container, { subtree: true, childList: true });