feat: move projects to sidebar rail and speed up session switching (#506)

* feat: move projects from header tabs to left sidebar rail

* refactor: remove variant from git generation session context

* feat: implemented drandrop action for navrails

* refactor: improve sidebar drag-and-drop smoothness and remove floating overlay

* fix: prevent mobile nav rail touch from closing drawer and opening menu

* fix: hide header tabs on desktop

* fix: prevent session flicker when switching projects

* style: soften chat panel divider borders

* style: improve icon contrast across core navigation

* fix: stabilize session selection during project switching

* style: unify sidebar surfaces and transparent section layers

* style: remove UI shadows and keep only scroll shadow

* perf: speed up project switching with cached session loading

* perf: speed up Git changes view and background refresh

* perf: make session switching lighter and less aggressive

* perf: optimized sessions list loading while project switching

* perf: smooth chat rendering and reduce interaction spikes

* fix: restore reliable load older messages visibility
This commit is contained in:
Bohdan Triapitsyn
2026-02-25 14:32:23 +02:00
committed by GitHub
parent 4c69bccf56
commit 10851bd7ac
51 changed files with 1995 additions and 1399 deletions
+74 -12
View File
@@ -45,6 +45,34 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => {
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
};
const hasSameTurnStructure = (prev: ChatMessageEntry[], next: ChatMessageEntry[]): boolean => {
if (prev === next) {
return true;
}
if (prev.length !== next.length) {
return false;
}
for (let index = 0; index < prev.length; index += 1) {
const prevMessage = prev[index];
const nextMessage = next[index];
if (prevMessage.info.id !== nextMessage.info.id) {
return false;
}
if (resolveMessageRole(prevMessage) !== resolveMessageRole(nextMessage)) {
return false;
}
if (getMessageParentId(prevMessage) !== getMessageParentId(nextMessage)) {
return false;
}
}
return true;
};
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
if (!message) return false;
if (resolveMessageRole(message) !== 'user') return false;
@@ -423,6 +451,13 @@ const MessageList: React.FC<MessageListProps> = ({
scrollToBottom,
}) => {
const { isMobile } = useDeviceInfo();
const turnStructureCacheRef = React.useRef<{
messages: ChatMessageEntry[];
turns: Turn[];
ungroupedMessages: ChatMessageEntry[];
} | null>(null);
const normalizedMessageCacheRef = React.useRef<Map<string, { source: ChatMessageEntry; normalized: ChatMessageEntry }>>(new Map());
React.useEffect(() => {
if (permissions.length === 0 && questions.length === 0) {
return;
@@ -432,6 +467,7 @@ const MessageList: React.FC<MessageListProps> = ({
const baseDisplayMessages = React.useMemo(() => {
const seenIds = new Set<string>();
const nextNormalizedCache = new Map<string, { source: ChatMessageEntry; normalized: ChatMessageEntry }>();
const normalizedMessages = messages
.filter((message) => {
const messageId = message.info?.id;
@@ -443,19 +479,30 @@ const MessageList: React.FC<MessageListProps> = ({
}
return true;
})
.map((message) => {
const filteredParts = filterSyntheticParts(message.parts);
// Optimization: If parts haven't changed, return the original message object.
// This preserves referential equality and prevents unnecessary re-renders of ChatMessage (which is memoized).
if (filteredParts === message.parts) {
return message;
.map((message, index) => {
const messageId = typeof message.info?.id === 'string' && message.info.id.length > 0
? message.info.id
: `__idx_${index}`;
const cacheKey = `${messageId}:${resolveMessageRole(message) ?? 'unknown'}`;
const cached = normalizedMessageCacheRef.current.get(cacheKey);
if (cached && cached.source === message) {
nextNormalizedCache.set(cacheKey, cached);
return cached.normalized;
}
return {
...message,
parts: filteredParts,
};
const filteredParts = filterSyntheticParts(message.parts);
const normalized = filteredParts === message.parts
? message
: {
...message,
parts: filteredParts,
};
nextNormalizedCache.set(cacheKey, { source: message, normalized });
return normalized;
});
normalizedMessageCacheRef.current = nextNormalizedCache;
const output: ChatMessageEntry[] = [];
for (let index = 0; index < normalizedMessages.length; index += 1) {
@@ -573,6 +620,14 @@ const MessageList: React.FC<MessageListProps> = ({
}, [activeRetryStatus, baseDisplayMessages]);
const { turns, ungroupedMessages } = React.useMemo(() => {
const cached = turnStructureCacheRef.current;
if (cached && hasSameTurnStructure(cached.messages, displayMessages)) {
return {
turns: cached.turns,
ungroupedMessages: cached.ungroupedMessages,
};
}
const groupedTurns = detectTurns(displayMessages);
const groupedMessageIds = new Set<string>();
@@ -584,11 +639,18 @@ const MessageList: React.FC<MessageListProps> = ({
});
const ungrouped = displayMessages.filter((message) => !groupedMessageIds.has(message.info.id));
return {
const nextValue = {
turns: groupedTurns,
ungroupedMessages: ungrouped,
};
turnStructureCacheRef.current = {
messages: displayMessages,
turns: groupedTurns,
ungroupedMessages: ungrouped,
};
return nextValue;
}, [displayMessages]);
return (