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,
|
loadMessages,
|
||||||
loadMoreMessages,
|
loadMoreMessages,
|
||||||
updateViewportAnchor,
|
updateViewportAnchor,
|
||||||
updateActiveTurnAnchor,
|
|
||||||
getActiveTurnAnchor,
|
|
||||||
sessionMemoryState,
|
sessionMemoryState,
|
||||||
openNewSessionDraft,
|
openNewSessionDraft,
|
||||||
isSyncing,
|
isSyncing,
|
||||||
@@ -78,23 +76,19 @@ export const ChatContainer: React.FC = () => {
|
|||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
showScrollButton,
|
showScrollButton,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
spacerHeight,
|
scrollToPosition,
|
||||||
pendingAnchorId,
|
isPinned,
|
||||||
hasActiveAnchor,
|
|
||||||
} = useChatScrollManager({
|
} = useChatScrollManager({
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
sessionMessages,
|
sessionMessages,
|
||||||
streamingMessageId,
|
streamingMessageId,
|
||||||
sessionMemoryState,
|
sessionMemoryState,
|
||||||
updateViewportAnchor,
|
updateViewportAnchor,
|
||||||
updateActiveTurnAnchor,
|
|
||||||
getActiveTurnAnchor,
|
|
||||||
isSyncing,
|
isSyncing,
|
||||||
isMobile,
|
isMobile,
|
||||||
messageStreamStates,
|
messageStreamStates,
|
||||||
sessionPermissions: sessionBlockingCards,
|
sessionPermissions: sessionBlockingCards,
|
||||||
trimToViewportWindow,
|
trimToViewportWindow,
|
||||||
sessionActivityPhase,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const memoryState = React.useMemo(() => {
|
const memoryState = React.useMemo(() => {
|
||||||
@@ -123,12 +117,12 @@ export const ChatContainer: React.FC = () => {
|
|||||||
await loadMoreMessages(currentSessionId, 'up');
|
await loadMoreMessages(currentSessionId, 'up');
|
||||||
if (container && prevHeight !== null && prevTop !== null) {
|
if (container && prevHeight !== null && prevTop !== null) {
|
||||||
const heightDiff = container.scrollHeight - prevHeight;
|
const heightDiff = container.scrollHeight - prevHeight;
|
||||||
container.scrollTop = prevTop + heightDiff;
|
scrollToPosition(prevTop + heightDiff, { instant: true });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
}
|
}
|
||||||
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef]);
|
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]);
|
||||||
|
|
||||||
// Scroll to a specific message by ID (for timeline dialog)
|
// Scroll to a specific message by ID (for timeline dialog)
|
||||||
const scrollToMessage = React.useCallback((messageId: string) => {
|
const scrollToMessage = React.useCallback((messageId: string) => {
|
||||||
@@ -169,7 +163,8 @@ export const ChatContainer: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle';
|
const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle';
|
||||||
const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown';
|
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 (!shouldSkipScroll) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -184,7 +179,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
}, [currentSessionId, hasActiveAnchor, loadMessages, messages, scrollToBottom, sessionActivityPhase]);
|
}, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionActivityPhase]);
|
||||||
|
|
||||||
if (!currentSessionId && !draftOpen) {
|
if (!currentSessionId && !draftOpen) {
|
||||||
return (
|
return (
|
||||||
@@ -277,7 +272,6 @@ export const ChatContainer: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
data-scroll-shadow="true"
|
data-scroll-shadow="true"
|
||||||
data-scrollbar="chat"
|
data-scrollbar="chat"
|
||||||
hideBottomShadow={!!pendingAnchorId}
|
|
||||||
>
|
>
|
||||||
<div className="relative z-0 min-h-full">
|
<div className="relative z-0 min-h-full">
|
||||||
<MessageList
|
<MessageList
|
||||||
@@ -290,16 +284,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
isLoadingOlder={isLoadingOlder}
|
isLoadingOlder={isLoadingOlder}
|
||||||
onLoadOlder={handleLoadOlder}
|
onLoadOlder={handleLoadOlder}
|
||||||
scrollToBottom={scrollToBottom}
|
scrollToBottom={scrollToBottom}
|
||||||
pendingAnchorId={pendingAnchorId}
|
|
||||||
/>
|
/>
|
||||||
{}
|
|
||||||
{spacerHeight > 0 && hasActiveAnchor && (
|
|
||||||
<div
|
|
||||||
data-role="active-turn-spacer"
|
|
||||||
style={{ height: spacerHeight }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollShadow>
|
</ScrollShadow>
|
||||||
<OverlayScrollbar containerRef={scrollRef} />
|
<OverlayScrollbar containerRef={scrollRef} />
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return;
|
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return;
|
||||||
|
|
||||||
|
// Re-pin and scroll to bottom when sending
|
||||||
scrollToBottom?.({ instant: true, force: true });
|
scrollToBottom?.({ instant: true, force: true });
|
||||||
|
|
||||||
if (!currentProviderId || !currentModelId) {
|
if (!currentProviderId || !currentModelId) {
|
||||||
@@ -1356,12 +1357,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className={cn(
|
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"
|
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
|
||||||
)}
|
)}
|
||||||
data-keyboard-avoid="true"
|
data-keyboard-avoid="true"
|
||||||
style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
||||||
>
|
>
|
||||||
|
{/* Absolute positioned above input - no layout shift */}
|
||||||
|
<div className="absolute bottom-full left-0 right-0">
|
||||||
<StatusRow
|
<StatusRow
|
||||||
isWorking={working.isWorking}
|
isWorking={working.isWorking}
|
||||||
statusText={workingStatusText}
|
statusText={workingStatusText}
|
||||||
@@ -1375,6 +1378,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
onAbort={handleAbort}
|
onAbort={handleAbort}
|
||||||
showAbortStatus={showAbortStatus}
|
showAbortStatus={showAbortStatus}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={dropZoneRef}
|
ref={dropZoneRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ interface ChatMessageProps {
|
|||||||
};
|
};
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
animationHandlers?: AnimationHandlers;
|
animationHandlers?: AnimationHandlers;
|
||||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void;
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
isPendingAnchor?: boolean;
|
|
||||||
turnGroupingContext?: TurnGroupingContext;
|
turnGroupingContext?: TurnGroupingContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +73,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
nextMessage,
|
nextMessage,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
animationHandlers,
|
animationHandlers,
|
||||||
isPendingAnchor = false,
|
|
||||||
turnGroupingContext,
|
turnGroupingContext,
|
||||||
}) => {
|
}) => {
|
||||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||||
@@ -834,7 +832,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
)}
|
)}
|
||||||
data-message-id={message.info.id}
|
data-message-id={message.info.id}
|
||||||
ref={messageContainerRef}
|
ref={messageContainerRef}
|
||||||
style={isPendingAnchor ? { visibility: 'hidden' } : undefined}
|
|
||||||
>
|
>
|
||||||
<div className="chat-column">
|
<div className="chat-column">
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ interface MessageListProps {
|
|||||||
hasMoreAbove: boolean;
|
hasMoreAbove: boolean;
|
||||||
isLoadingOlder: boolean;
|
isLoadingOlder: boolean;
|
||||||
onLoadOlder: () => void;
|
onLoadOlder: () => void;
|
||||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void;
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
pendingAnchorId?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageList: React.FC<MessageListProps> = ({
|
const MessageList: React.FC<MessageListProps> = ({
|
||||||
@@ -33,7 +32,6 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
isLoadingOlder,
|
isLoadingOlder,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
pendingAnchorId,
|
|
||||||
}) => {
|
}) => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (permissions.length === 0 && questions.length === 0) {
|
if (permissions.length === 0 && questions.length === 0) {
|
||||||
@@ -101,7 +99,6 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
onContentChange={onMessageContentChange}
|
onContentChange={onMessageContentChange}
|
||||||
animationHandlers={getAnimationHandlers(message.info.id)}
|
animationHandlers={getAnimationHandlers(message.info.id)}
|
||||||
scrollToBottom={scrollToBottom}
|
scrollToBottom={scrollToBottom}
|
||||||
isPendingAnchor={pendingAnchorId === message.info.id}
|
|
||||||
turnGroupingContext={getContextForMessage(message.info.id)}
|
turnGroupingContext={getContextForMessage(message.info.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -118,6 +115,9 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom spacer - always 10% of viewport height */}
|
||||||
|
<div className="flex-shrink-0" style={{ height: '10vh' }} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,15 +56,12 @@ export interface TurnGroupingContext {
|
|||||||
isWorking: boolean;
|
isWorking: boolean;
|
||||||
isGroupExpanded: boolean;
|
isGroupExpanded: boolean;
|
||||||
|
|
||||||
previewedPartIds: Set<string>;
|
|
||||||
toggleGroup: () => void;
|
toggleGroup: () => void;
|
||||||
markPartsPreviewed: (partIds: string[]) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface TurnUiState {
|
interface TurnUiState {
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
previewedPartIds: Set<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TurnActivityInfo {
|
interface TurnActivityInfo {
|
||||||
@@ -424,7 +421,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
|||||||
(turnId: string): TurnUiState => {
|
(turnId: string): TurnUiState => {
|
||||||
const existing = turnUiStates.get(turnId);
|
const existing = turnUiStates.get(turnId);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
return { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
|
return { isExpanded: defaultActivityExpanded };
|
||||||
},
|
},
|
||||||
[turnUiStates, defaultActivityExpanded]
|
[turnUiStates, defaultActivityExpanded]
|
||||||
);
|
);
|
||||||
@@ -432,25 +429,8 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
|||||||
const toggleGroup = React.useCallback((turnId: string) => {
|
const toggleGroup = React.useCallback((turnId: string) => {
|
||||||
setTurnUiStates((prev) => {
|
setTurnUiStates((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
|
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
|
||||||
next.set(turnId, { ...current, isExpanded: !current.isExpanded });
|
next.set(turnId, { 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 });
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [defaultActivityExpanded]);
|
}, [defaultActivityExpanded]);
|
||||||
@@ -506,12 +486,10 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
|||||||
userMessageCreatedAt,
|
userMessageCreatedAt,
|
||||||
isWorking: isTurnWorking,
|
isWorking: isTurnWorking,
|
||||||
isGroupExpanded: uiState.isExpanded,
|
isGroupExpanded: uiState.isExpanded,
|
||||||
previewedPartIds: uiState.previewedPartIds,
|
|
||||||
toggleGroup: () => toggleGroup(turn.turnId),
|
toggleGroup: () => toggleGroup(turn.turnId),
|
||||||
markPartsPreviewed: (partIds: string[]) => markPartsPreviewedInternal(turn.turnId, partIds),
|
|
||||||
} satisfies TurnGroupingContext;
|
} 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 {
|
interface FadeInOnRevealProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
skipAnimation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FADE_ANIMATION_ENABLED = true;
|
const FADE_ANIMATION_ENABLED = true;
|
||||||
|
|
||||||
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className }) => {
|
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className, skipAnimation }) => {
|
||||||
const [visible, setVisible] = React.useState(false);
|
const [visible, setVisible] = React.useState(skipAnimation ?? false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!FADE_ANIMATION_ENABLED) {
|
if (!FADE_ANIMATION_ENABLED || skipAnimation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +36,9 @@ export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, classN
|
|||||||
window.cancelAnimationFrame(frame);
|
window.cancelAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [skipAnimation]);
|
||||||
|
|
||||||
if (!FADE_ANIMATION_ENABLED) {
|
if (!FADE_ANIMATION_ENABLED || skipAnimation) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
import AssistantTextPart from './parts/AssistantTextPart';
|
|
||||||
import UserTextPart from './parts/UserTextPart';
|
import UserTextPart from './parts/UserTextPart';
|
||||||
import ReasoningPart from './parts/ReasoningPart';
|
|
||||||
import ToolPart from './parts/ToolPart';
|
import ToolPart from './parts/ToolPart';
|
||||||
import ProgressiveGroup from './parts/ProgressiveGroup';
|
import ProgressiveGroup from './parts/ProgressiveGroup';
|
||||||
import MigratingPart from './parts/MigratingPart';
|
|
||||||
import { MessageFilesDisplay } from '../FileAttachment';
|
import { MessageFilesDisplay } from '../FileAttachment';
|
||||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||||
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
|
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
|
||||||
@@ -37,77 +34,6 @@ const formatTurnDuration = (durationMs: number): string => {
|
|||||||
return `${minutes}m ${seconds}s`;
|
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 ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
|
||||||
|
|
||||||
const isActivityStandaloneTool = (toolName: unknown): boolean => {
|
const isActivityStandaloneTool = (toolName: unknown): boolean => {
|
||||||
@@ -429,15 +355,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
return toolParts.every((toolPart) => isToolFinalized(toolPart));
|
return toolParts.every((toolPart) => isToolFinalized(toolPart));
|
||||||
}, [toolParts, hasPendingTools, isToolFinalized]);
|
}, [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(() => {
|
const reasoningParts = React.useMemo(() => {
|
||||||
return visibleParts.filter((part) => part.type === 'reasoning');
|
return visibleParts.filter((part) => part.type === 'reasoning');
|
||||||
@@ -461,19 +378,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
hasTools &&
|
hasTools &&
|
||||||
(hasPendingTools || hasOpenStep || !allToolsFinalized);
|
(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
|
const shouldHoldTools = awaitingMessageCompletion
|
||||||
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
|
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
|
||||||
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
|
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
|
||||||
@@ -682,70 +587,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
|
|
||||||
const shouldShowActivityGroup = Boolean(turnGroupingContext && hasEverHadMultipleVisibleActivities);
|
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(
|
const shouldRenderActivityGroup = Boolean(
|
||||||
turnGroupingContext &&
|
turnGroupingContext &&
|
||||||
shouldShowActivityGroup &&
|
shouldShowActivityGroup &&
|
||||||
@@ -789,8 +630,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
onToggleTool={onToggleTool}
|
onToggleTool={onToggleTool}
|
||||||
onShowPopup={onShowPopup}
|
onShowPopup={onShowPopup}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
isWorking={turnGroupingContext.isWorking}
|
|
||||||
previewedPartIds={turnGroupingContext.previewedPartIds}
|
|
||||||
diffStats={turnGroupingContext.diffStats}
|
diffStats={turnGroupingContext.diffStats}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -885,146 +724,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
element,
|
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,
|
activityPartsByPart,
|
||||||
activityGroupSegmentsForMessage,
|
activityGroupSegmentsForMessage,
|
||||||
expandedTools,
|
expandedTools,
|
||||||
isMessageAnimating,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
isToolFinalized,
|
isToolFinalized,
|
||||||
messageId,
|
|
||||||
onContentChange,
|
onContentChange,
|
||||||
onShowPopup,
|
onShowPopup,
|
||||||
onToggleTool,
|
onToggleTool,
|
||||||
previewableActivityPartIds,
|
|
||||||
shouldHoldAssistantText,
|
|
||||||
shouldHoldReasoning,
|
|
||||||
shouldHoldTools,
|
shouldHoldTools,
|
||||||
shouldShowActivityGroup,
|
shouldShowActivityGroup,
|
||||||
showReasoningTraces,
|
showReasoningTraces,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import ReasoningPart from './ReasoningPart';
|
|||||||
import JustificationBlock from './JustificationBlock';
|
import JustificationBlock from './JustificationBlock';
|
||||||
import { FadeInOnReveal } from '../FadeInOnReveal';
|
import { FadeInOnReveal } from '../FadeInOnReveal';
|
||||||
|
|
||||||
|
const MAX_VISIBLE_COLLAPSED = 6;
|
||||||
|
|
||||||
interface DiffStats {
|
interface DiffStats {
|
||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
@@ -26,32 +28,9 @@ interface ProgressiveGroupProps {
|
|||||||
onToggleTool: (toolId: string) => void;
|
onToggleTool: (toolId: string) => void;
|
||||||
onShowPopup: (content: ToolPopupContent) => void;
|
onShowPopup: (content: ToolPopupContent) => void;
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
isWorking: boolean;
|
|
||||||
previewedPartIds: Set<string>;
|
|
||||||
diffStats?: DiffStats;
|
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[] => {
|
const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => {
|
||||||
return [...parts].sort((a, b) => {
|
return [...parts].sort((a, b) => {
|
||||||
const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined;
|
const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined;
|
||||||
@@ -93,13 +72,12 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
expandedTools,
|
expandedTools,
|
||||||
onToggleTool,
|
onToggleTool,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
isWorking,
|
|
||||||
previewedPartIds,
|
|
||||||
diffStats,
|
diffStats,
|
||||||
}) => {
|
}) => {
|
||||||
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
|
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);
|
const [expansionKey, setExpansionKey] = React.useState(0);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -108,51 +86,68 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
previousExpandedRef.current = isExpanded;
|
previousExpandedRef.current = isExpanded;
|
||||||
onContentChange?.('structural');
|
onContentChange?.('structural');
|
||||||
|
|
||||||
// Increment key when expanding to trigger fresh animations
|
|
||||||
if (isExpanded && wasCollapsed) {
|
if (isExpanded && wasCollapsed) {
|
||||||
setExpansionKey((k) => k + 1);
|
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]);
|
}, [isExpanded, onContentChange]);
|
||||||
|
|
||||||
|
|
||||||
const displayParts = React.useMemo(() => {
|
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);
|
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) {
|
if (displayParts.length === 0) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<FadeInOnReveal>
|
<FadeInOnReveal>
|
||||||
<div className="my-1">
|
<div className="my-1">
|
||||||
{}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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="flex items-center gap-2 flex-shrink-0">
|
||||||
{}
|
|
||||||
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||||
{}
|
{isHeaderInteractive ? (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity',
|
'absolute inset-0 transition-opacity',
|
||||||
@@ -162,7 +157,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
>
|
>
|
||||||
<RiStackLine className="h-3.5 w-3.5" />
|
<RiStackLine className="h-3.5 w-3.5" />
|
||||||
</div>
|
</div>
|
||||||
{}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||||
@@ -177,16 +171,16 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
<RiArrowRightSLine className="h-3.5 w-3.5" />
|
<RiArrowRightSLine className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<RiStackLine className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="typography-meta font-medium">Activity</span>
|
<span className="typography-meta font-medium">Activity</span>
|
||||||
</div>
|
</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="flex-shrink-0 leading-none">
|
||||||
<span className="text-[color:var(--status-success)]">
|
<span className="text-[color:var(--status-success)]">
|
||||||
+{Math.max(0, diffStats.additions)}
|
+{Math.max(0, diffStats.additions)}
|
||||||
@@ -196,13 +190,10 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
-{Math.max(0, diffStats.deletions)}
|
-{Math.max(0, diffStats.deletions)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{}
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative pr-2 pb-1 pt-1 pl-[1.4375rem]',
|
'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'
|
'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 partId = activity.part.id || `group-part-${index}`;
|
||||||
const connection = toolConnections[partId];
|
const connection = connectionsToUse[partId];
|
||||||
|
|
||||||
const animationKey = `${partId}-exp${expansionKey}`;
|
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) {
|
switch (activity.kind) {
|
||||||
case 'tool':
|
case 'tool':
|
||||||
return (
|
return (
|
||||||
<FadeInOnReveal key={animationKey}>
|
<FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
|
||||||
<ToolPart
|
<ToolPart
|
||||||
part={activity.part as ToolPartType}
|
part={activity.part as ToolPartType}
|
||||||
isExpanded={expandedTools.has(partId)}
|
isExpanded={expandedTools.has(partId)}
|
||||||
@@ -235,7 +241,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
|
|
||||||
case 'reasoning':
|
case 'reasoning':
|
||||||
return (
|
return (
|
||||||
<FadeInOnReveal key={animationKey}>
|
<FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
|
||||||
<ReasoningPart
|
<ReasoningPart
|
||||||
part={activity.part}
|
part={activity.part}
|
||||||
messageId={activity.messageId}
|
messageId={activity.messageId}
|
||||||
@@ -246,7 +252,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
|
|
||||||
case 'justification':
|
case 'justification':
|
||||||
return (
|
return (
|
||||||
<FadeInOnReveal key={animationKey}>
|
<FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
|
||||||
<JustificationBlock
|
<JustificationBlock
|
||||||
part={activity.part}
|
part={activity.part}
|
||||||
messageId={activity.messageId}
|
messageId={activity.messageId}
|
||||||
@@ -260,7 +266,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</FadeInOnReveal>
|
</FadeInOnReveal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
|||||||
isZombie: memoryState?.isZombie || false,
|
isZombie: memoryState?.isZombie || false,
|
||||||
backgroundCount: memoryState?.backgroundMessageCount || 0,
|
backgroundCount: memoryState?.backgroundMessageCount || 0,
|
||||||
lastAccessed: memoryState?.lastAccessedAt || 0,
|
lastAccessed: memoryState?.lastAccessedAt || 0,
|
||||||
activeTurnAnchorId: memoryState?.activeTurnAnchorId ?? null,
|
|
||||||
activeTurnSpacerHeight: memoryState?.activeTurnSpacerHeight ?? 0,
|
|
||||||
isCurrent: session.id === currentSessionId
|
isCurrent: session.id === currentSessionId
|
||||||
};
|
};
|
||||||
}).sort((a, b) => b.lastAccessed - a.lastAccessed);
|
}).sort((a, b) => b.lastAccessed - a.lastAccessed);
|
||||||
@@ -145,11 +143,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
|||||||
}`}>
|
}`}>
|
||||||
{stat.messageCount} msgs
|
{stat.messageCount} msgs
|
||||||
</span>
|
</span>
|
||||||
{stat.activeTurnAnchorId && stat.activeTurnSpacerHeight > 0 && (
|
|
||||||
<span className="font-mono text-xs text-primary">
|
|
||||||
anchor+{Math.round(stat.activeTurnSpacerHeight)}px
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{stat.backgroundCount > 0 && (
|
{stat.backgroundCount > 0 && (
|
||||||
<span className="text-primary">+{stat.backgroundCount}</span>
|
<span className="text-primary">+{stat.backgroundCount}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
|
||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||||
@@ -26,7 +25,6 @@ interface SessionMemoryState {
|
|||||||
isZombie?: boolean;
|
isZombie?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
|
|
||||||
|
|
||||||
interface UseChatScrollManagerOptions {
|
interface UseChatScrollManagerOptions {
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
@@ -35,13 +33,10 @@ interface UseChatScrollManagerOptions {
|
|||||||
streamingMessageId: string | null;
|
streamingMessageId: string | null;
|
||||||
sessionMemoryState: Map<string, SessionMemoryState>;
|
sessionMemoryState: Map<string, SessionMemoryState>;
|
||||||
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
||||||
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void;
|
|
||||||
getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null;
|
|
||||||
isSyncing: boolean;
|
isSyncing: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
messageStreamStates: Map<string, unknown>;
|
messageStreamStates: Map<string, unknown>;
|
||||||
trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
|
trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
|
||||||
sessionActivityPhase?: Map<string, SessionActivityPhase>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnimationHandlers {
|
export interface AnimationHandlers {
|
||||||
@@ -59,128 +54,76 @@ interface UseChatScrollManagerResult {
|
|||||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
showScrollButton: boolean;
|
showScrollButton: boolean;
|
||||||
scrollToBottom: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void;
|
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
spacerHeight: number;
|
scrollToPosition: (position: number, options?: { instant?: boolean }) => void;
|
||||||
pendingAnchorId: string | null;
|
isPinned: boolean;
|
||||||
hasActiveAnchor: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANCHOR_TARGET_OFFSET = 8;
|
|
||||||
const DEFAULT_SCROLL_BUTTON_THRESHOLD = 40;
|
|
||||||
const NEW_USER_ANCHOR_WINDOW_MS = 20_000;
|
|
||||||
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
||||||
// After we set an anchor/spacer, ignore incidental scroll events for a bit.
|
|
||||||
const ANCHOR_CLEAR_GRACE_MS = 1200;
|
|
||||||
// Require recent direct user input (wheel/touch) to treat scroll as intentional.
|
|
||||||
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
|
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
|
||||||
const ANCHOR_CLEAR_TOLERANCE_PX = 24;
|
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
|
||||||
|
const PIN_THRESHOLD_RATIO = 0.10;
|
||||||
const getMessageId = (message: ChatMessageRecord): string | null => {
|
|
||||||
const info = message.info;
|
|
||||||
if (typeof info?.id === 'string') {
|
|
||||||
return info.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isUserMessage = (message: ChatMessageRecord): boolean => {
|
|
||||||
const info = message.info;
|
|
||||||
if (info?.userMessageMarker === true) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const clientRole = info?.clientRole;
|
|
||||||
const serverRole = info?.role;
|
|
||||||
return clientRole === 'user' || serverRole === 'user';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMessageCreatedAt = (message: ChatMessageRecord): number => {
|
|
||||||
const info = message.info as { time?: { created?: unknown } };
|
|
||||||
const created = info?.time?.created;
|
|
||||||
return typeof created === 'number' ? created : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useChatScrollManager = ({
|
export const useChatScrollManager = ({
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
sessionMessages,
|
sessionMessages,
|
||||||
streamingMessageId,
|
|
||||||
updateViewportAnchor,
|
updateViewportAnchor,
|
||||||
updateActiveTurnAnchor,
|
|
||||||
getActiveTurnAnchor,
|
|
||||||
isSyncing,
|
isSyncing,
|
||||||
isMobile,
|
isMobile,
|
||||||
sessionActivityPhase,
|
|
||||||
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
|
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
|
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
|
||||||
|
|
||||||
const [anchorId, setAnchorId] = React.useState<string | null>(null);
|
const getPinThreshold = React.useCallback(() => {
|
||||||
const [spacerHeight, setSpacerHeight] = React.useState(0);
|
const container = scrollRef.current;
|
||||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
if (!container || container.clientHeight <= 0) {
|
||||||
const [pendingAnchorId, setPendingAnchorId] = React.useState<string | null>(null);
|
return 0;
|
||||||
|
}
|
||||||
|
const raw = container.clientHeight * PIN_THRESHOLD_RATIO;
|
||||||
|
return Math.max(24, Math.min(200, raw));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||||
|
const [isPinned, setIsPinned] = React.useState(true);
|
||||||
|
|
||||||
const lastScrolledAnchorIdRef = React.useRef<string | null>(null);
|
|
||||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||||
const currentSessionIdRef = React.useRef<string | null>(currentSessionId ?? null);
|
const currentSessionIdRef = React.useRef<string | null>(currentSessionId ?? null);
|
||||||
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
||||||
const anchorClearIgnoreUntilRef = React.useRef<number>(0);
|
|
||||||
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
|
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
|
||||||
const previousMessageIdsRef = React.useRef<Set<string>>(new Set());
|
const isPinnedRef = React.useRef(true);
|
||||||
const lastMessageCountRef = React.useRef<number>(sessionMessages.length);
|
const lastScrollTopRef = React.useRef<number>(0);
|
||||||
const spacerHeightRef = React.useRef(0);
|
|
||||||
|
|
||||||
const anchorIdRef = React.useRef<string | null>(null);
|
|
||||||
const pendingRestoreAnchorRef = React.useRef<{ sessionId: string; anchorId: string; startedAt: number } | null>(null);
|
|
||||||
|
|
||||||
const userScrollOverrideRef = React.useRef<boolean>(false);
|
|
||||||
|
|
||||||
const currentPhase = currentSessionId
|
|
||||||
? sessionActivityPhase?.get(currentSessionId) ?? 'idle'
|
|
||||||
: 'idle';
|
|
||||||
const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown';
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
currentSessionIdRef.current = currentSessionId ?? null;
|
currentSessionIdRef.current = currentSessionId ?? null;
|
||||||
}, [currentSessionId]);
|
}, [currentSessionId]);
|
||||||
|
|
||||||
const updateSpacerHeight = React.useCallback((height: number) => {
|
|
||||||
const newHeight = Math.max(0, height);
|
|
||||||
if (spacerHeightRef.current !== newHeight) {
|
|
||||||
spacerHeightRef.current = newHeight;
|
|
||||||
setSpacerHeight(newHeight);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const calculateAnchorPosition = React.useCallback((anchorElement: HTMLElement): number => {
|
|
||||||
const messageTop = anchorElement.offsetTop;
|
|
||||||
return messageTop - ANCHOR_TARGET_OFFSET;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isAnchorStillPinned = React.useCallback((): boolean => {
|
|
||||||
const container = scrollRef.current;
|
|
||||||
const anchorId = anchorIdRef.current;
|
|
||||||
if (!container || !anchorId) return false;
|
|
||||||
|
|
||||||
const anchorElement = container.querySelector(`[data-message-id="${anchorId}"]`) as HTMLElement | null;
|
|
||||||
if (!anchorElement) return false;
|
|
||||||
|
|
||||||
const expectedTop = calculateAnchorPosition(anchorElement);
|
|
||||||
const distance = Math.abs(container.scrollTop - expectedTop);
|
|
||||||
return distance <= ANCHOR_CLEAR_TOLERANCE_PX;
|
|
||||||
}, [calculateAnchorPosition]);
|
|
||||||
|
|
||||||
const clearActiveTurnAnchor = React.useCallback((sessionId: string) => {
|
|
||||||
anchorIdRef.current = null;
|
|
||||||
lastScrolledAnchorIdRef.current = null;
|
|
||||||
pendingRestoreAnchorRef.current = null;
|
|
||||||
setAnchorId(null);
|
|
||||||
updateSpacerHeight(0);
|
|
||||||
updateActiveTurnAnchor(sessionId, null, 0);
|
|
||||||
}, [updateActiveTurnAnchor, updateSpacerHeight]);
|
|
||||||
|
|
||||||
const markProgrammaticScroll = React.useCallback(() => {
|
const markProgrammaticScroll = React.useCallback(() => {
|
||||||
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getDistanceFromBottom = React.useCallback(() => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return 0;
|
||||||
|
return container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePinnedState = React.useCallback((newPinned: boolean) => {
|
||||||
|
if (isPinnedRef.current !== newPinned) {
|
||||||
|
isPinnedRef.current = newPinned;
|
||||||
|
setIsPinned(newPinned);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToBottomInternal = React.useCallback((options?: { instant?: boolean; followBottom?: boolean }) => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const bottom = container.scrollHeight - container.clientHeight;
|
||||||
|
markProgrammaticScroll();
|
||||||
|
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
|
||||||
|
}, [markProgrammaticScroll, scrollEngine]);
|
||||||
|
|
||||||
const updateScrollButtonVisibility = React.useCallback(() => {
|
const updateScrollButtonVisibility = React.useCallback(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -188,126 +131,35 @@ export const useChatScrollManager = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingAnchorId) {
|
|
||||||
setShowScrollButton(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasScrollableContent = container.scrollHeight > container.clientHeight;
|
const hasScrollableContent = container.scrollHeight > container.clientHeight;
|
||||||
if (!hasScrollableContent) {
|
if (!hasScrollableContent) {
|
||||||
setShowScrollButton(false);
|
setShowScrollButton(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
// Show scroll button when scrolled above the 10vh threshold
|
||||||
const currentSpacerHeight = spacerHeightRef.current;
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
|
setShowScrollButton(distanceFromBottom > getPinThreshold());
|
||||||
|
}, [getDistanceFromBottom, getPinThreshold]);
|
||||||
|
|
||||||
if (currentSpacerHeight > 0) {
|
const scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => {
|
||||||
const spacerStartPosition = container.scrollHeight - currentSpacerHeight;
|
|
||||||
const viewportBottom = container.scrollTop + container.clientHeight;
|
|
||||||
setShowScrollButton(viewportBottom < spacerStartPosition);
|
|
||||||
} else {
|
|
||||||
setShowScrollButton(distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD);
|
|
||||||
}
|
|
||||||
}, [pendingAnchorId]);
|
|
||||||
|
|
||||||
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => {
|
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
||||||
|
|
||||||
const shouldRespectUserScroll =
|
|
||||||
userScrollOverrideRef.current &&
|
|
||||||
currentPhase === 'idle' &&
|
|
||||||
!isSyncing &&
|
|
||||||
!options?.force &&
|
|
||||||
distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD;
|
|
||||||
|
|
||||||
if (shouldRespectUserScroll) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.force) {
|
|
||||||
userScrollOverrideRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.clearAnchor && currentSessionId && anchorIdRef.current) {
|
|
||||||
clearActiveTurnAnchor(currentSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bottom = container.scrollHeight - container.clientHeight;
|
|
||||||
markProgrammaticScroll();
|
markProgrammaticScroll();
|
||||||
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
|
scrollEngine.scrollToPosition(Math.max(0, position), options);
|
||||||
}, [clearActiveTurnAnchor, currentPhase, currentSessionId, isSyncing, markProgrammaticScroll, scrollEngine]);
|
}, [markProgrammaticScroll, scrollEngine]);
|
||||||
|
|
||||||
const scrollToNewAnchor = React.useCallback((messageId: string) => {
|
|
||||||
if (lastScrolledAnchorIdRef.current === messageId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastScrolledAnchorIdRef.current = messageId;
|
|
||||||
|
|
||||||
// Give the UI a grace window so incidental scroll/layout events don't clear the anchor.
|
|
||||||
anchorClearIgnoreUntilRef.current = Date.now() + ANCHOR_CLEAR_GRACE_MS;
|
|
||||||
|
|
||||||
setPendingAnchorId(messageId);
|
|
||||||
const expectedSessionId = currentSessionIdRef.current;
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
if (expectedSessionId !== currentSessionIdRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean }) => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) {
|
if (!container) return;
|
||||||
setPendingAnchorId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const anchorElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null;
|
// Re-pin when explicitly scrolling to bottom
|
||||||
if (!anchorElement) {
|
updatePinnedState(true);
|
||||||
setPendingAnchorId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerHeight = container.clientHeight;
|
scrollToBottomInternal(options);
|
||||||
const targetScrollTop = calculateAnchorPosition(anchorElement);
|
setShowScrollButton(false);
|
||||||
|
}, [scrollToBottomInternal, updatePinnedState]);
|
||||||
const contentHeight = container.scrollHeight;
|
|
||||||
const currentSpacer = spacerHeightRef.current;
|
|
||||||
const contentWithoutSpacer = contentHeight - currentSpacer;
|
|
||||||
const requiredHeight = targetScrollTop + containerHeight;
|
|
||||||
|
|
||||||
let newSpacerHeight = 0;
|
|
||||||
if (contentWithoutSpacer < requiredHeight) {
|
|
||||||
newSpacerHeight = requiredHeight - contentWithoutSpacer;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newSpacerHeight !== currentSpacer) {
|
|
||||||
updateSpacerHeight(newSpacerHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSessionIdRef.current) {
|
|
||||||
updateActiveTurnAnchor(currentSessionIdRef.current, messageId, newSpacerHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
if (expectedSessionId !== currentSessionIdRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
markProgrammaticScroll();
|
|
||||||
scrollEngine.scrollToPosition(Math.max(0, targetScrollTop), { instant: true });
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
if (expectedSessionId !== currentSessionIdRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPendingAnchorId(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [calculateAnchorPosition, markProgrammaticScroll, scrollEngine, updateActiveTurnAnchor, updateSpacerHeight]);
|
|
||||||
|
|
||||||
const handleScrollEvent = React.useCallback((event?: Event) => {
|
const handleScrollEvent = React.useCallback((event?: Event) => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
@@ -316,46 +168,44 @@ export const useChatScrollManager = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const isProgrammatic = now < suppressUserScrollUntilRef.current || pendingAnchorId !== null;
|
const isProgrammatic = now < suppressUserScrollUntilRef.current;
|
||||||
|
|
||||||
const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS;
|
const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS;
|
||||||
|
|
||||||
if (event?.isTrusted && !isProgrammatic && hasDirectIntent) {
|
|
||||||
userScrollOverrideRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollEngine.handleScroll();
|
scrollEngine.handleScroll();
|
||||||
updateScrollButtonVisibility();
|
updateScrollButtonVisibility();
|
||||||
|
|
||||||
const shouldIgnoreAnchorClear = now < anchorClearIgnoreUntilRef.current;
|
// Handle pin/unpin logic
|
||||||
|
const currentScrollTop = container.scrollTop;
|
||||||
|
|
||||||
if (
|
// Unpin requires strict user intent check
|
||||||
event?.isTrusted &&
|
if (event?.isTrusted && !isProgrammatic && hasDirectIntent) {
|
||||||
!isProgrammatic &&
|
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
|
||||||
!shouldIgnoreAnchorClear &&
|
if (scrollingUp && isPinnedRef.current) {
|
||||||
hasDirectIntent &&
|
updatePinnedState(false);
|
||||||
currentPhase === 'idle' &&
|
|
||||||
anchorIdRef.current !== null &&
|
|
||||||
spacerHeightRef.current > 0 &&
|
|
||||||
// Only clear when the user actually scrolls away from the pinned anchor.
|
|
||||||
// (Spacer being out of viewport is expected while anchored.)
|
|
||||||
!isAnchorStillPinned()
|
|
||||||
) {
|
|
||||||
clearActiveTurnAnchor(currentSessionId);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-pin at bottom should always work (even momentum scroll)
|
||||||
|
if (!isPinnedRef.current) {
|
||||||
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
|
if (distanceFromBottom <= getPinThreshold()) {
|
||||||
|
updatePinnedState(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollTopRef.current = currentScrollTop;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||||
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
|
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
|
||||||
const estimatedIndex = Math.floor(position * sessionMessages.length);
|
const estimatedIndex = Math.floor(position * sessionMessages.length);
|
||||||
updateViewportAnchor(currentSessionId, estimatedIndex);
|
updateViewportAnchor(currentSessionId, estimatedIndex);
|
||||||
}, [
|
}, [
|
||||||
clearActiveTurnAnchor,
|
|
||||||
currentPhase,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
isAnchorStillPinned,
|
getDistanceFromBottom,
|
||||||
pendingAnchorId,
|
getPinThreshold,
|
||||||
scrollEngine,
|
scrollEngine,
|
||||||
sessionMessages.length,
|
sessionMessages.length,
|
||||||
|
updatePinnedState,
|
||||||
updateScrollButtonVisibility,
|
updateScrollButtonVisibility,
|
||||||
updateViewportAnchor,
|
updateViewportAnchor,
|
||||||
]);
|
]);
|
||||||
@@ -379,6 +229,7 @@ export const useChatScrollManager = ({
|
|||||||
};
|
};
|
||||||
}, [handleScrollEvent]);
|
}, [handleScrollEvent]);
|
||||||
|
|
||||||
|
// Session switch - always start pinned at bottom
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -387,175 +238,69 @@ export const useChatScrollManager = ({
|
|||||||
lastSessionIdRef.current = currentSessionId;
|
lastSessionIdRef.current = currentSessionId;
|
||||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||||
|
|
||||||
previousMessageIdsRef.current = new Set(
|
// Always start pinned at bottom on session switch
|
||||||
sessionMessages.map(getMessageId).filter((id): id is string => Boolean(id))
|
updatePinnedState(true);
|
||||||
);
|
|
||||||
lastMessageCountRef.current = sessionMessages.length;
|
|
||||||
|
|
||||||
if (isActivePhase) {
|
|
||||||
const persistedAnchor = getActiveTurnAnchor(currentSessionId);
|
|
||||||
if (persistedAnchor && persistedAnchor.anchorId) {
|
|
||||||
anchorIdRef.current = persistedAnchor.anchorId;
|
|
||||||
lastScrolledAnchorIdRef.current = persistedAnchor.anchorId;
|
|
||||||
|
|
||||||
const container = scrollRef.current;
|
|
||||||
const anchorElement = container
|
|
||||||
? (container.querySelector(`[data-message-id="${persistedAnchor.anchorId}"]`) as HTMLElement | null)
|
|
||||||
: null;
|
|
||||||
const messageHeight = anchorElement?.offsetHeight ?? 0;
|
|
||||||
const restoredSpacerHeight = Math.max(0, persistedAnchor.spacerHeight - (messageHeight - 50));
|
|
||||||
|
|
||||||
flushSync(() => {
|
|
||||||
setAnchorId(persistedAnchor.anchorId);
|
|
||||||
updateSpacerHeight(restoredSpacerHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
pendingRestoreAnchorRef.current = { sessionId: currentSessionId, anchorId: persistedAnchor.anchorId, startedAt: Date.now() };
|
|
||||||
} else {
|
|
||||||
lastScrolledAnchorIdRef.current = null;
|
|
||||||
anchorIdRef.current = null;
|
|
||||||
setAnchorId(null);
|
|
||||||
updateSpacerHeight(0);
|
|
||||||
pendingRestoreAnchorRef.current = null;
|
|
||||||
|
|
||||||
const container = scrollRef.current;
|
|
||||||
if (container) {
|
|
||||||
const bottom = container.scrollHeight - container.clientHeight;
|
|
||||||
markProgrammaticScroll();
|
|
||||||
scrollEngine.scrollToPosition(Math.max(0, bottom), { instant: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lastScrolledAnchorIdRef.current = null;
|
|
||||||
anchorIdRef.current = null;
|
|
||||||
setAnchorId(null);
|
|
||||||
updateSpacerHeight(0);
|
|
||||||
pendingRestoreAnchorRef.current = null;
|
|
||||||
updateActiveTurnAnchor(currentSessionId, null, 0);
|
|
||||||
|
|
||||||
const container = scrollRef.current;
|
|
||||||
if (container) {
|
|
||||||
const bottom = container.scrollHeight - container.clientHeight;
|
|
||||||
markProgrammaticScroll();
|
|
||||||
scrollEngine.scrollToPosition(Math.max(0, bottom), { instant: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingAnchorId(null);
|
|
||||||
setShowScrollButton(false);
|
setShowScrollButton(false);
|
||||||
userScrollOverrideRef.current = false;
|
|
||||||
}, [
|
|
||||||
currentSessionId,
|
|
||||||
getActiveTurnAnchor,
|
|
||||||
isActivePhase,
|
|
||||||
markProgrammaticScroll,
|
|
||||||
scrollEngine,
|
|
||||||
updateActiveTurnAnchor,
|
|
||||||
updateSpacerHeight,
|
|
||||||
sessionMessages,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useIsomorphicLayoutEffect(() => {
|
const container = scrollRef.current;
|
||||||
if (typeof window === 'undefined') return;
|
if (container) {
|
||||||
if (!currentSessionId) return;
|
markProgrammaticScroll();
|
||||||
|
scrollToBottomInternal({ instant: true });
|
||||||
|
}
|
||||||
|
}, [currentSessionId, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]);
|
||||||
|
|
||||||
const pending = pendingRestoreAnchorRef.current;
|
// Maintain pin-to-bottom when content changes
|
||||||
if (!pending || pending.sessionId !== currentSessionId) return;
|
React.useEffect(() => {
|
||||||
|
if (!isPinnedRef.current) return;
|
||||||
|
if (isSyncing) return;
|
||||||
|
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const anchorElement = container.querySelector(`[data-message-id="${pending.anchorId}"]`) as HTMLElement | null;
|
// When pinned and content grows, scroll to bottom instantly
|
||||||
if (!anchorElement) {
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
// When the anchor is created from a just-sent user message, the persisted anchor can
|
if (distanceFromBottom > getPinThreshold()) {
|
||||||
// show up before the message is in the rendered list. Give it a short window.
|
|
||||||
if (Date.now() - pending.startedAt < 1200) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearActiveTurnAnchor(currentSessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetScrollTop = calculateAnchorPosition(anchorElement);
|
|
||||||
markProgrammaticScroll();
|
markProgrammaticScroll();
|
||||||
scrollEngine.scrollToPosition(targetScrollTop, { instant: true });
|
scrollToBottomInternal({ instant: true });
|
||||||
pendingRestoreAnchorRef.current = null;
|
|
||||||
}, [calculateAnchorPosition, clearActiveTurnAnchor, currentSessionId, markProgrammaticScroll, scrollEngine, sessionMessages]);
|
|
||||||
|
|
||||||
useIsomorphicLayoutEffect(() => {
|
|
||||||
if (isSyncing) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [getDistanceFromBottom, getPinThreshold, isSyncing, markProgrammaticScroll, scrollToBottomInternal, sessionMessages]);
|
||||||
|
|
||||||
if (lastSessionIdRef.current !== currentSessionId) {
|
// Use ResizeObserver to detect content changes and maintain pin
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousIds = previousMessageIdsRef.current;
|
|
||||||
const nextIds = new Set(sessionMessages.map(getMessageId).filter((id): id is string => Boolean(id)));
|
|
||||||
const nextCount = sessionMessages.length;
|
|
||||||
|
|
||||||
if (nextCount > lastMessageCountRef.current) {
|
|
||||||
const addedIds: string[] = [];
|
|
||||||
nextIds.forEach((id) => {
|
|
||||||
if (!previousIds.has(id)) {
|
|
||||||
addedIds.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (addedIds.length > 0) {
|
|
||||||
const now = Date.now();
|
|
||||||
let latestNewUserMessageId: string | null = null;
|
|
||||||
let latestNewUserCreatedAt = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < sessionMessages.length; i++) {
|
|
||||||
const message = sessionMessages[i];
|
|
||||||
const id = getMessageId(message);
|
|
||||||
if (!id || !addedIds.includes(id)) continue;
|
|
||||||
if (!isUserMessage(message)) continue;
|
|
||||||
|
|
||||||
let createdAt = getMessageCreatedAt(message);
|
|
||||||
if (createdAt <= 0 && (Boolean(streamingMessageId) || isActivePhase)) {
|
|
||||||
createdAt = now;
|
|
||||||
}
|
|
||||||
if (createdAt >= latestNewUserCreatedAt) {
|
|
||||||
latestNewUserCreatedAt = createdAt;
|
|
||||||
latestNewUserMessageId = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldAnchorNewUser =
|
|
||||||
latestNewUserMessageId !== null &&
|
|
||||||
(Boolean(streamingMessageId) ||
|
|
||||||
isActivePhase ||
|
|
||||||
now - latestNewUserCreatedAt <= NEW_USER_ANCHOR_WINDOW_MS);
|
|
||||||
|
|
||||||
if (shouldAnchorNewUser && latestNewUserMessageId) {
|
|
||||||
anchorIdRef.current = latestNewUserMessageId;
|
|
||||||
setAnchorId(latestNewUserMessageId);
|
|
||||||
scrollToNewAnchor(latestNewUserMessageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastMessageCountRef.current = nextCount;
|
|
||||||
previousMessageIdsRef.current = nextIds;
|
|
||||||
}, [currentSessionId, isActivePhase, isSyncing, scrollToNewAnchor, sessionMessages, streamingMessageId]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container || typeof ResizeObserver === 'undefined') return;
|
if (!container || typeof ResizeObserver === 'undefined') return;
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
updateScrollButtonVisibility();
|
updateScrollButtonVisibility();
|
||||||
|
|
||||||
|
// Maintain pin when content grows - always instant for smooth experience
|
||||||
|
if (isPinnedRef.current) {
|
||||||
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
|
if (distanceFromBottom > getPinThreshold()) {
|
||||||
|
scrollToBottomInternal({ instant: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(container);
|
observer.observe(container);
|
||||||
|
|
||||||
|
// Also observe children for content changes
|
||||||
|
const childObserver = new MutationObserver(() => {
|
||||||
|
if (isPinnedRef.current) {
|
||||||
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
|
if (distanceFromBottom > getPinThreshold()) {
|
||||||
|
scrollToBottomInternal({ instant: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
childObserver.observe(container, { childList: true, subtree: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
|
childObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [updateScrollButtonVisibility]);
|
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -572,21 +317,19 @@ export const useChatScrollManager = ({
|
|||||||
};
|
};
|
||||||
}, [currentSessionId, sessionMessages.length, updateScrollButtonVisibility]);
|
}, [currentSessionId, sessionMessages.length, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (anchorId) {
|
|
||||||
updateScrollButtonVisibility();
|
|
||||||
}
|
|
||||||
}, [anchorId, updateScrollButtonVisibility]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
updateScrollButtonVisibility();
|
|
||||||
}, [spacerHeight, updateScrollButtonVisibility]);
|
|
||||||
|
|
||||||
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
||||||
|
|
||||||
const handleMessageContentChange = React.useCallback(() => {
|
const handleMessageContentChange = React.useCallback(() => {
|
||||||
updateScrollButtonVisibility();
|
updateScrollButtonVisibility();
|
||||||
}, [updateScrollButtonVisibility]);
|
|
||||||
|
// Maintain pin when content changes - always instant
|
||||||
|
if (isPinnedRef.current) {
|
||||||
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
|
if (distanceFromBottom > getPinThreshold()) {
|
||||||
|
scrollToBottomInternal({ instant: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
||||||
const existing = animationHandlersRef.current.get(messageId);
|
const existing = animationHandlersRef.current.get(messageId);
|
||||||
@@ -597,30 +340,34 @@ export const useChatScrollManager = ({
|
|||||||
const handlers: AnimationHandlers = {
|
const handlers: AnimationHandlers = {
|
||||||
onChunk: () => {
|
onChunk: () => {
|
||||||
updateScrollButtonVisibility();
|
updateScrollButtonVisibility();
|
||||||
|
if (isPinnedRef.current) {
|
||||||
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
|
if (distanceFromBottom > getPinThreshold()) {
|
||||||
|
scrollToBottomInternal({ instant: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
updateScrollButtonVisibility();
|
updateScrollButtonVisibility();
|
||||||
},
|
},
|
||||||
onStreamingCandidate: () => {
|
onStreamingCandidate: () => {},
|
||||||
|
onAnimationStart: () => {},
|
||||||
},
|
|
||||||
onAnimationStart: () => {
|
|
||||||
|
|
||||||
},
|
|
||||||
onAnimatedHeightChange: () => {
|
onAnimatedHeightChange: () => {
|
||||||
updateScrollButtonVisibility();
|
updateScrollButtonVisibility();
|
||||||
|
if (isPinnedRef.current) {
|
||||||
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
|
if (distanceFromBottom > getPinThreshold()) {
|
||||||
|
scrollToBottomInternal({ instant: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onReservationCancelled: () => {
|
onReservationCancelled: () => {},
|
||||||
|
onReasoningBlock: () => {},
|
||||||
},
|
|
||||||
onReasoningBlock: () => {
|
|
||||||
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
animationHandlersRef.current.set(messageId, handlers);
|
animationHandlersRef.current.set(messageId, handlers);
|
||||||
return handlers;
|
return handlers;
|
||||||
}, [updateScrollButtonVisibility]);
|
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scrollRef,
|
scrollRef,
|
||||||
@@ -628,8 +375,7 @@ export const useChatScrollManager = ({
|
|||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
showScrollButton,
|
showScrollButton,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
spacerHeight,
|
scrollToPosition,
|
||||||
pendingAnchorId,
|
isPinned,
|
||||||
hasActiveAnchor: anchorId !== null,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client';
|
import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client';
|
||||||
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { getActiveSessionWindow } from '@/stores/types/sessionTypes';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
@@ -276,7 +277,7 @@ export const useEventStream = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const resyncMessages = React.useCallback(
|
const resyncMessages = React.useCallback(
|
||||||
(sessionId: string, reason: string) => {
|
(sessionId: string, reason: string, limit?: number) => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
@@ -287,7 +288,7 @@ export const useEventStream = () => {
|
|||||||
if (now - lastResyncAtRef.current < RESYNC_DEBOUNCE_MS) {
|
if (now - lastResyncAtRef.current < RESYNC_DEBOUNCE_MS) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
const task = loadMessages(sessionId)
|
const task = loadMessages(sessionId, limit)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn(`[useEventStream] Failed to resync messages (${reason}):`, error);
|
console.warn(`[useEventStream] Failed to resync messages (${reason}):`, error);
|
||||||
})
|
})
|
||||||
@@ -309,7 +310,7 @@ export const useEventStream = () => {
|
|||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadSessions(),
|
loadSessions(),
|
||||||
currentSessionId ? resyncMessages(currentSessionId, reason) : Promise.resolve(),
|
currentSessionId ? resyncMessages(currentSessionId, reason, Infinity) : Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[useEventStream] Bootstrap failed:', reason, error);
|
console.warn('[useEventStream] Bootstrap failed:', reason, error);
|
||||||
@@ -1451,7 +1452,7 @@ export const useEventStream = () => {
|
|||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resyncMessages(sessionId, 'sse_reconnected')
|
resyncMessages(sessionId, 'sse_reconnected', Infinity)
|
||||||
.then(() => requestSessionMetadataRefresh(sessionId))
|
.then(() => requestSessionMetadataRefresh(sessionId))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
||||||
@@ -1618,7 +1619,7 @@ export const useEventStream = () => {
|
|||||||
console.info('[useEventStream] Visibility restored, triggering soft refresh...');
|
console.info('[useEventStream] Visibility restored, triggering soft refresh...');
|
||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
resyncMessages(sessionId, 'visibility_restore').catch(() => {});
|
resyncMessages(sessionId, 'visibility_restore', getActiveSessionWindow()).catch(() => {});
|
||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1644,7 +1645,7 @@ export const useEventStream = () => {
|
|||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
resyncMessages(sessionId, 'window_focus')
|
resyncMessages(sessionId, 'window_focus', getActiveSessionWindow())
|
||||||
.then(() => console.info('[useEventStream] Messages refreshed on focus'))
|
.then(() => console.info('[useEventStream] Messages refreshed on focus'))
|
||||||
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
|
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type ScrollEngineOptions = {
|
|||||||
|
|
||||||
type ScrollOptions = {
|
type ScrollOptions = {
|
||||||
instant?: boolean;
|
instant?: boolean;
|
||||||
|
followBottom?: boolean; // Dynamically track bottom during animation
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScrollEngineResult = {
|
type ScrollEngineResult = {
|
||||||
@@ -33,6 +34,7 @@ export const useScrollEngine = ({
|
|||||||
const animationStartRef = React.useRef<number | null>(null);
|
const animationStartRef = React.useRef<number | null>(null);
|
||||||
const animationFromRef = React.useRef(0);
|
const animationFromRef = React.useRef(0);
|
||||||
const animationTargetRef = React.useRef(0);
|
const animationTargetRef = React.useRef(0);
|
||||||
|
const followBottomRef = React.useRef(false);
|
||||||
|
|
||||||
const cancelAnimation = React.useCallback(() => {
|
const cancelAnimation = React.useCallback(() => {
|
||||||
if (animationFrameRef.current !== null && typeof window !== 'undefined') {
|
if (animationFrameRef.current !== null && typeof window !== 'undefined') {
|
||||||
@@ -41,6 +43,7 @@ export const useScrollEngine = ({
|
|||||||
|
|
||||||
animationFrameRef.current = null;
|
animationFrameRef.current = null;
|
||||||
animationStartRef.current = null;
|
animationStartRef.current = null;
|
||||||
|
followBottomRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runAnimationFrame = React.useCallback(
|
const runAnimationFrame = React.useCallback(
|
||||||
@@ -55,6 +58,11 @@ export const useScrollEngine = ({
|
|||||||
animationStartRef.current = timestamp;
|
animationStartRef.current = timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If followBottom mode, dynamically update target to current bottom
|
||||||
|
if (followBottomRef.current) {
|
||||||
|
animationTargetRef.current = container.scrollHeight - container.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
const progress = Math.min(1, (timestamp - animationStartRef.current) / ANIMATION_DURATION_MS);
|
const progress = Math.min(1, (timestamp - animationStartRef.current) / ANIMATION_DURATION_MS);
|
||||||
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
||||||
const from = animationFromRef.current;
|
const from = animationFromRef.current;
|
||||||
@@ -86,6 +94,7 @@ export const useScrollEngine = ({
|
|||||||
|
|
||||||
const target = Math.max(0, position);
|
const target = Math.max(0, position);
|
||||||
const preferInstant = options?.instant ?? false;
|
const preferInstant = options?.instant ?? false;
|
||||||
|
const followBottom = options?.followBottom ?? false;
|
||||||
|
|
||||||
manualOverrideRef.current = false;
|
manualOverrideRef.current = false;
|
||||||
|
|
||||||
@@ -102,6 +111,11 @@ export const useScrollEngine = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If followBottom animation is already running, don't restart - let it continue
|
||||||
|
if (followBottom && followBottomRef.current && animationFrameRef.current !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
cancelAnimation();
|
cancelAnimation();
|
||||||
|
|
||||||
const distance = Math.abs(target - container.scrollTop);
|
const distance = Math.abs(target - container.scrollTop);
|
||||||
@@ -120,6 +134,7 @@ export const useScrollEngine = ({
|
|||||||
animationFromRef.current = container.scrollTop;
|
animationFromRef.current = container.scrollTop;
|
||||||
animationTargetRef.current = target;
|
animationTargetRef.current = target;
|
||||||
animationStartRef.current = null;
|
animationStartRef.current = null;
|
||||||
|
followBottomRef.current = followBottom;
|
||||||
animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame);
|
animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame);
|
||||||
},
|
},
|
||||||
[cancelAnimation, containerRef, runAnimationFrame, setIsAtTop]
|
[cancelAnimation, containerRef, runAnimationFrame, setIsAtTop]
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ interface MessageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MessageActions {
|
interface MessageActions {
|
||||||
loadMessages: (sessionId: string) => Promise<void>;
|
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
||||||
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
|
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
|
||||||
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
|
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
|
||||||
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
||||||
@@ -354,8 +354,6 @@ interface MessageActions {
|
|||||||
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void;
|
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void;
|
||||||
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void;
|
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void;
|
||||||
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
||||||
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void;
|
|
||||||
getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null;
|
|
||||||
trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => void;
|
trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => void;
|
||||||
evictLeastRecentlyUsed: (currentSessionId?: string) => void;
|
evictLeastRecentlyUsed: (currentSessionId?: string) => void;
|
||||||
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
|
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
|
||||||
@@ -387,10 +385,12 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
|
|
||||||
loadMessages: async (sessionId: string, limit?: number) => {
|
loadMessages: async (sessionId: string, limit?: number) => {
|
||||||
const memLimits = getMemoryLimits();
|
const memLimits = getMemoryLimits();
|
||||||
const effectiveLimit = limit ?? memLimits.HISTORICAL_MESSAGES;
|
const noLimit = limit === Infinity;
|
||||||
|
const effectiveLimit = noLimit ? Infinity : (limit ?? memLimits.HISTORICAL_MESSAGES);
|
||||||
const isStreaming = get().sessionMemoryState.get(sessionId)?.isStreaming;
|
const isStreaming = get().sessionMemoryState.get(sessionId)?.isStreaming;
|
||||||
const targetLimit = isStreaming ? memLimits.VIEWPORT_MESSAGES : effectiveLimit;
|
const targetLimit = isStreaming ? memLimits.VIEWPORT_MESSAGES : effectiveLimit;
|
||||||
const fetchLimit = isStreaming ? undefined : targetLimit + memLimits.FETCH_BUFFER;
|
// Don't pass Infinity to API - use undefined for "fetch all"
|
||||||
|
const fetchLimit = isStreaming || noLimit ? undefined : targetLimit + memLimits.FETCH_BUFFER;
|
||||||
const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit));
|
const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit));
|
||||||
|
|
||||||
// Filter out reverted messages first
|
// Filter out reverted messages first
|
||||||
@@ -2201,34 +2201,6 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => {
|
|
||||||
set((state) => {
|
|
||||||
const memoryState = state.sessionMemoryState.get(sessionId) || {
|
|
||||||
viewportAnchor: 0,
|
|
||||||
isStreaming: false,
|
|
||||||
lastAccessedAt: Date.now(),
|
|
||||||
backgroundMessageCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const newMemoryState = new Map(state.sessionMemoryState);
|
|
||||||
newMemoryState.set(sessionId, {
|
|
||||||
...memoryState,
|
|
||||||
activeTurnAnchorId: anchorId ?? undefined,
|
|
||||||
activeTurnSpacerHeight: spacerHeight,
|
|
||||||
});
|
|
||||||
return { sessionMemoryState: newMemoryState };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getActiveTurnAnchor: (sessionId: string) => {
|
|
||||||
const memoryState = get().sessionMemoryState.get(sessionId);
|
|
||||||
if (!memoryState) return null;
|
|
||||||
return {
|
|
||||||
anchorId: memoryState.activeTurnAnchorId ?? null,
|
|
||||||
spacerHeight: memoryState.activeTurnSpacerHeight ?? 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => {
|
trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => {
|
||||||
const effectiveTargetSize = targetSize ?? getMemoryLimits().VIEWPORT_MESSAGES;
|
const effectiveTargetSize = targetSize ?? getMemoryLimits().VIEWPORT_MESSAGES;
|
||||||
const state = get();
|
const state = get();
|
||||||
@@ -2502,8 +2474,6 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
totalAvailableMessages: memory.totalAvailableMessages,
|
totalAvailableMessages: memory.totalAvailableMessages,
|
||||||
hasMoreAbove: memory.hasMoreAbove,
|
hasMoreAbove: memory.hasMoreAbove,
|
||||||
trimmedHeadMaxId: memory.trimmedHeadMaxId,
|
trimmedHeadMaxId: memory.trimmedHeadMaxId,
|
||||||
activeTurnAnchorId: memory.activeTurnAnchorId,
|
|
||||||
activeTurnSpacerHeight: memory.activeTurnSpacerHeight,
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [
|
sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ export interface SessionMemoryState {
|
|||||||
hasMoreAbove?: boolean;
|
hasMoreAbove?: boolean;
|
||||||
trimmedHeadMaxId?: string;
|
trimmedHeadMaxId?: string;
|
||||||
streamingCooldownUntil?: number;
|
streamingCooldownUntil?: number;
|
||||||
/** Message ID of the user's active turn anchor (for scroll position preservation) */
|
|
||||||
activeTurnAnchorId?: string;
|
|
||||||
/** Height of the spacer below messages for active turn positioning */
|
|
||||||
activeTurnSpacerHeight?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionContextUsage {
|
export interface SessionContextUsage {
|
||||||
@@ -163,7 +159,7 @@ export interface SessionStore {
|
|||||||
shareSession: (id: string) => Promise<Session | null>;
|
shareSession: (id: string) => Promise<Session | null>;
|
||||||
unshareSession: (id: string) => Promise<Session | null>;
|
unshareSession: (id: string) => Promise<Session | null>;
|
||||||
setCurrentSession: (id: string | null) => void;
|
setCurrentSession: (id: string | null) => void;
|
||||||
loadMessages: (sessionId: string) => Promise<void>;
|
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
||||||
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
|
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
|
||||||
abortCurrentOperation: () => Promise<void>;
|
abortCurrentOperation: () => Promise<void>;
|
||||||
acknowledgeSessionAbort: (sessionId: string) => void;
|
acknowledgeSessionAbort: (sessionId: string) => void;
|
||||||
@@ -197,8 +193,6 @@ export interface SessionStore {
|
|||||||
clearAttachedFiles: () => void;
|
clearAttachedFiles: () => void;
|
||||||
|
|
||||||
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
||||||
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void;
|
|
||||||
getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null;
|
|
||||||
trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
|
trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
|
||||||
evictLeastRecentlyUsed: () => void;
|
evictLeastRecentlyUsed: () => void;
|
||||||
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
|
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
|
|
||||||
get().evictLeastRecentlyUsed();
|
get().evictLeastRecentlyUsed();
|
||||||
},
|
},
|
||||||
loadMessages: (sessionId: string) => useMessageStore.getState().loadMessages(sessionId),
|
loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit),
|
||||||
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => {
|
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => {
|
||||||
const draft = get().newSessionDraft;
|
const draft = get().newSessionDraft;
|
||||||
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
|
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
|
||||||
@@ -502,11 +502,9 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(),
|
clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(),
|
||||||
|
|
||||||
updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor),
|
updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor),
|
||||||
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => useMessageStore.getState().updateActiveTurnAnchor(sessionId, anchorId, spacerHeight),
|
|
||||||
getActiveTurnAnchor: (sessionId: string) => useMessageStore.getState().getActiveTurnAnchor(sessionId),
|
|
||||||
trimToViewportWindow: (sessionId: string, targetSize?: number) => {
|
trimToViewportWindow: (sessionId: string, targetSize?: number) => {
|
||||||
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
|
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
|
||||||
// Skip trimming for sessions in active phase (busy/cooldown) to preserve anchor/spacer
|
// Skip trimming for sessions in active phase (busy/cooldown)
|
||||||
const phase = get().sessionActivityPhase?.get(sessionId);
|
const phase = get().sessionActivityPhase?.get(sessionId);
|
||||||
if (phase === 'busy' || phase === 'cooldown') {
|
if (phase === 'busy' || phase === 'cooldown') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user