refactor: streamline chat scroll manager
- Removed unused activeTurnAnchorId and activeTurnSpacerHeight from MemoryDebugPanel. - Simplified useChatScrollManager by eliminating unnecessary state and functions related to active turn anchoring. - Updated loadMessages function signatures to accept an optional limit parameter across message and session stores. - Enhanced scroll behavior in useScrollEngine to support dynamic bottom tracking during animations. - Improved event stream handling to allow for resyncing messages with a specified limit. - Cleaned up session memory state to remove active turn properties, focusing on viewport anchoring.
This commit is contained in:
@@ -25,8 +25,6 @@ export const ChatContainer: React.FC = () => {
|
||||
loadMessages,
|
||||
loadMoreMessages,
|
||||
updateViewportAnchor,
|
||||
updateActiveTurnAnchor,
|
||||
getActiveTurnAnchor,
|
||||
sessionMemoryState,
|
||||
openNewSessionDraft,
|
||||
isSyncing,
|
||||
@@ -75,26 +73,22 @@ export const ChatContainer: React.FC = () => {
|
||||
const {
|
||||
scrollRef,
|
||||
handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
showScrollButton,
|
||||
scrollToBottom,
|
||||
spacerHeight,
|
||||
pendingAnchorId,
|
||||
hasActiveAnchor,
|
||||
getAnimationHandlers,
|
||||
showScrollButton,
|
||||
scrollToBottom,
|
||||
scrollToPosition,
|
||||
isPinned,
|
||||
} = useChatScrollManager({
|
||||
currentSessionId,
|
||||
sessionMessages,
|
||||
streamingMessageId,
|
||||
sessionMemoryState,
|
||||
updateViewportAnchor,
|
||||
updateActiveTurnAnchor,
|
||||
getActiveTurnAnchor,
|
||||
isSyncing,
|
||||
isMobile,
|
||||
messageStreamStates,
|
||||
sessionPermissions: sessionBlockingCards,
|
||||
trimToViewportWindow,
|
||||
sessionActivityPhase,
|
||||
});
|
||||
|
||||
const memoryState = React.useMemo(() => {
|
||||
@@ -123,12 +117,12 @@ export const ChatContainer: React.FC = () => {
|
||||
await loadMoreMessages(currentSessionId, 'up');
|
||||
if (container && prevHeight !== null && prevTop !== null) {
|
||||
const heightDiff = container.scrollHeight - prevHeight;
|
||||
container.scrollTop = prevTop + heightDiff;
|
||||
scrollToPosition(prevTop + heightDiff, { instant: true });
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingOlder(false);
|
||||
}
|
||||
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef]);
|
||||
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]);
|
||||
|
||||
// Scroll to a specific message by ID (for timeline dialog)
|
||||
const scrollToMessage = React.useCallback((messageId: string) => {
|
||||
@@ -169,7 +163,8 @@ export const ChatContainer: React.FC = () => {
|
||||
} finally {
|
||||
const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle';
|
||||
const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown';
|
||||
const shouldSkipScroll = isActivePhase && hasActiveAnchor;
|
||||
// When pinned and active, scroll is already maintained automatically
|
||||
const shouldSkipScroll = isActivePhase && isPinned;
|
||||
|
||||
if (!shouldSkipScroll) {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -184,7 +179,7 @@ export const ChatContainer: React.FC = () => {
|
||||
};
|
||||
|
||||
void load();
|
||||
}, [currentSessionId, hasActiveAnchor, loadMessages, messages, scrollToBottom, sessionActivityPhase]);
|
||||
}, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionActivityPhase]);
|
||||
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
return (
|
||||
@@ -277,7 +272,6 @@ export const ChatContainer: React.FC = () => {
|
||||
}}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
hideBottomShadow={!!pendingAnchorId}
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
<MessageList
|
||||
@@ -290,16 +284,7 @@ export const ChatContainer: React.FC = () => {
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
onLoadOlder={handleLoadOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
pendingAnchorId={pendingAnchorId}
|
||||
/>
|
||||
{}
|
||||
{spacerHeight > 0 && hasActiveAnchor && (
|
||||
<div
|
||||
data-role="active-turn-spacer"
|
||||
style={{ height: spacerHeight }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} />
|
||||
|
||||
@@ -360,6 +360,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return;
|
||||
|
||||
// Re-pin and scroll to bottom when sending
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
|
||||
if (!currentProviderId || !currentModelId) {
|
||||
@@ -1356,25 +1357,28 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn(
|
||||
"pt-0 pb-2 md:pb-4",
|
||||
"relative pt-0 pb-2 md:pb-4",
|
||||
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
|
||||
)}
|
||||
data-keyboard-avoid="true"
|
||||
style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
||||
>
|
||||
<StatusRow
|
||||
isWorking={working.isWorking}
|
||||
statusText={workingStatusText}
|
||||
isGenericStatus={working.isGenericStatus}
|
||||
isWaitingForPermission={working.isWaitingForPermission}
|
||||
wasAborted={working.wasAborted}
|
||||
abortActive={working.abortActive}
|
||||
completionId={working.lastCompletionId}
|
||||
isComplete={working.isComplete}
|
||||
showAbort={showAbortInStatusRow}
|
||||
onAbort={handleAbort}
|
||||
showAbortStatus={showAbortStatus}
|
||||
/>
|
||||
{/* Absolute positioned above input - no layout shift */}
|
||||
<div className="absolute bottom-full left-0 right-0">
|
||||
<StatusRow
|
||||
isWorking={working.isWorking}
|
||||
statusText={workingStatusText}
|
||||
isGenericStatus={working.isGenericStatus}
|
||||
isWaitingForPermission={working.isWaitingForPermission}
|
||||
wasAborted={working.wasAborted}
|
||||
abortActive={working.abortActive}
|
||||
completionId={working.lastCompletionId}
|
||||
isComplete={working.isComplete}
|
||||
showAbort={showAbortInStatusRow}
|
||||
onAbort={handleAbort}
|
||||
showAbortStatus={showAbortStatus}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className={cn(
|
||||
|
||||
@@ -63,8 +63,7 @@ interface ChatMessageProps {
|
||||
};
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animationHandlers?: AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void;
|
||||
isPendingAnchor?: boolean;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
}
|
||||
|
||||
@@ -74,7 +73,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
nextMessage,
|
||||
onContentChange,
|
||||
animationHandlers,
|
||||
isPendingAnchor = false,
|
||||
turnGroupingContext,
|
||||
}) => {
|
||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||
@@ -834,7 +832,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
)}
|
||||
data-message-id={message.info.id}
|
||||
ref={messageContainerRef}
|
||||
style={isPendingAnchor ? { visibility: 'hidden' } : undefined}
|
||||
>
|
||||
<div className="chat-column">
|
||||
{isUser ? (
|
||||
|
||||
@@ -19,8 +19,7 @@ interface MessageListProps {
|
||||
hasMoreAbove: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
onLoadOlder: () => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void;
|
||||
pendingAnchorId?: string | null;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
}
|
||||
|
||||
const MessageList: React.FC<MessageListProps> = ({
|
||||
@@ -33,7 +32,6 @@ const MessageList: React.FC<MessageListProps> = ({
|
||||
isLoadingOlder,
|
||||
onLoadOlder,
|
||||
scrollToBottom,
|
||||
pendingAnchorId,
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (permissions.length === 0 && questions.length === 0) {
|
||||
@@ -101,7 +99,6 @@ const MessageList: React.FC<MessageListProps> = ({
|
||||
onContentChange={onMessageContentChange}
|
||||
animationHandlers={getAnimationHandlers(message.info.id)}
|
||||
scrollToBottom={scrollToBottom}
|
||||
isPendingAnchor={pendingAnchorId === message.info.id}
|
||||
turnGroupingContext={getContextForMessage(message.info.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -118,6 +115,9 @@ const MessageList: React.FC<MessageListProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom spacer - always 10% of viewport height */}
|
||||
<div className="flex-shrink-0" style={{ height: '10vh' }} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -56,15 +56,12 @@ export interface TurnGroupingContext {
|
||||
isWorking: boolean;
|
||||
isGroupExpanded: boolean;
|
||||
|
||||
previewedPartIds: Set<string>;
|
||||
toggleGroup: () => void;
|
||||
markPartsPreviewed: (partIds: string[]) => void;
|
||||
}
|
||||
|
||||
|
||||
interface TurnUiState {
|
||||
isExpanded: boolean;
|
||||
previewedPartIds: Set<string>;
|
||||
}
|
||||
|
||||
interface TurnActivityInfo {
|
||||
@@ -424,7 +421,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
||||
(turnId: string): TurnUiState => {
|
||||
const existing = turnUiStates.get(turnId);
|
||||
if (existing) return existing;
|
||||
return { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
|
||||
return { isExpanded: defaultActivityExpanded };
|
||||
},
|
||||
[turnUiStates, defaultActivityExpanded]
|
||||
);
|
||||
@@ -432,25 +429,8 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
||||
const toggleGroup = React.useCallback((turnId: string) => {
|
||||
setTurnUiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
|
||||
next.set(turnId, { ...current, isExpanded: !current.isExpanded });
|
||||
return next;
|
||||
});
|
||||
}, [defaultActivityExpanded]);
|
||||
|
||||
const markPartsPreviewedInternal = React.useCallback((turnId: string, partIds: string[]) => {
|
||||
if (partIds.length === 0) return;
|
||||
|
||||
setTurnUiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const state = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
|
||||
const newPreviewed = new Set(state.previewedPartIds);
|
||||
partIds.forEach((id) => {
|
||||
if (id && id.trim().length > 0) {
|
||||
newPreviewed.add(id);
|
||||
}
|
||||
});
|
||||
next.set(turnId, { ...state, previewedPartIds: newPreviewed });
|
||||
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
|
||||
next.set(turnId, { isExpanded: !current.isExpanded });
|
||||
return next;
|
||||
});
|
||||
}, [defaultActivityExpanded]);
|
||||
@@ -506,12 +486,10 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
||||
userMessageCreatedAt,
|
||||
isWorking: isTurnWorking,
|
||||
isGroupExpanded: uiState.isExpanded,
|
||||
previewedPartIds: uiState.previewedPartIds,
|
||||
toggleGroup: () => toggleGroup(turn.turnId),
|
||||
markPartsPreviewed: (partIds: string[]) => markPartsPreviewedInternal(turn.turnId, partIds),
|
||||
} satisfies TurnGroupingContext;
|
||||
},
|
||||
[getOrCreateTurnState, lastTurnId, markPartsPreviewedInternal, messageToTurn, sessionIsWorking, toggleGroup, turnActivityInfo]
|
||||
[getOrCreateTurnState, lastTurnId, messageToTurn, sessionIsWorking, toggleGroup, turnActivityInfo]
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -4,15 +4,16 @@ import { cn } from '@/lib/utils';
|
||||
interface FadeInOnRevealProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
skipAnimation?: boolean;
|
||||
}
|
||||
|
||||
const FADE_ANIMATION_ENABLED = true;
|
||||
|
||||
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className }) => {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className, skipAnimation }) => {
|
||||
const [visible, setVisible] = React.useState(skipAnimation ?? false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!FADE_ANIMATION_ENABLED) {
|
||||
if (!FADE_ANIMATION_ENABLED || skipAnimation) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,9 +36,9 @@ export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, classN
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [skipAnimation]);
|
||||
|
||||
if (!FADE_ANIMATION_ENABLED) {
|
||||
if (!FADE_ANIMATION_ENABLED || skipAnimation) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import AssistantTextPart from './parts/AssistantTextPart';
|
||||
import UserTextPart from './parts/UserTextPart';
|
||||
import ReasoningPart from './parts/ReasoningPart';
|
||||
import ToolPart from './parts/ToolPart';
|
||||
import ProgressiveGroup from './parts/ProgressiveGroup';
|
||||
import MigratingPart from './parts/MigratingPart';
|
||||
import { MessageFilesDisplay } from '../FileAttachment';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
|
||||
@@ -37,77 +34,6 @@ const formatTurnDuration = (durationMs: number): string => {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
};
|
||||
|
||||
|
||||
const useMigrationTimer = (
|
||||
turnGroupingContext: TurnGroupingContext | undefined,
|
||||
previewablePartIds: Set<string>
|
||||
): { isAnimating: boolean } => {
|
||||
const timerRef = React.useRef<number | null>(null);
|
||||
const animationTimerRef = React.useRef<number | null>(null);
|
||||
const [isAnimating, setIsAnimating] = React.useState(false);
|
||||
|
||||
const contextRef = React.useRef(turnGroupingContext);
|
||||
contextRef.current = turnGroupingContext;
|
||||
const partIdsRef = React.useRef(previewablePartIds);
|
||||
partIdsRef.current = previewablePartIds;
|
||||
|
||||
const timerStartedRef = React.useRef(false);
|
||||
|
||||
const hasPreviewableParts = previewablePartIds.size > 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!turnGroupingContext) return;
|
||||
if (!turnGroupingContext.isWorking) return;
|
||||
if (!hasPreviewableParts) return;
|
||||
if (timerStartedRef.current) return;
|
||||
|
||||
timerStartedRef.current = true;
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
setIsAnimating(true);
|
||||
|
||||
animationTimerRef.current = window.setTimeout(() => {
|
||||
animationTimerRef.current = null;
|
||||
setIsAnimating(false);
|
||||
const context = contextRef.current;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const idsToPreview = Array.from(partIdsRef.current);
|
||||
if (idsToPreview.length > 0) {
|
||||
context.markPartsPreviewed(idsToPreview);
|
||||
}
|
||||
}, 300);
|
||||
}, 1000);
|
||||
}, [hasPreviewableParts, turnGroupingContext]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!turnGroupingContext) return;
|
||||
if (!turnGroupingContext.isWorking || !hasPreviewableParts) {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (animationTimerRef.current) {
|
||||
window.clearTimeout(animationTimerRef.current);
|
||||
animationTimerRef.current = null;
|
||||
}
|
||||
setIsAnimating(false);
|
||||
timerStartedRef.current = false;
|
||||
}
|
||||
}, [hasPreviewableParts, turnGroupingContext]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
if (animationTimerRef.current) window.clearTimeout(animationTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isAnimating };
|
||||
};
|
||||
|
||||
const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
|
||||
|
||||
const isActivityStandaloneTool = (toolName: unknown): boolean => {
|
||||
@@ -429,15 +355,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
return toolParts.every((toolPart) => isToolFinalized(toolPart));
|
||||
}, [toolParts, hasPendingTools, isToolFinalized]);
|
||||
|
||||
const assistantTextReady = React.useMemo(() => {
|
||||
if (assistantTextParts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return assistantTextParts.every((part) => {
|
||||
const time = (part as Record<string, unknown>).time as Record<string, unknown> | undefined;
|
||||
return typeof time?.end === 'number';
|
||||
});
|
||||
}, [assistantTextParts]);
|
||||
|
||||
const reasoningParts = React.useMemo(() => {
|
||||
return visibleParts.filter((part) => part.type === 'reasoning');
|
||||
@@ -461,19 +378,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
hasTools &&
|
||||
(hasPendingTools || hasOpenStep || !allToolsFinalized);
|
||||
|
||||
const shouldCoordinateRendering = React.useMemo(() => {
|
||||
if (!hasTools) {
|
||||
return assistantTextParts.length > 0 ? shouldHoldForReasoning : false;
|
||||
}
|
||||
if (assistantTextParts.length === 0) {
|
||||
return hasOpenStep || hasPendingTools || !allToolsFinalized;
|
||||
}
|
||||
return true;
|
||||
}, [assistantTextParts.length, hasOpenStep, hasPendingTools, hasTools, shouldHoldForReasoning, allToolsFinalized]);
|
||||
|
||||
const shouldHoldAssistantText = awaitingMessageCompletion
|
||||
|| (shouldCoordinateRendering && (!assistantTextReady || !allToolsFinalized || hasPendingTools || hasOpenStep))
|
||||
|| shouldHoldForReasoning;
|
||||
const shouldHoldTools = awaitingMessageCompletion
|
||||
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
|
||||
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
|
||||
@@ -682,70 +587,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
|
||||
const shouldShowActivityGroup = Boolean(turnGroupingContext && hasEverHadMultipleVisibleActivities);
|
||||
|
||||
const previewableActivityPartsForMessage = React.useMemo(() => {
|
||||
if (!turnGroupingContext) return [];
|
||||
if (!shouldShowActivityGroup) return [];
|
||||
if (!turnGroupingContext.isWorking) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const previewable: (typeof activityPartsForMessage) = [];
|
||||
|
||||
activityPartsForMessage.forEach((activity) => {
|
||||
if (turnGroupingContext.previewedPartIds.has(activity.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showReasoningTraces && activity.kind !== 'tool') {
|
||||
return;
|
||||
}
|
||||
|
||||
const part = activity.part;
|
||||
|
||||
if (activity.kind === 'tool') {
|
||||
const toolPart = part as ToolPartType;
|
||||
if (isActivityStandaloneTool(toolPart.tool)) {
|
||||
return;
|
||||
}
|
||||
if (shouldHoldTools) return;
|
||||
if (!isToolFinalized(toolPart)) return;
|
||||
} else if (activity.kind === 'reasoning') {
|
||||
if (!showReasoningTraces) return;
|
||||
if (shouldHoldReasoning) return;
|
||||
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
|
||||
if (typeof time?.end !== 'number') return;
|
||||
} else if (activity.kind === 'justification') {
|
||||
if (!showReasoningTraces) return;
|
||||
if (shouldHoldAssistantText) return;
|
||||
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
|
||||
if (typeof time?.end !== 'number') return;
|
||||
}
|
||||
|
||||
previewable.push(activity);
|
||||
});
|
||||
|
||||
return previewable;
|
||||
}, [
|
||||
activityPartsForMessage,
|
||||
isToolFinalized,
|
||||
shouldHoldAssistantText,
|
||||
shouldHoldReasoning,
|
||||
shouldHoldTools,
|
||||
showReasoningTraces,
|
||||
shouldShowActivityGroup,
|
||||
turnGroupingContext,
|
||||
]);
|
||||
|
||||
const previewableActivityPartIds = React.useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
previewableActivityPartsForMessage.forEach((activity) => {
|
||||
ids.add(activity.id);
|
||||
});
|
||||
return ids;
|
||||
}, [previewableActivityPartsForMessage]);
|
||||
|
||||
const { isAnimating: isMessageAnimating } = useMigrationTimer(turnGroupingContext, previewableActivityPartIds);
|
||||
|
||||
const shouldRenderActivityGroup = Boolean(
|
||||
turnGroupingContext &&
|
||||
shouldShowActivityGroup &&
|
||||
@@ -789,8 +630,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
onToggleTool={onToggleTool}
|
||||
onShowPopup={onShowPopup}
|
||||
onContentChange={onContentChange}
|
||||
isWorking={turnGroupingContext.isWorking}
|
||||
previewedPartIds={turnGroupingContext.previewedPartIds}
|
||||
diffStats={turnGroupingContext.diffStats}
|
||||
/>
|
||||
);
|
||||
@@ -885,146 +724,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
element,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!turnGroupingContext.isWorking || turnGroupingContext.isGroupExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (turnGroupingContext.previewedPartIds.has(activity.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showReasoningTraces && activity.kind !== 'tool') {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapForMigration = previewableActivityPartIds.has(activity.id);
|
||||
|
||||
switch (activity.kind) {
|
||||
case 'tool': {
|
||||
const toolPart = part as ToolPartType;
|
||||
|
||||
if (isActivityStandaloneTool(toolPart.tool)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
|
||||
const time = toolState?.time;
|
||||
const isFinalized = isToolFinalized(toolPart);
|
||||
const shouldShowTool = !shouldHoldTools && isFinalized;
|
||||
|
||||
if (!shouldShowTool) {
|
||||
break;
|
||||
}
|
||||
|
||||
const connection = toolConnections[toolPart.id];
|
||||
|
||||
const toolElement = (
|
||||
<FadeInOnReveal key={`tool-${toolPart.id}`}>
|
||||
<ToolPart
|
||||
part={toolPart}
|
||||
isExpanded={expandedTools.has(toolPart.id)}
|
||||
onToggle={onToggleTool}
|
||||
syntaxTheme={syntaxTheme}
|
||||
isMobile={isMobile}
|
||||
onContentChange={onContentChange}
|
||||
hasPrevTool={connection?.hasPrev ?? false}
|
||||
hasNextTool={connection?.hasNext ?? false}
|
||||
/>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
|
||||
element = wrapForMigration ? (
|
||||
<MigratingPart key={`migrating-tool-${toolPart.id}`} isMigrating={isMessageAnimating}>
|
||||
{toolElement}
|
||||
</MigratingPart>
|
||||
) : toolElement;
|
||||
|
||||
endTime = isFinalized && typeof time?.end === 'number' ? time.end : null;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'reasoning': {
|
||||
if (!showReasoningTraces) {
|
||||
break;
|
||||
}
|
||||
const reasoningTime = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
|
||||
const hasEndTime = typeof reasoningTime?.end === 'number';
|
||||
const shouldShowReasoning = hasEndTime && !shouldHoldReasoning;
|
||||
|
||||
if (!shouldShowReasoning) {
|
||||
break;
|
||||
}
|
||||
|
||||
const reasoningElement = (
|
||||
<FadeInOnReveal key={`reasoning-${index}`}>
|
||||
<ReasoningPart
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
|
||||
element = wrapForMigration ? (
|
||||
<MigratingPart key={`migrating-reasoning-${index}`} isMigrating={isMessageAnimating}>
|
||||
{reasoningElement}
|
||||
</MigratingPart>
|
||||
) : reasoningElement;
|
||||
|
||||
endTime = hasEndTime ? reasoningTime?.end ?? null : null;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'justification': {
|
||||
if (!showReasoningTraces) {
|
||||
break;
|
||||
}
|
||||
|
||||
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
|
||||
const hasEndTime = typeof time?.end === 'number';
|
||||
const shouldShowJustification = hasEndTime && !shouldHoldAssistantText;
|
||||
|
||||
if (!shouldShowJustification) {
|
||||
break;
|
||||
}
|
||||
|
||||
const textElement = (
|
||||
<FadeInOnReveal key={`assistant-text-${index}`}>
|
||||
<AssistantTextPart
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
streamPhase="completed"
|
||||
allowAnimation={false}
|
||||
onContentChange={onContentChange}
|
||||
renderAsReasoning
|
||||
/>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
|
||||
element = wrapForMigration ? (
|
||||
<MigratingPart key={`migrating-text-${index}`} isMigrating={isMessageAnimating}>
|
||||
{textElement}
|
||||
</MigratingPart>
|
||||
) : textElement;
|
||||
|
||||
endTime = hasEndTime ? time?.end ?? null : null;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
partsWithTime.push({
|
||||
part,
|
||||
index,
|
||||
endTime,
|
||||
element,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1050,16 +749,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
activityPartsByPart,
|
||||
activityGroupSegmentsForMessage,
|
||||
expandedTools,
|
||||
isMessageAnimating,
|
||||
isMobile,
|
||||
isToolFinalized,
|
||||
messageId,
|
||||
onContentChange,
|
||||
onShowPopup,
|
||||
onToggleTool,
|
||||
previewableActivityPartIds,
|
||||
shouldHoldAssistantText,
|
||||
shouldHoldReasoning,
|
||||
shouldHoldTools,
|
||||
shouldShowActivityGroup,
|
||||
showReasoningTraces,
|
||||
|
||||
@@ -10,6 +10,8 @@ import ReasoningPart from './ReasoningPart';
|
||||
import JustificationBlock from './JustificationBlock';
|
||||
import { FadeInOnReveal } from '../FadeInOnReveal';
|
||||
|
||||
const MAX_VISIBLE_COLLAPSED = 6;
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
@@ -26,32 +28,9 @@ interface ProgressiveGroupProps {
|
||||
onToggleTool: (toolId: string) => void;
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
isWorking: boolean;
|
||||
previewedPartIds: Set<string>;
|
||||
diffStats?: DiffStats;
|
||||
}
|
||||
|
||||
const getGroupSummary = (parts: TurnActivityPart[]): string => {
|
||||
const counts = {
|
||||
tools: parts.filter((p) => p.kind === 'tool').length,
|
||||
reasoning: parts.filter((p) => p.kind === 'reasoning').length,
|
||||
justifications: parts.filter((p) => p.kind === 'justification').length,
|
||||
};
|
||||
|
||||
const segments: string[] = [];
|
||||
if (counts.tools > 0) {
|
||||
segments.push(`${counts.tools} tool${counts.tools > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (counts.reasoning > 0) {
|
||||
segments.push(`${counts.reasoning} reasoning`);
|
||||
}
|
||||
if (counts.justifications > 0) {
|
||||
segments.push(`${counts.justifications} justification${counts.justifications > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
return segments.join(', ');
|
||||
};
|
||||
|
||||
const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => {
|
||||
return [...parts].sort((a, b) => {
|
||||
const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined;
|
||||
@@ -93,13 +72,12 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
expandedTools,
|
||||
onToggleTool,
|
||||
onContentChange,
|
||||
isWorking,
|
||||
previewedPartIds,
|
||||
diffStats,
|
||||
}) => {
|
||||
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
|
||||
// Track if we just expanded from collapsed state
|
||||
const [justExpandedFromCollapsed, setJustExpandedFromCollapsed] = React.useState(false);
|
||||
|
||||
// Track expansion count to force re-mount of items when group expands from collapsed
|
||||
const [expansionKey, setExpansionKey] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -108,85 +86,101 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
previousExpandedRef.current = isExpanded;
|
||||
onContentChange?.('structural');
|
||||
|
||||
// Increment key when expanding to trigger fresh animations
|
||||
if (isExpanded && wasCollapsed) {
|
||||
setExpansionKey((k) => k + 1);
|
||||
setJustExpandedFromCollapsed(true);
|
||||
// Reset after a short delay (after animations would have started)
|
||||
const timer = setTimeout(() => setJustExpandedFromCollapsed(false), 50);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setJustExpandedFromCollapsed(false);
|
||||
}
|
||||
}, [isExpanded, onContentChange]);
|
||||
|
||||
|
||||
const displayParts = React.useMemo(() => {
|
||||
if (!isWorking) {
|
||||
return sortPartsByTime(parts);
|
||||
}
|
||||
return sortPartsByTime(parts);
|
||||
}, [parts]);
|
||||
|
||||
// While turn is working, only show parts that have been "previewed".
|
||||
// Collapsed mode previews them in-chat first, then migrates into Activity.
|
||||
// Summary/Detailed modes skip in-chat preview, but still use the same migration gate.
|
||||
return sortPartsByTime(
|
||||
parts.filter((activity) => {
|
||||
const partId = activity.part.id;
|
||||
return partId && previewedPartIds.has(activity.id);
|
||||
})
|
||||
);
|
||||
}, [parts, isWorking, previewedPartIds]);
|
||||
|
||||
|
||||
const summary = getGroupSummary(displayParts);
|
||||
const toolConnections = getToolConnections(displayParts);
|
||||
|
||||
// For collapsed state: show last N items
|
||||
const visibleCollapsedParts = React.useMemo(() => {
|
||||
return displayParts.slice(-MAX_VISIBLE_COLLAPSED);
|
||||
}, [displayParts]);
|
||||
|
||||
// Set of part IDs that were visible in collapsed state
|
||||
const visibleInCollapsedIds = React.useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
visibleCollapsedParts.forEach((p) => {
|
||||
if (p.part.id) ids.add(p.part.id);
|
||||
});
|
||||
return ids;
|
||||
}, [visibleCollapsedParts]);
|
||||
|
||||
// Connections for collapsed view (based on visible parts only)
|
||||
const collapsedToolConnections = React.useMemo(() => {
|
||||
return getToolConnections(visibleCollapsedParts);
|
||||
}, [visibleCollapsedParts]);
|
||||
|
||||
const hiddenCount = Math.max(0, displayParts.length - MAX_VISIBLE_COLLAPSED);
|
||||
|
||||
if (displayParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const partsToRender = isExpanded ? displayParts : visibleCollapsedParts;
|
||||
const connectionsToUse = isExpanded ? toolConnections : collapsedToolConnections;
|
||||
|
||||
// If there are no hidden items, header is not interactive
|
||||
const isHeaderInteractive = hiddenCount > 0;
|
||||
|
||||
return (
|
||||
<FadeInOnReveal>
|
||||
<div className="my-1">
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'group/tool flex items-center gap-2 pr-2 pl-px pt-0 pb-1.5 rounded-xl cursor-pointer'
|
||||
'group/tool flex items-center gap-2 pr-2 pl-px pt-0 pb-1.5 rounded-xl',
|
||||
isHeaderInteractive && 'cursor-pointer'
|
||||
)}
|
||||
onClick={onToggle}
|
||||
onClick={isHeaderInteractive ? onToggle : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{}
|
||||
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity',
|
||||
isExpanded && 'opacity-0',
|
||||
!isExpanded && !isMobile && 'group-hover/tool:opacity-0'
|
||||
)}
|
||||
>
|
||||
<RiStackLine className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||
isExpanded && 'opacity-100',
|
||||
!isExpanded && isMobile && 'opacity-0',
|
||||
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
|
||||
)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||
{isHeaderInteractive ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity',
|
||||
isExpanded && 'opacity-0',
|
||||
!isExpanded && !isMobile && 'group-hover/tool:opacity-0'
|
||||
)}
|
||||
>
|
||||
<RiStackLine className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||
isExpanded && 'opacity-100',
|
||||
!isExpanded && isMobile && 'opacity-0',
|
||||
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
|
||||
)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5" />
|
||||
<RiStackLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
<span className="typography-meta font-medium">Activity</span>
|
||||
</div>
|
||||
<span className="typography-meta font-medium">Activity</span>
|
||||
</div>
|
||||
|
||||
{(summary || diffStats) && (
|
||||
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70 flex items-center gap-2">
|
||||
{summary && (
|
||||
<span className="truncate block">{summary}</span>
|
||||
)}
|
||||
{diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && (
|
||||
{diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && (
|
||||
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70 flex items-center gap-2">
|
||||
<span className="flex-shrink-0 leading-none">
|
||||
<span className="text-[color:var(--status-success)]">
|
||||
+{Math.max(0, diffStats.additions)}
|
||||
@@ -196,13 +190,10 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
-{Math.max(0, diffStats.deletions)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative pr-2 pb-1 pt-1 pl-[1.4375rem]',
|
||||
@@ -210,16 +201,31 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
'before:top-[-0.25rem] before:bottom-0'
|
||||
)}
|
||||
>
|
||||
{displayParts.map((activity, index) => {
|
||||
{!isExpanded && hiddenCount > 0 && (
|
||||
<div
|
||||
className="typography-micro text-muted-foreground/70 mb-1 cursor-pointer hover:text-muted-foreground"
|
||||
onClick={onToggle}
|
||||
>
|
||||
+{hiddenCount} more...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{partsToRender.map((activity, index) => {
|
||||
const partId = activity.part.id || `group-part-${index}`;
|
||||
const connection = toolConnections[partId];
|
||||
const connection = connectionsToUse[partId];
|
||||
|
||||
const animationKey = `${partId}-exp${expansionKey}`;
|
||||
|
||||
// Skip animation if:
|
||||
// - We just expanded from collapsed AND
|
||||
// - This part was already visible in collapsed state
|
||||
const wasVisibleInCollapsed = activity.part.id ? visibleInCollapsedIds.has(activity.part.id) : false;
|
||||
const skipAnimation = justExpandedFromCollapsed && wasVisibleInCollapsed;
|
||||
|
||||
switch (activity.kind) {
|
||||
case 'tool':
|
||||
return (
|
||||
<FadeInOnReveal key={animationKey}>
|
||||
<FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
|
||||
<ToolPart
|
||||
part={activity.part as ToolPartType}
|
||||
isExpanded={expandedTools.has(partId)}
|
||||
@@ -235,7 +241,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
|
||||
case 'reasoning':
|
||||
return (
|
||||
<FadeInOnReveal key={animationKey}>
|
||||
<FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
|
||||
<ReasoningPart
|
||||
part={activity.part}
|
||||
messageId={activity.messageId}
|
||||
@@ -246,7 +252,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
|
||||
case 'justification':
|
||||
return (
|
||||
<FadeInOnReveal key={animationKey}>
|
||||
<FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
|
||||
<JustificationBlock
|
||||
part={activity.part}
|
||||
messageId={activity.messageId}
|
||||
@@ -260,7 +266,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
|
||||
@@ -42,8 +42,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
||||
isZombie: memoryState?.isZombie || false,
|
||||
backgroundCount: memoryState?.backgroundMessageCount || 0,
|
||||
lastAccessed: memoryState?.lastAccessedAt || 0,
|
||||
activeTurnAnchorId: memoryState?.activeTurnAnchorId ?? null,
|
||||
activeTurnSpacerHeight: memoryState?.activeTurnSpacerHeight ?? 0,
|
||||
isCurrent: session.id === currentSessionId
|
||||
};
|
||||
}).sort((a, b) => b.lastAccessed - a.lastAccessed);
|
||||
@@ -145,11 +143,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
||||
}`}>
|
||||
{stat.messageCount} msgs
|
||||
</span>
|
||||
{stat.activeTurnAnchorId && stat.activeTurnSpacerHeight > 0 && (
|
||||
<span className="font-mono text-xs text-primary">
|
||||
anchor+{Math.round(stat.activeTurnSpacerHeight)}px
|
||||
</span>
|
||||
)}
|
||||
{stat.backgroundCount > 0 && (
|
||||
<span className="text-primary">+{stat.backgroundCount}</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user