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
@@ -182,7 +182,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
return (
<div
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
>
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
@@ -198,7 +198,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
className={cn(
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
activeTab === tab.id
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
: 'text-muted-foreground hover:bg-interactive-hover/50'
)}
onPointerDown={(event) => {
@@ -176,8 +176,8 @@ export const ChatContainer: React.FC = () => {
const [turnStart, setTurnStart] = React.useState(0);
const turnHandleRef = React.useRef<number | null>(null);
const turnIdleRef = React.useRef(false);
const TURN_INIT = 20;
const TURN_BATCH = 20;
const TURN_INIT = 5;
const TURN_BATCH = 8;
const userTurnIndexes = React.useMemo(() => {
const indexes: number[] = [];
@@ -304,7 +304,7 @@ export const ChatContainer: React.FC = () => {
const hasMoreAbove = React.useMemo(() => {
if (!memoryState) {
return false;
return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES;
}
if (memoryState.historyComplete === true) {
return false;
@@ -323,6 +323,13 @@ export const ChatContainer: React.FC = () => {
return false;
}, [memoryState, sessionMessages.length]);
const hasHistoryMetadata = React.useMemo(() => {
if (!memoryState) {
return false;
}
return memoryState.hasMoreAbove !== undefined || memoryState.historyComplete !== undefined;
}, [memoryState]);
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
React.useEffect(() => {
setIsLoadingOlder(false);
@@ -384,7 +391,7 @@ export const ChatContainer: React.FC = () => {
}
const hasSessionMessages = hasSessionMessagesEntry;
if (hasSessionMessages) {
if (hasSessionMessages && hasHistoryMetadata) {
return;
}
@@ -410,7 +417,7 @@ export const ChatContainer: React.FC = () => {
};
void load();
}, [currentSessionId, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
}, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
if (!currentSessionId && !draftOpen) {
return (
@@ -1820,7 +1820,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const stopIconSizeClass = isMobile ? 'h-6 w-6' : (isVSCode ? 'h-4 w-4' : 'h-5 w-5');
const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
const iconButtonBaseClass = 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0';
const iconButtonBaseClass = 'flex items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0';
const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
// Send button - respects queue mode setting
@@ -1981,8 +1981,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
type="button"
className={cn(
footerIconButtonClass,
'rounded-md text-muted-foreground',
'hover:bg-interactive-hover/40 hover:text-foreground'
'rounded-md',
'hover:bg-interactive-hover/40'
)}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
@@ -2288,7 +2288,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
'rounded-md',
isExpandedInput
? 'text-primary'
: 'text-muted-foreground hover:bg-[var(--interactive-hover)]/40 hover:text-foreground'
: 'text-foreground hover:bg-[var(--interactive-hover)]/40'
)}
onMouseDown={(event) => {
event.preventDefault();
@@ -920,7 +920,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
displayParts.length === 0 ? null : (
<FadeInOnReveal>
<div className="flex justify-end">
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="max-w-[85%] rounded-2xl rounded-br-sm px-5 py-3 shadow-sm border border-primary/5">
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="max-w-[85%] rounded-2xl rounded-br-sm px-5 py-3 shadow-none border border-primary/5">
<MessageBody
messageId={message.info.id}
parts={displayParts}
@@ -252,7 +252,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
return (
<div
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
style={style}
>
{showTabs ? (
@@ -269,7 +269,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
className={cn(
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
activeTab === tab.id
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
: 'text-muted-foreground hover:bg-interactive-hover/50'
)}
onPointerDown={(event) => {
@@ -368,7 +368,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return (
<div
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[520px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
className="absolute z-[100] min-w-0 w-full max-w-[520px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
style={style}
>
{showTabs ? (
@@ -385,7 +385,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
className={cn(
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
activeTab === tab.id
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
: 'text-muted-foreground hover:bg-interactive-hover/50'
)}
onPointerDown={(event) => {
@@ -303,7 +303,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
{showMenu && (
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-none">
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
onClick={() => handleCopy('csv')}
@@ -364,7 +364,7 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
<RiDownloadLine className="size-3.5" />
</button>
{showMenu && (
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-none">
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
onClick={() => handleDownload('csv')}
+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 (
@@ -148,7 +148,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
return (
<div
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
style={style}
>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
@@ -229,7 +229,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px]",
"rounded-xl border border-border bg-background shadow-md",
"rounded-xl border border-border bg-background shadow-none",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
@@ -510,6 +510,26 @@ const getMessageRole = (message: ChatMessageEntry): string => {
return typeof role === 'string' ? role : '';
};
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) {
if (prev[index]?.info?.id !== next[index]?.info?.id) {
return false;
}
if (getMessageRole(prev[index]) !== getMessageRole(next[index])) {
return false;
}
}
return true;
};
const getStructureKey = (messages: ChatMessageEntry[]): string => {
if (messages.length === 0) return '';
return messages
@@ -517,13 +537,116 @@ const getStructureKey = (messages: ChatMessageEntry[]): string => {
.join('|');
};
const isAppendOnlyChange = (prev: ChatMessageEntry[], next: ChatMessageEntry[]): boolean => {
if (prev.length > next.length) {
return false;
}
for (let index = 0; index < prev.length; index += 1) {
if (prev[index]?.info?.id !== next[index]?.info?.id) {
return false;
}
if (getMessageRole(prev[index]) !== getMessageRole(next[index])) {
return false;
}
}
return true;
};
const appendTurnsIncremental = (prevTurns: Turn[], appendedMessages: ChatMessageEntry[]): Turn[] => {
if (appendedMessages.length === 0) {
return prevTurns;
}
const nextTurns = prevTurns.length > 0
? [
...prevTurns.slice(0, -1),
{
...prevTurns[prevTurns.length - 1],
assistantMessages: [...prevTurns[prevTurns.length - 1].assistantMessages],
},
]
: [];
let currentTurn = nextTurns.length > 0 ? nextTurns[nextTurns.length - 1] : null;
appendedMessages.forEach((message) => {
const role = getMessageRole(message);
if (role === 'user') {
currentTurn = {
turnId: message.info.id,
userMessage: message,
assistantMessages: [],
};
nextTurns.push(currentTurn);
return;
}
if (role === 'assistant' && currentTurn) {
currentTurn.assistantMessages.push(message);
}
});
return nextTurns;
};
const appendNeighborsIncremental = (
prevNeighbors: Map<string, NeighborInfo>,
prevMessages: ChatMessageEntry[],
nextMessages: ChatMessageEntry[],
): Map<string, NeighborInfo> => {
if (nextMessages.length <= prevMessages.length) {
return prevNeighbors;
}
const appended = nextMessages.slice(prevMessages.length);
if (appended.length === 0) {
return prevNeighbors;
}
const nextNeighbors = new Map(prevNeighbors);
const previousTail = prevMessages.length > 0 ? prevMessages[prevMessages.length - 1] : undefined;
if (previousTail) {
nextNeighbors.set(previousTail.info.id, {
previousMessage: prevMessages.length > 1 ? prevMessages[prevMessages.length - 2] : undefined,
nextMessage: appended[0],
});
}
appended.forEach((message, index) => {
const previousMessage = index === 0 ? previousTail : appended[index - 1];
const nextMessage = index < appended.length - 1 ? appended[index + 1] : undefined;
nextNeighbors.set(message.info.id, {
previousMessage,
nextMessage,
});
});
return nextNeighbors;
};
export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ messages, children }) => {
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
const structureKey = React.useMemo(() => getStructureKey(messages), [messages]);
const structureKeyCacheRef = React.useRef<{ messages: ChatMessageEntry[]; key: string } | null>(null);
const structureKey = React.useMemo(() => {
const cached = structureKeyCacheRef.current;
if (cached && hasSameTurnStructure(cached.messages, messages)) {
return cached.key;
}
const key = getStructureKey(messages);
structureKeyCacheRef.current = {
messages,
key,
};
return key;
}, [messages]);
const staticCacheRef = React.useRef<{
messages: ChatMessageEntry[];
structureKey: string;
defaultActivityExpanded: boolean;
showTextJustificationActivity: boolean;
@@ -542,6 +665,76 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
return cached.value;
}
if (
cached &&
cached.defaultActivityExpanded === defaultActivityExpanded &&
cached.showTextJustificationActivity === showTextJustificationActivity &&
isAppendOnlyChange(cached.messages, messages)
) {
const appendedMessages = messages.slice(cached.messages.length);
const turns = appendTurnsIncremental(cached.value.turns, appendedMessages);
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
const messageToTurn = new Map(cached.value.messageToTurn);
let currentTurn = turns.length > 0 ? turns[turns.length - 1] : null;
appendedMessages.forEach((message) => {
const role = getMessageRole(message);
if (role === 'user') {
currentTurn = turns.find((turn) => turn.turnId === message.info.id) ?? null;
if (currentTurn) {
messageToTurn.set(message.info.id, currentTurn);
}
return;
}
if (role === 'assistant' && currentTurn) {
messageToTurn.set(message.info.id, currentTurn);
}
});
const turnActivityInfo = new Map(cached.value.turnActivityInfo);
const previousLastTurnId = cached.value.lastTurnId;
if (previousLastTurnId && previousLastTurnId !== lastTurnId) {
const finalizedTurn = turns.find((turn) => turn.turnId === previousLastTurnId);
if (finalizedTurn) {
turnActivityInfo.set(previousLastTurnId, getTurnActivityInfo(finalizedTurn, showTextJustificationActivity));
}
}
if (lastTurnId) {
turnActivityInfo.delete(lastTurnId);
}
const messageNeighbors = appendNeighborsIncremental(cached.value.messageNeighbors, cached.messages, messages);
const lastTurnMessageIds = new Set<string>();
if (turns.length > 0) {
const lastTurn = turns[turns.length - 1]!;
lastTurnMessageIds.add(lastTurn.userMessage.info.id);
lastTurn.assistantMessages.forEach((msg) => {
lastTurnMessageIds.add(msg.info.id);
});
}
const value: TurnGroupingStaticData = {
turns,
messageToTurn,
turnActivityInfo,
lastTurnId,
lastTurnMessageIds,
defaultActivityExpanded,
messageNeighbors,
};
staticCacheRef.current = {
messages,
structureKey,
defaultActivityExpanded,
showTextJustificationActivity,
value,
};
return value;
}
const turns = detectTurns(messages);
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
@@ -582,6 +775,7 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
};
staticCacheRef.current = {
messages,
structureKey,
defaultActivityExpanded,
showTextJustificationActivity,
@@ -601,12 +795,29 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
if (!lastTurn) return undefined;
// Re-slice assistant messages from the live `messages` array so that
// streamed part updates are reflected without re-detecting all turns.
const userIdx = messages.findIndex((m) => m.info.id === lastTurn.userMessage.info.id);
if (userIdx < 0) return getTurnActivityInfo(lastTurn, showTextJustificationActivity);
const liveAssistant = messages.slice(userIdx + 1).filter((m) => {
const role = (m.info as { clientRole?: string | null }).clientRole ?? m.info.role;
return role === 'assistant';
});
const lastTurnUserId = lastTurn.userMessage.info.id;
const liveAssistant: ChatMessageEntry[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const candidate = messages[index];
if (!candidate) {
continue;
}
if (candidate.info.id === lastTurnUserId) {
break;
}
const role = (candidate.info as { clientRole?: string | null }).clientRole ?? candidate.info.role;
if (role === 'assistant') {
liveAssistant.push(candidate);
}
}
if (liveAssistant.length === 0 && messages.every((message) => message.info.id !== lastTurnUserId)) {
return getTurnActivityInfo(lastTurn, showTextJustificationActivity);
}
liveAssistant.reverse();
const liveTurn: Turn = { ...lastTurn, assistantMessages: liveAssistant };
return getTurnActivityInfo(liveTurn, showTextJustificationActivity);
}, [staticValue, messages, showTextJustificationActivity]);
@@ -321,7 +321,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
className={cn(
'flex items-center gap-1',
'rounded-lg border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-lg',
'bg-[var(--surface-elevated)] shadow-none',
'px-1.5 py-1',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isClosing