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:
committed by
GitHub
parent
4c69bccf56
commit
10851bd7ac
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user