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
@@ -24,6 +24,12 @@ interface SelectionPayload {
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
const BLOCK_TAGS = new Set([
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
'section', 'table', 'ul',
]);
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
@@ -103,6 +109,11 @@ const renderBlockMarkdownNode = (node: Node): string => {
return `\`\`\`${language}\n${code}\n\`\`\``;
}
if (tag === 'code') {
const code = normalizeLineBreaks(element.textContent || '').trim();
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
}
if (tag === 'ul') return renderListMarkdown(element, false);
if (tag === 'ol') return renderListMarkdown(element, true);
@@ -137,8 +148,34 @@ const renderBlockMarkdownNode = (node: Node): string => {
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
};
const isInlineSelectionFragment = (fragment: DocumentFragment): boolean => {
return Array.from(fragment.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) {
return true;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return true;
}
const element = node as HTMLElement;
return !BLOCK_TAGS.has(element.tagName.toLowerCase());
});
};
const rangeToMarkdown = (range: Range, plainText: string): string => {
const fragment = range.cloneContents();
if (isInlineSelectionFragment(fragment)) {
const inlineMarkdown = trimSelectionValue(
Array.from(fragment.childNodes)
.map((node) => renderInlineMarkdownNode(node))
.join('')
);
if (inlineMarkdown) {
return inlineMarkdown;
}
}
const markdown = Array.from(fragment.childNodes)
.map((node) => renderBlockMarkdownNode(node))
.filter((value) => value.length > 0)
@@ -436,6 +473,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
)}
style={{
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
}}
>
<button
@@ -507,6 +546,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
style={{
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)',
}}
>
<button
onClick={handleAddToChat}
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useUIStore } from '@/stores/useUIStore';
import { useDurationTickerNow } from './useDurationTicker';
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
@@ -65,19 +66,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()):
};
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (!active) {
return;
}
const timer = window.setInterval(() => {
setNow(Date.now());
}, 100);
return () => window.clearInterval(timer);
}, [active]);
const now = useDurationTickerNow(active, 250);
return <>{formatDuration(start, end, now)}</>;
};
@@ -13,6 +13,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionActivity } from '@/hooks/useSessionActivity';
import { opencodeClient } from '@/lib/opencode/client';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Text } from '@/components/ui/text';
@@ -32,6 +33,7 @@ import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
import { MinDurationShineText } from './MinDurationShineText';
import { ToolRevealOnMount } from './ToolRevealOnMount';
import { getToolIcon } from './toolPresentation';
import { useDurationTickerNow } from './useDurationTicker';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -144,6 +146,14 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
};
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
const TASK_TOOL_POLL_FAST_MS = 1200;
const TASK_TOOL_POLL_IDLE_MS = 3200;
const TASK_TOOL_POLL_HIDDEN_MS = 6000;
const TASK_TOOL_INITIAL_FETCH_LIMIT = 500;
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
const duration = Math.min(Math.max(0, (end ?? now) - start), MAX_DURATION_MS);
@@ -154,17 +164,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()) =
};
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (!active) {
return;
}
const timer = window.setInterval(() => {
setNow(Date.now());
}, 100);
return () => window.clearInterval(timer);
}, [active]);
const now = useDurationTickerNow(active, 250);
return <>{formatDuration(start, end, now)}</>;
};
@@ -642,6 +642,41 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[])
return entries;
};
const buildTaskSessionMessagesSignature = (messages: SessionMessageWithParts[]): string => {
if (!Array.isArray(messages) || messages.length === 0) {
return '0';
}
const lastMessage = messages[messages.length - 1];
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
const lastMessageUpdated =
typeof lastMessage?.info?.time?.completed === 'number'
? lastMessage.info.time.completed
: typeof lastMessage?.info?.time?.created === 'number'
? lastMessage.info.time.created
: 0;
const lastParts = Array.isArray(lastMessage?.parts) ? lastMessage.parts : [];
const lastPart = lastParts[lastParts.length - 1] as Record<string, unknown> | undefined;
const tailType = typeof lastPart?.type === 'string' ? lastPart.type : '';
const tailId = typeof lastPart?.id === 'string' ? lastPart.id : '';
const tailTextLength = (() => {
const textCandidate = lastPart?.text;
if (typeof textCandidate === 'string') {
return textCandidate.length;
}
const stateCandidate = lastPart?.state;
if (stateCandidate && typeof stateCandidate === 'object') {
const stateStatus = (stateCandidate as Record<string, unknown>).status;
if (typeof stateStatus === 'string') {
return stateStatus.length;
}
}
return 0;
})();
return `${messages.length}:${lastMessageId}:${lastMessageUpdated}:${lastParts.length}:${tailType}:${tailId}:${tailTextLength}`;
};
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
const title = entry.state?.title;
if (typeof title === 'string' && title.trim().length > 0) {
@@ -687,7 +722,16 @@ const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
return false;
}
return trimmed.includes('/') || trimmed.includes('\\');
if (trimmed.includes('/') || trimmed.includes('\\')) {
return true;
}
const baseName = trimmed.split(/[\\/]/).pop() || trimmed;
if (baseName.startsWith('.') || baseName.includes('.')) {
return true;
}
return /^[A-Za-z0-9_-]+$/.test(baseName);
};
const stripTaskMetadataFromOutput = (output: string): string => {
@@ -1692,6 +1736,69 @@ const ToolPart: React.FC<ToolPartProps> = ({
return false;
}, [childSessionMessages, isTaskTool, taskSessionId]);
const childSessionActivity = useSessionActivity(taskSessionId);
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
const taskPollNoChangeCountRef = React.useRef(0);
const taskPollLastSignatureRef = React.useRef<string>('');
React.useEffect(() => {
setTaskChildSeenActive(false);
setTaskChildPollingStopped(false);
taskPollNoChangeCountRef.current = 0;
taskPollLastSignatureRef.current = '';
}, [taskSessionId]);
React.useEffect(() => {
if (!isTaskTool || !taskSessionId) {
return;
}
const childSessionIsActive =
childSessionActivity.phase === 'busy'
|| childSessionActivity.phase === 'retry'
|| childSessionHasInFlightTools
|| (!isFinalized && activeLatched);
if (childSessionIsActive) {
if (!taskChildSeenActive) {
setTaskChildSeenActive(true);
}
if (taskChildPollingStopped) {
setTaskChildPollingStopped(false);
}
return;
}
if (!taskChildSeenActive || taskChildPollingStopped || childSessionTaskSummaryEntries.length === 0) {
return;
}
if (typeof window === 'undefined') {
setTaskChildPollingStopped(true);
return;
}
const timer = window.setTimeout(() => {
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
activeLatched,
isFinalized,
isTaskTool,
taskChildPollingStopped,
taskChildSeenActive,
taskSessionId,
]);
React.useEffect(() => {
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
setLocalFinalizedAt(undefined);
@@ -1730,7 +1837,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
return;
}
const shouldPoll = isActive || childSessionHasInFlightTools || childSessionTaskSummaryEntries.length === 0;
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
const shouldPoll =
!taskChildPollingStopped
&& (isActive || childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll;
if (!shouldFetchSnapshot) {
return;
@@ -1739,33 +1849,83 @@ const ToolPart: React.FC<ToolPartProps> = ({
let cancelled = false;
let pollTimer: number | undefined;
const fetchSessionMessages = async () => {
const isVisible = () => {
if (typeof document === 'undefined') {
return true;
}
return document.visibilityState === 'visible';
};
const resolveFetchLimit = (isInitialFetch: boolean) => {
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return TASK_TOOL_INITIAL_FETCH_LIMIT;
}
if (isActive || childSessionHasInFlightTools || childSessionActive) {
return TASK_TOOL_ACTIVE_FETCH_LIMIT;
}
return TASK_TOOL_IDLE_FETCH_LIMIT;
};
const resolvePollDelay = () => {
if (!isVisible()) {
return TASK_TOOL_POLL_HIDDEN_MS;
}
if (taskPollNoChangeCountRef.current >= TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS) {
return TASK_TOOL_POLL_IDLE_MS;
}
return TASK_TOOL_POLL_FAST_MS;
};
const scheduleNextPoll = () => {
if (!shouldPoll || typeof window === 'undefined' || cancelled) {
return;
}
pollTimer = window.setTimeout(() => {
pollTimer = undefined;
void fetchSessionMessages(false);
}, resolvePollDelay());
};
const fetchSessionMessages = async (isInitialFetch: boolean) => {
try {
const messages = await opencodeClient.getSessionMessages(taskSessionId, 500);
const messages = await opencodeClient.getSessionMessages(taskSessionId, resolveFetchLimit(isInitialFetch));
if (cancelled || !Array.isArray(messages) || messages.length === 0) {
return;
}
const nextSignature = buildTaskSessionMessagesSignature(messages as SessionMessageWithParts[]);
if (nextSignature === taskPollLastSignatureRef.current) {
taskPollNoChangeCountRef.current += 1;
return;
}
taskPollLastSignatureRef.current = nextSignature;
taskPollNoChangeCountRef.current = 0;
useSessionStore.getState().syncMessages(taskSessionId, messages);
} catch {
// Ignore transient subagent fetch errors.
} finally {
scheduleNextPoll();
}
};
void fetchSessionMessages();
if (shouldPoll && typeof window !== 'undefined') {
pollTimer = window.setInterval(() => {
void fetchSessionMessages();
}, 1200);
}
void fetchSessionMessages(true);
return () => {
cancelled = true;
if (typeof pollTimer === 'number') {
window.clearInterval(pollTimer);
window.clearTimeout(pollTimer);
}
};
}, [childSessionHasInFlightTools, childSessionTaskSummaryEntries.length, isActive, isTaskTool, taskSessionId]);
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
isActive,
isTaskTool,
taskChildPollingStopped,
taskSessionId,
]);
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
@@ -25,7 +25,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
return;
}
target.style.opacity = '';
target.style.filter = '';
// target.style.filter = '';
target.style.transform = '';
target.style.maskImage = '';
target.style.webkitMaskImage = '';
@@ -61,7 +61,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
CSS.supports('-webkit-mask-image', 'linear-gradient(to right, black, transparent)'));
el.style.opacity = '0';
el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
// el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
el.style.transform = wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)';
if (maskSupported) {
@@ -84,16 +84,15 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
const keyframes: Keyframe[] = maskSupported
? [
{ opacity: 0, filter: 'blur(3px)', transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
{ opacity: 1, filter: 'blur(0px)', transform: 'translateX(0)', maskPosition: '0% 0%' },
{ opacity: 0, transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
{ opacity: 1, transform: 'translateX(0)', maskPosition: '0% 0%' },
]
: [
{
opacity: 0,
filter: wipe ? 'blur(3px)' : 'blur(2px)',
transform: wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)',
},
{ opacity: 1, filter: 'blur(0px)', transform: wipe ? 'translateX(0)' : 'translateY(0)' },
{ opacity: 1, transform: wipe ? 'translateX(0)' : 'translateY(0)' },
];
animation = node.animate(keyframes, {
@@ -0,0 +1,70 @@
import React from 'react';
type Subscriber = (now: number) => void;
type TickerChannel = {
subscribers: Set<Subscriber>;
timerId: number | null;
};
const tickerChannels = new Map<number, TickerChannel>();
const getTickerChannel = (intervalMs: number): TickerChannel => {
const existing = tickerChannels.get(intervalMs);
if (existing) {
return existing;
}
const created: TickerChannel = {
subscribers: new Set<Subscriber>(),
timerId: null,
};
tickerChannels.set(intervalMs, created);
return created;
};
const subscribeToTicker = (intervalMs: number, subscriber: Subscriber): (() => void) => {
const channel = getTickerChannel(intervalMs);
channel.subscribers.add(subscriber);
subscriber(Date.now());
if (channel.timerId === null && typeof window !== 'undefined') {
channel.timerId = window.setInterval(() => {
const now = Date.now();
channel.subscribers.forEach((listener) => {
listener(now);
});
}, intervalMs);
}
return () => {
const tracked = tickerChannels.get(intervalMs);
if (!tracked) {
return;
}
tracked.subscribers.delete(subscriber);
if (tracked.subscribers.size > 0) {
return;
}
if (tracked.timerId !== null && typeof window !== 'undefined') {
window.clearInterval(tracked.timerId);
}
tickerChannels.delete(intervalMs);
};
};
export const useDurationTickerNow = (active: boolean, intervalMs: number = 250): number => {
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (!active) {
return;
}
return subscribeToTicker(intervalMs, setNow);
}, [active, intervalMs]);
return now;
};