refactor(chat): complete turn-based pipeline and stabilize streaming, scroll, and tool UX (#629)
* refactor: add canonical turn projection pipeline for chat Centralizes turn/activity/summary derivation, migrates render consumers to shared projection sources, and adds projector regression coverage for retries, malformed parent fallbacks, and activity segmentation. * refactor: stream assistant text live in turn-first render path * refactor: complete phase-3 turn timeline history/navigation * refactor: complete phase-4 turn pipeline cutover Remove legacy grouping adapters and lock key edge cases with executable regression tests so turn-centric rendering stays stable across retry/history/navigation flows. * fix: align chat history loading path with opencode parity * fix: stabilize chat streaming and reduce render churn * feat: add blurIn streaming text animation and smooth lerp-based auto-scroll Streaming markdown now uses Streamdown blurIn animation (150ms, ease-out) for per-word reveal Auto-scroll during streaming uses continuous exponential smoothing (lerp) instead of restarting spring animations One-shot scroll-to-bottom (button click) uses motion spring for natural deceleration * fix: respect user scroll-up during streaming and re-pin on scroll back to bottom * fix: stabilize activity stream and tool progress rendering Activity now stays chronologically consistent and avoids duplicate reasoning/justification. Tool rows show running state and duration earlier with smoother pending behavior. Removed legacy trim/timestamp paths to prevent message drops and simplify Activity UI. * fix: reverted regression of revert functionality * fix: stabilize chat activity layout and tool progress behavior Stopped user messages from re-animating when new user messages appear. Made Activity and aggregated tool rows wrap inline consistently without misaligned headers/icons. Fixed tool part matching so duration and shine no longer reset across pending/in-progress status updates. * fix: restore activity mode behavior and stabilize tool rendering Re-enabled Collapsed/Summary/Detailed/Changes defaults for Activity and tool expansion. Fixed tool row wrapping/alignment, removed user fade re-animations, and disabled FadeInOnReveal animations. Improved tool status handling to reduce timer/shine resets and made reasoning bold render as normal text. * refactor: simplify chat tool and message rendering Replace activity-group rendering with flat inline part rendering Remove obsolete tool output and justification activity settings Keep user message animation and improve edit/apply-patch file path headers * style: narrow chat and composer columns equally * fix: Tool status checks and abort flow regression * fix: improve multi-file apply patch header rendering * fix: make timeline message jump scroll to selected chat entry Conversation Timeline clicks now reliably scroll to the selected message in chat Timeline dialog waits for navigation result before closing to avoid silent failures Chat message scrolling now resolves the active chat scroller consistently * chore: remove branch-added chat tests and clean lint leftovers Removes newly added chat and message test files from this branch Fixes lint blockers in ProgressiveGroup, ToolPart, and messageStore Keeps type-check, lint, and build passing after cleanup * fix: keep composer status row closest to the input Status/todo row now renders below queued messages and attachments. Linked GitHub issue/PR chips stay above status so input context order is consistent. Improves chat composer readability while assistant is working. * fix: disable assistant header animations and polish chat status behavior Assistant message header now renders without transition effects Removed a lint issue from the working placeholder props Chat status and message flow updates improve consistency during active turns * feat: increase bottom spacer in mobile chat for better scrolling * feat: add sorted chat mode with progressive activity rendering Adds global chat render mode settings for sorted and live behavior Renders non-stop assistant output in Activity as messages complete Disables streaming text animation in sorted mode and restores classic reasoning/justification blocks * fix: stabilize chat timeline navigation and older history loading Prevents auto-loading older history from scroll and keeps loading behind the button Fixes timeline jump-to-message behavior that snapped back to the bottom Reduces chat list flicker and empty-state glitches on large session pagination * fix: stabilize sorted activity text and hide reasoning timers Prevented sorted justification from appearing before message completion by requiring explicit non-stop finish. Removed duration values for sorted Thinking and Justification blocks. Kept smooth sorted auto-follow behavior tuned to reduce bottom-jump jitter. * refactor: simplify chat tool rendering paths and add parts docs Centralized tool icon mapping into a shared presentation helper used by grouped and expandable rows. Removed static-tool-specific branches from ToolPart to keep it focused on expandable tools. Added a new DOCUMENTATION.md explaining where to change tool descriptions and rendering behavior. * feat: enhance event stream handling for message parts - Refactor event key building functions to improve clarity and reduce redundancy. - Introduce `applyPartDelta` method in message store for handling delta updates to message parts. - Update `useEventStream` to utilize the new `applyPartDelta` method for processing incoming deltas. - Enhance text extraction logic to accommodate new part types and improve merging behavior. - Implement logic to skip delta-driven updates for ongoing text/reasoning parts to prevent overwriting. - Adjust server-side text merging logic to handle trimmed text comparisons and improve consistency. * feat: enhance OpencodeService with delta handling and stale delta management - Introduced globalSseStaleDeltas to track stale deltas. - Added methods to generate keys for delta and updated part events. - Updated global SSE loop to skip processing stale deltas. - Refactored global SSE event handling to improve performance and reliability. refactor: remove unused streaming part handling in messageStore - Eliminated streaming part queue and associated logic. - Simplified message merging logic by removing redundant checks. - Streamlined message handling to improve clarity and maintainability. chore: clean up unused streaming text normalization functions - Removed deprecated functions related to streaming text identity and merging. - Simplified normalizeStreamingTextPayload to focus on current functionality. * refactor: simplify streaming event passthrough Use SDK-native global event streaming in the UI client Remove dead server-side SSE normalization helpers Keep streaming payloads closer to upstream behavior * revert: remove message memory settings UI Drop the restored message memory settings screen Keep session settings focused on current defaults Preserve chat history limits as internal behavior * feat: add compact collapsed Activity preview in sorted mode Collapsed Activity now shows a live preview of the latest 7 entries Older activity rows roll up behind a +N more indicator The +N more indicator is clickable to expand the full Activity * fix: adjusted activity changed files text prominence * fix: make sorted mode snap to bottom when not streaming * perf: show chat scrollbar only on user-driven scrolling Chat scrollbar now stays hidden during automatic follow-to-bottom Scrollbar visibility is gated by real user scroll intent Reduced scroll overhead by removing timer-based intent tracking * refactor: align web and vscode session activity streaming Route VS Code SSE through SDK-based proxy flow Restore controlled web session activity tracking and cooldowns Keep chat activity behavior consistent across runtimes * fix: adjusted muted foreground themes color * fix: stabilize session activity and attention updates Keep streamed chat state in sync as sessions complete Restore reliable attention indicators for inactive sessions Tighten server and UI event handling for chat updates * feat: add chat render previews and file icon visibility setting Added animated chat render mode previews in Chat settings. Added a new toggle to show or hide file icons across tool rows. Persisted chat render, activity, and mermaid modes to shared settings file. * fix: update default GitHub OAuth client ID Use the new OpenChamber org GitHub OAuth app ID by default. Keep web server and VS Code auth defaults in sync. * fix: sync chat settings across VS Code chat views Broadcast settings changes to both sidebar and editor chat webviews. Apply synced settings in each webview via shared settings refresh. Ensures chat render mode updates appear immediately in editor tabs. * fix: stop writing session folders file in VS Code workspaces Disable disk persistence for session folders in VS Code webview runtime Keep session folder state in VS Code/webview storage instead of project files Preserve existing behavior for non-VS Code runtimes * style: polish expanded tool panels and bash output readability Removed extra borders/labels in expanded tool cards for a cleaner UI Added softer scroll shadows and full-height decorative activity line Switched bash command/output to code-style typography with tighter spacing * feat: add default-open tool controls for chat activity Added Chat settings toggles to open Bash and Edit tools by default Default-open behavior now applies across both sorted and live activity rendering Users can still manually collapse default-open tools, plus bash output height was reduced * fix: align chat loading skeleton with message layout Match skeleton width and horizontal alignment to chat message column Add one-line tool-style skeleton rows with subtle circular placeholders Increase top spacing so loading state no longer touches chat header * fix: prevent blank screen when adding inline diff comments Sanitize persisted inline comment drafts to ignore invalid data Harden diff annotations against malformed line numbers Fail safely when annotation updates error instead of crashing the view * fix: streamline VS Code archived sessions sidebar Show archived sessions in VS Code only for the active workspace directory. Render archived sessions as a flat list in VS Code without project-root subfolders. Hide the "Open in Side Panel" session action in VS Code.
This commit is contained in:
committed by
GitHub
parent
d969ff4af1
commit
fab32ca992
@@ -2,6 +2,12 @@ import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
||||
import {
|
||||
isNearBottom,
|
||||
normalizeWheelDelta,
|
||||
shouldPauseAutoScrollOnWheel,
|
||||
} from '@/components/chat/lib/scroll/scrollIntent';
|
||||
|
||||
import { useScrollEngine } from './useScrollEngine';
|
||||
|
||||
@@ -34,8 +40,9 @@ interface UseChatScrollManagerOptions {
|
||||
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
||||
isSyncing: boolean;
|
||||
isMobile: boolean;
|
||||
chatRenderMode?: 'sorted' | 'live';
|
||||
messageStreamStates: Map<string, unknown>;
|
||||
trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
|
||||
onActiveTurnChange?: (turnId: string | null) => void;
|
||||
}
|
||||
|
||||
export interface AnimationHandlers {
|
||||
@@ -55,13 +62,19 @@ interface UseChatScrollManagerResult {
|
||||
showScrollButton: boolean;
|
||||
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToPosition: (position: number, options?: { instant?: boolean }) => void;
|
||||
releasePinnedScroll: () => void;
|
||||
isPinned: boolean;
|
||||
isOverflowing: boolean;
|
||||
isProgrammaticFollowActive: boolean;
|
||||
}
|
||||
|
||||
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
||||
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
|
||||
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
|
||||
const PIN_THRESHOLD_RATIO = 0.10;
|
||||
const REPIN_BLOCK_AFTER_RELEASE_MS = 4000;
|
||||
const STRICT_REPIN_DISTANCE_PX = 160;
|
||||
const SORTED_PIN_THRESHOLD_PX = 24;
|
||||
|
||||
export const useChatScrollManager = ({
|
||||
currentSessionId,
|
||||
@@ -70,6 +83,8 @@ export const useChatScrollManager = ({
|
||||
updateViewportAnchor,
|
||||
isSyncing,
|
||||
isMobile,
|
||||
chatRenderMode = 'live',
|
||||
onActiveTurnChange,
|
||||
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
|
||||
@@ -83,14 +98,24 @@ export const useChatScrollManager = ({
|
||||
return Math.max(24, Math.min(200, raw));
|
||||
}, []);
|
||||
|
||||
const getAutoFollowThreshold = React.useCallback(() => {
|
||||
if (chatRenderMode === 'sorted') {
|
||||
return SORTED_PIN_THRESHOLD_PX;
|
||||
}
|
||||
return getPinThreshold();
|
||||
}, [chatRenderMode, getPinThreshold]);
|
||||
|
||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||
const [isPinned, setIsPinned] = React.useState(true);
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
|
||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
||||
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
|
||||
const isPinnedRef = React.useRef(true);
|
||||
const repinBlockedUntilRef = React.useRef<number>(0);
|
||||
const lastScrollTopRef = React.useRef<number>(0);
|
||||
const touchLastYRef = React.useRef<number | null>(null);
|
||||
|
||||
const markProgrammaticScroll = React.useCallback(() => {
|
||||
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
||||
@@ -102,6 +127,10 @@ export const useChatScrollManager = ({
|
||||
return container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
}, []);
|
||||
|
||||
const isStrictlyAtBottom = React.useCallback((distanceFromBottom: number) => {
|
||||
return distanceFromBottom <= STRICT_REPIN_DISTANCE_PX;
|
||||
}, []);
|
||||
|
||||
const updatePinnedState = React.useCallback((newPinned: boolean) => {
|
||||
if (isPinnedRef.current !== newPinned) {
|
||||
isPinnedRef.current = newPinned;
|
||||
@@ -119,6 +148,9 @@ export const useChatScrollManager = ({
|
||||
}, [markProgrammaticScroll, scrollEngine]);
|
||||
|
||||
const scrollPinnedToBottom = React.useCallback(() => {
|
||||
if (Date.now() < repinBlockedUntilRef.current) {
|
||||
return;
|
||||
}
|
||||
if (streamingMessageId) {
|
||||
scrollToBottomInternal({ followBottom: true });
|
||||
return;
|
||||
@@ -131,10 +163,12 @@ export const useChatScrollManager = ({
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
setShowScrollButton(false);
|
||||
setIsOverflowing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasScrollableContent = container.scrollHeight > container.clientHeight;
|
||||
setIsOverflowing(hasScrollableContent);
|
||||
if (!hasScrollableContent) {
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
@@ -142,7 +176,7 @@ export const useChatScrollManager = ({
|
||||
|
||||
// Show scroll button when scrolled above the 10vh threshold
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
setShowScrollButton(distanceFromBottom > getPinThreshold());
|
||||
setShowScrollButton(!isNearBottom(distanceFromBottom, getPinThreshold()));
|
||||
}, [getDistanceFromBottom, getPinThreshold]);
|
||||
|
||||
const scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => {
|
||||
@@ -158,12 +192,20 @@ export const useChatScrollManager = ({
|
||||
if (!container) return;
|
||||
|
||||
// Re-pin when explicitly scrolling to bottom
|
||||
repinBlockedUntilRef.current = 0;
|
||||
updatePinnedState(true);
|
||||
|
||||
scrollToBottomInternal(options);
|
||||
setShowScrollButton(false);
|
||||
}, [scrollToBottomInternal, updatePinnedState]);
|
||||
|
||||
const releasePinnedScroll = React.useCallback(() => {
|
||||
scrollEngine.cancelFollow();
|
||||
repinBlockedUntilRef.current = Date.now() + REPIN_BLOCK_AFTER_RELEASE_MS;
|
||||
updatePinnedState(false);
|
||||
updateScrollButtonVisibility();
|
||||
}, [scrollEngine, updatePinnedState, updateScrollButtonVisibility]);
|
||||
|
||||
const handleScrollEvent = React.useCallback((event?: Event) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container || !currentSessionId) {
|
||||
@@ -179,19 +221,26 @@ export const useChatScrollManager = ({
|
||||
|
||||
// Handle pin/unpin logic
|
||||
const currentScrollTop = container.scrollTop;
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
|
||||
// Unpin requires strict user intent check
|
||||
if (event?.isTrusted && !isProgrammatic && hasDirectIntent) {
|
||||
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
|
||||
if (scrollingUp && isPinnedRef.current) {
|
||||
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
|
||||
|
||||
// Unpin whenever we move away from bottom.
|
||||
// Also handle programmatic jumps to older content (timeline navigation)
|
||||
// so we don't snap back to bottom on the next content update.
|
||||
if (isPinnedRef.current) {
|
||||
const nearBottom = isNearBottom(distanceFromBottom, getPinThreshold());
|
||||
const scrollingUpByUserIntent = Boolean(!isProgrammatic && event?.isTrusted && hasDirectIntent && scrollingUp);
|
||||
const programmaticJumpAwayFromBottom = Boolean(!event?.isTrusted && scrollingUp && !nearBottom);
|
||||
|
||||
if (scrollingUpByUserIntent || programmaticJumpAwayFromBottom) {
|
||||
updatePinnedState(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pin at bottom should always work (even momentum scroll)
|
||||
if (!isPinnedRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom <= getPinThreshold()) {
|
||||
// Re-pin only when returning to bottom, not while still scrolling up.
|
||||
if (!isPinnedRef.current && now >= repinBlockedUntilRef.current) {
|
||||
if (event?.isTrusted && !scrollingUp && isStrictlyAtBottom(distanceFromBottom)) {
|
||||
updatePinnedState(true);
|
||||
}
|
||||
}
|
||||
@@ -206,6 +255,7 @@ export const useChatScrollManager = ({
|
||||
currentSessionId,
|
||||
getDistanceFromBottom,
|
||||
getPinThreshold,
|
||||
isStrictlyAtBottom,
|
||||
scrollEngine,
|
||||
sessionMessages.length,
|
||||
updatePinnedState,
|
||||
@@ -213,6 +263,31 @@ export const useChatScrollManager = ({
|
||||
updateViewportAnchor,
|
||||
]);
|
||||
|
||||
const handleWheelIntent = React.useCallback((event: WheelEvent) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: event.deltaY,
|
||||
deltaMode: event.deltaMode,
|
||||
rootHeight: container.clientHeight,
|
||||
});
|
||||
|
||||
// Scrolling up while pinned → unpin and kill follow loop immediately
|
||||
if (isPinnedRef.current && shouldPauseAutoScrollOnWheel({
|
||||
root: container,
|
||||
target: event.target,
|
||||
delta,
|
||||
})) {
|
||||
scrollEngine.cancelFollow();
|
||||
updatePinnedState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
}, [scrollEngine, updatePinnedState]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
@@ -221,16 +296,69 @@ export const useChatScrollManager = ({
|
||||
lastDirectScrollIntentAtRef.current = Date.now();
|
||||
};
|
||||
|
||||
const handleTouchStartIntent = (event: TouchEvent) => {
|
||||
markDirectIntent();
|
||||
const touch = event.touches.item(0);
|
||||
touchLastYRef.current = touch ? touch.clientY : null;
|
||||
};
|
||||
|
||||
const handleTouchMoveIntent = (event: TouchEvent) => {
|
||||
markDirectIntent();
|
||||
|
||||
const touch = event.touches.item(0);
|
||||
if (!touch) {
|
||||
touchLastYRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousY = touchLastYRef.current;
|
||||
touchLastYRef.current = touch.clientY;
|
||||
if (previousY === null || !isPinnedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerDelta = touch.clientY - previousY;
|
||||
if (Math.abs(fingerDelta) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syntheticWheelDelta = -fingerDelta;
|
||||
if (syntheticWheelDelta >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldPauseAutoScrollOnWheel({
|
||||
root: container,
|
||||
target: event.target,
|
||||
delta: syntheticWheelDelta,
|
||||
})) {
|
||||
scrollEngine.cancelFollow();
|
||||
updatePinnedState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEndIntent = () => {
|
||||
touchLastYRef.current = null;
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true });
|
||||
container.addEventListener('touchstart', handleTouchStartIntent as EventListener, { passive: true });
|
||||
container.addEventListener('touchmove', handleTouchMoveIntent as EventListener, { passive: true });
|
||||
container.addEventListener('touchend', handleTouchEndIntent as EventListener, { passive: true });
|
||||
container.addEventListener('touchcancel', handleTouchEndIntent as EventListener, { passive: true });
|
||||
container.addEventListener('wheel', handleWheelIntent as EventListener, { passive: true });
|
||||
container.addEventListener('wheel', markDirectIntent as EventListener, { passive: true });
|
||||
container.addEventListener('touchmove', markDirectIntent as EventListener, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScrollEvent as EventListener);
|
||||
container.removeEventListener('touchstart', handleTouchStartIntent as EventListener);
|
||||
container.removeEventListener('touchmove', handleTouchMoveIntent as EventListener);
|
||||
container.removeEventListener('touchend', handleTouchEndIntent as EventListener);
|
||||
container.removeEventListener('touchcancel', handleTouchEndIntent as EventListener);
|
||||
container.removeEventListener('wheel', handleWheelIntent as EventListener);
|
||||
container.removeEventListener('wheel', markDirectIntent as EventListener);
|
||||
container.removeEventListener('touchmove', markDirectIntent as EventListener);
|
||||
};
|
||||
}, [handleScrollEvent]);
|
||||
}, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]);
|
||||
|
||||
// Session switch - always start pinned at bottom
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
@@ -255,6 +383,7 @@ export const useChatScrollManager = ({
|
||||
// Maintain pin-to-bottom when content changes
|
||||
React.useEffect(() => {
|
||||
if (!isPinnedRef.current) return;
|
||||
if (Date.now() < repinBlockedUntilRef.current) return;
|
||||
if (isSyncing) return;
|
||||
|
||||
const container = scrollRef.current;
|
||||
@@ -262,10 +391,10 @@ export const useChatScrollManager = ({
|
||||
|
||||
// When pinned and content grows, follow bottom with fast smooth scroll
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}, [getDistanceFromBottom, getPinThreshold, isSyncing, scrollPinnedToBottom, sessionMessages]);
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, isSyncing, scrollPinnedToBottom, sessionMessages]);
|
||||
|
||||
// Use ResizeObserver to detect content changes and maintain pin
|
||||
React.useEffect(() => {
|
||||
@@ -276,9 +405,9 @@ export const useChatScrollManager = ({
|
||||
updateScrollButtonVisibility();
|
||||
|
||||
// Maintain pin when content grows - fast smooth follow
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
@@ -288,9 +417,9 @@ export const useChatScrollManager = ({
|
||||
|
||||
// Also observe children for content changes
|
||||
const childObserver = new MutationObserver(() => {
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
@@ -302,7 +431,7 @@ export const useChatScrollManager = ({
|
||||
observer.disconnect();
|
||||
childObserver.disconnect();
|
||||
};
|
||||
}, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -325,13 +454,13 @@ export const useChatScrollManager = ({
|
||||
updateScrollButtonVisibility();
|
||||
|
||||
// Maintain pin when content changes - fast smooth follow
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
}, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
|
||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
||||
const existing = animationHandlersRef.current.get(messageId);
|
||||
@@ -342,9 +471,9 @@ export const useChatScrollManager = ({
|
||||
const handlers: AnimationHandlers = {
|
||||
onChunk: () => {
|
||||
updateScrollButtonVisibility();
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
@@ -356,9 +485,9 @@ export const useChatScrollManager = ({
|
||||
onAnimationStart: () => {},
|
||||
onAnimatedHeightChange: () => {
|
||||
updateScrollButtonVisibility();
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
@@ -369,7 +498,59 @@ export const useChatScrollManager = ({
|
||||
|
||||
animationHandlersRef.current.set(messageId, handlers);
|
||||
return handlers;
|
||||
}, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onActiveTurnChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
onActiveTurnChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const spy = createScrollSpy({
|
||||
onActive: (turnId) => {
|
||||
onActiveTurnChange(turnId);
|
||||
},
|
||||
});
|
||||
|
||||
spy.setContainer(container);
|
||||
|
||||
const registerTurns = () => {
|
||||
spy.clear();
|
||||
const turnNodes = container.querySelectorAll<HTMLElement>('[data-turn-id]');
|
||||
turnNodes.forEach((node) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
spy.register(node, turnId);
|
||||
});
|
||||
spy.markDirty();
|
||||
};
|
||||
|
||||
registerTurns();
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
registerTurns();
|
||||
});
|
||||
mutationObserver.observe(container, { subtree: true, childList: true });
|
||||
|
||||
const handleScroll = () => {
|
||||
spy.onScroll();
|
||||
};
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
mutationObserver.disconnect();
|
||||
spy.destroy();
|
||||
onActiveTurnChange(null);
|
||||
};
|
||||
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
@@ -378,6 +559,9 @@ export const useChatScrollManager = ({
|
||||
showScrollButton,
|
||||
scrollToBottom,
|
||||
scrollToPosition,
|
||||
releasePinnedScroll,
|
||||
isPinned,
|
||||
isOverflowing,
|
||||
isProgrammaticFollowActive: scrollEngine.isFollowingBottom,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -176,48 +176,13 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const TEXT_SHRINK_TOLERANCE = 50;
|
||||
const RESYNC_DEBOUNCE_MS = 750;
|
||||
const QUESTION_RECONCILE_COOLDOWN_MS = 1500;
|
||||
const PERMISSION_RECONCILE_COOLDOWN_MS = 1500;
|
||||
|
||||
const textLengthCache = new WeakMap<Part[], number>();
|
||||
const computeTextLength = (parts: Part[] | undefined | null): number => {
|
||||
if (!parts || !Array.isArray(parts)) return 0;
|
||||
|
||||
const cached = textLengthCache.get(parts);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let length = 0;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (part?.type === 'text') {
|
||||
const text = (part as { text?: string; content?: string }).text ?? (part as { text?: string; content?: string }).content;
|
||||
if (typeof text === 'string') length += text.length;
|
||||
}
|
||||
}
|
||||
|
||||
textLengthCache.set(parts, length);
|
||||
return length;
|
||||
};
|
||||
|
||||
const MIN_SORTABLE_LENGTH = 10;
|
||||
const extractSortableId = (id: unknown): string | null => {
|
||||
if (typeof id !== 'string') return null;
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) return null;
|
||||
const underscoreIndex = trimmed.indexOf('_');
|
||||
const candidate = underscoreIndex >= 0 ? trimmed.slice(underscoreIndex + 1) : trimmed;
|
||||
if (!candidate || candidate.length < MIN_SORTABLE_LENGTH) return null;
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const isIdNewer = (id: string, referenceId: string): boolean => {
|
||||
const currentSortable = extractSortableId(id);
|
||||
const referenceSortable = extractSortableId(referenceId);
|
||||
if (!currentSortable || !referenceSortable) return true;
|
||||
if (currentSortable.length !== referenceSortable.length) return true;
|
||||
return currentSortable > referenceSortable;
|
||||
const readEventDirectory = (props: Record<string, unknown>): string => {
|
||||
const directory = readStringProp(props, ['directory']);
|
||||
return directory ?? 'global';
|
||||
};
|
||||
|
||||
const MAX_MESSAGE_CACHE_SIZE = 500;
|
||||
@@ -247,10 +212,17 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
|
||||
return message;
|
||||
};
|
||||
|
||||
const getLatestMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => {
|
||||
const storeState = useSessionStore.getState();
|
||||
const sessionMessages = storeState.messages.get(sessionId) || [];
|
||||
return sessionMessages.find((message) => message.info.id === messageId) || null;
|
||||
};
|
||||
|
||||
export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const {
|
||||
addStreamingPart,
|
||||
applyPartDelta,
|
||||
completeStreamingMessage,
|
||||
updateMessageInfo,
|
||||
updateSessionCompaction,
|
||||
@@ -557,12 +529,27 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
const lastMessageEventBySessionRef = React.useRef<Map<string, number>>(new Map());
|
||||
const pendingMessageStallTimersRef = React.useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const lastMessageStallRecoveryBySessionRef = React.useRef<Map<string, number>>(new Map());
|
||||
|
||||
const partTypeHintsByKeyRef = React.useRef<Map<string, string>>(new Map());
|
||||
const sessionCooldownTimersRef = React.useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const sessionActivityPhaseRef = React.useRef<Map<string, 'idle' | 'busy' | 'cooldown'>>(new Map());
|
||||
const sessionActivityLastRefreshAtRef = React.useRef<number>(0);
|
||||
const sessionActivityRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
||||
const scheduleSoftResyncRef = React.useRef<
|
||||
(sessionId: string, reason: string, limit?: number) => Promise<void>
|
||||
>(() => Promise.resolve());
|
||||
const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {});
|
||||
|
||||
const writePartTypeHint = React.useCallback((key: string, type: string) => {
|
||||
const map = partTypeHintsByKeyRef.current;
|
||||
map.set(key, type);
|
||||
if (map.size > 4000) {
|
||||
const firstKey = map.keys().next().value;
|
||||
if (typeof firstKey === 'string') {
|
||||
map.delete(firstKey);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isNotificationContextHidden = React.useCallback((isVSCodeRuntime: boolean): boolean => {
|
||||
if (visibilityStateRef.current === 'hidden') {
|
||||
return true;
|
||||
@@ -789,24 +776,161 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
useSessionStore.setState({ sessionStatus: next });
|
||||
}, []);
|
||||
|
||||
const updateSessionActivityPhase = React.useCallback((
|
||||
sessionId: string,
|
||||
phase: 'idle' | 'busy' | 'cooldown',
|
||||
source: string = 'unknown',
|
||||
options?: { syncStatus?: boolean }
|
||||
) => {
|
||||
if (!sessionId) return;
|
||||
const syncStatus = options?.syncStatus !== false;
|
||||
|
||||
const current = sessionActivityPhaseRef.current.get(sessionId);
|
||||
if (current === phase) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTimer = sessionCooldownTimersRef.current.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
sessionCooldownTimersRef.current.delete(sessionId);
|
||||
}
|
||||
|
||||
const next = new Map(sessionActivityPhaseRef.current);
|
||||
next.set(sessionId, phase);
|
||||
sessionActivityPhaseRef.current = next;
|
||||
|
||||
if (!syncStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'idle') {
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, `${source}:idle`);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, `${source}:${phase}`);
|
||||
|
||||
if (phase === 'cooldown') {
|
||||
const timer = setTimeout(() => {
|
||||
sessionCooldownTimersRef.current.delete(sessionId);
|
||||
if (sessionActivityPhaseRef.current.get(sessionId) !== 'cooldown') {
|
||||
return;
|
||||
}
|
||||
const latest = new Map(sessionActivityPhaseRef.current);
|
||||
latest.set(sessionId, 'idle');
|
||||
sessionActivityPhaseRef.current = latest;
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, `${source}:cooldown_timeout`);
|
||||
}, 2000);
|
||||
sessionCooldownTimersRef.current.set(sessionId, timer);
|
||||
}
|
||||
}, [updateSessionStatus]);
|
||||
|
||||
const refreshSessionActivityStatus = React.useCallback(async () => {
|
||||
const now = Date.now();
|
||||
if (sessionActivityRefreshInFlightRef.current) {
|
||||
return sessionActivityRefreshInFlightRef.current;
|
||||
}
|
||||
if (now - sessionActivityLastRefreshAtRef.current < 1500) {
|
||||
return;
|
||||
}
|
||||
sessionActivityLastRefreshAtRef.current = now;
|
||||
|
||||
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
|
||||
const observed = new Set<string>();
|
||||
const currentSessions = useSessionStore.getState().sessions;
|
||||
const knownSessionIds = new Set(currentSessions.map((session) => session.id));
|
||||
|
||||
for (const [sessionId, raw] of Object.entries(statusMap)) {
|
||||
if (!sessionId || !raw) continue;
|
||||
observed.add(sessionId);
|
||||
const phase: 'idle' | 'busy' | 'cooldown' =
|
||||
raw.type === 'cooldown'
|
||||
? 'cooldown'
|
||||
: raw.type === 'busy' || raw.type === 'retry'
|
||||
? 'busy'
|
||||
: 'idle';
|
||||
updateSessionActivityPhase(sessionId, phase, 'snapshot');
|
||||
}
|
||||
|
||||
for (const [sessionId, phase] of sessionActivityPhaseRef.current.entries()) {
|
||||
if (!knownSessionIds.has(sessionId)) continue;
|
||||
if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) {
|
||||
updateSessionActivityPhase(sessionId, 'idle', 'snapshot_missing');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const task = (async (): Promise<void> => {
|
||||
try {
|
||||
const webServerActivity = await opencodeClient.getWebServerSessionActivity();
|
||||
if (webServerActivity && Object.keys(webServerActivity).length > 0) {
|
||||
applyStatusMap(webServerActivity);
|
||||
return;
|
||||
}
|
||||
|
||||
const globalStatusMap = await opencodeClient.getGlobalSessionStatus();
|
||||
if (globalStatusMap && Object.keys(globalStatusMap).length > 0) {
|
||||
applyStatusMap(globalStatusMap);
|
||||
}
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
})().finally(() => {
|
||||
sessionActivityRefreshInFlightRef.current = null;
|
||||
});
|
||||
|
||||
sessionActivityRefreshInFlightRef.current = task;
|
||||
return task;
|
||||
}, [updateSessionActivityPhase]);
|
||||
|
||||
const clearSessionActivityTimers = React.useCallback(() => {
|
||||
const cooldownTimers = sessionCooldownTimersRef.current;
|
||||
for (const timer of cooldownTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
cooldownTimers.clear();
|
||||
sessionActivityPhaseRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const repairSessionDerivedState = React.useCallback((
|
||||
reason: string,
|
||||
options?: { refreshActivity?: boolean; pollStatus?: boolean }
|
||||
) => {
|
||||
const refreshActivity = options?.refreshActivity !== false;
|
||||
const pollStatus = options?.pollStatus !== false;
|
||||
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Repairing derived session state', { reason, refreshActivity, pollStatus });
|
||||
}
|
||||
|
||||
if (refreshActivity) {
|
||||
void refreshSessionActivityStatus();
|
||||
}
|
||||
|
||||
if (pollStatus) {
|
||||
triggerSessionStatusPoll();
|
||||
}
|
||||
}, [refreshSessionActivityStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextSessionId = currentSessionId ?? null;
|
||||
const prevSessionId = previousSessionIdRef.current;
|
||||
const nextDirectory = resolveSessionDirectoryForStatus(nextSessionId);
|
||||
const prevDirectory = previousSessionDirectoryRef.current;
|
||||
|
||||
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||
// Clear the message cache on session switch to free memory
|
||||
messageCache.clear();
|
||||
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||
// Clear the message cache on session switch to free memory
|
||||
messageCache.clear();
|
||||
|
||||
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||
// Removed: void refreshSessionStatus();
|
||||
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||
repairSessionDerivedState('session_switch_directory');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousSessionIdRef.current = nextSessionId;
|
||||
previousSessionDirectoryRef.current = nextDirectory;
|
||||
}, [currentSessionId, resolveSessionDirectoryForStatus]);
|
||||
}, [currentSessionId, repairSessionDerivedState, resolveSessionDirectoryForStatus]);
|
||||
|
||||
const handleEvent = React.useCallback((event: EventData) => {
|
||||
lastEventTimestampRef.current = Date.now();
|
||||
@@ -889,7 +1013,8 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
|
||||
if (sessionId && statusType) {
|
||||
if (statusType === 'busy') {
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
|
||||
updateSessionActivityPhase(sessionId, 'busy', 'sse:session.status', { syncStatus: false });
|
||||
} else if (statusType === 'retry') {
|
||||
updateSessionStatus(sessionId, {
|
||||
type: 'retry',
|
||||
@@ -918,14 +1043,28 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
? metadataObj.next
|
||||
: undefined,
|
||||
}, 'sse:session.status');
|
||||
updateSessionActivityPhase(sessionId, 'busy', 'sse:session.status', { syncStatus: false });
|
||||
} else {
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status');
|
||||
updateSessionActivityPhase(sessionId, 'idle', 'sse:session.status', { syncStatus: false });
|
||||
repairSessionDerivedState('session.status_idle', { refreshActivity: false });
|
||||
}
|
||||
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'openchamber:session-activity':
|
||||
{
|
||||
const sessionId = readStringProp(props, ['sessionId', 'sessionID']);
|
||||
const phase = typeof props.phase === 'string' ? props.phase : null;
|
||||
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
||||
updateSessionActivityPhase(sessionId, phase, 'sse:openchamber:session-activity');
|
||||
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'openchamber:session-status':
|
||||
{
|
||||
const sessionId = readStringProp(props, ['sessionId', 'sessionID']);
|
||||
@@ -937,6 +1076,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
// Update session status
|
||||
if (status === 'busy') {
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:openchamber:session-status');
|
||||
updateSessionActivityPhase(sessionId, 'busy', 'sse:openchamber:session-status', { syncStatus: false });
|
||||
} else if (status === 'retry') {
|
||||
const metadata = (typeof props.metadata === 'object' && props.metadata !== null) ? props.metadata as Record<string, unknown> : {};
|
||||
updateSessionStatus(sessionId, {
|
||||
@@ -945,8 +1085,13 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
message: typeof metadata.message === 'string' ? metadata.message : undefined,
|
||||
next: typeof metadata.next === 'number' ? metadata.next : undefined,
|
||||
}, 'sse:openchamber:session-status');
|
||||
updateSessionActivityPhase(sessionId, 'busy', 'sse:openchamber:session-status', { syncStatus: false });
|
||||
} else {
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:openchamber:session-status');
|
||||
updateSessionActivityPhase(sessionId, 'idle', 'sse:openchamber:session-status', { syncStatus: false });
|
||||
if (needsAttention) {
|
||||
repairSessionDerivedState('openchamber.session-status_attention_idle', { refreshActivity: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Update attention state in the same update to ensure atomicity
|
||||
@@ -1008,18 +1153,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
pendingMessageStallTimersRef.current.delete(sessionId);
|
||||
}
|
||||
|
||||
const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
||||
if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) {
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Skipping message.part.updated for trimmed message', {
|
||||
sessionId,
|
||||
messageId,
|
||||
trimmedHeadMaxId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const shouldKeepSyntheticUserText = (value: unknown): boolean => {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (!text) return false;
|
||||
@@ -1043,10 +1176,14 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
};
|
||||
|
||||
let roleInfo = 'assistant';
|
||||
const existingMessage = getLatestMessageFromStore(sessionId, messageId);
|
||||
const existingPartForType = existingMessage?.parts?.find((item) => item?.id === partExt.id);
|
||||
const existingPartType = typeof (existingPartForType as { type?: unknown } | undefined)?.type === 'string'
|
||||
? (existingPartForType as { type: string }).type
|
||||
: undefined;
|
||||
if (messageInfo && typeof (messageInfo as { role?: unknown }).role === 'string') {
|
||||
roleInfo = (messageInfo as { role?: string }).role as string;
|
||||
} else {
|
||||
const existingMessage = getMessageFromStore(sessionId, messageId);
|
||||
if (existingMessage) {
|
||||
const existingRole = (existingMessage.info as Record<string, unknown>).role;
|
||||
if (typeof existingRole === 'string') {
|
||||
@@ -1069,11 +1206,30 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const messagePart: Part = {
|
||||
const updatedPartId = readStringProp(partExt, ['id', 'partID', 'partId']);
|
||||
const directory = readEventDirectory(props);
|
||||
const partTypeHintKey = updatedPartId ? `${directory}:${messageId}:${updatedPartId}` : null;
|
||||
const hintedPartType = partTypeHintKey ? partTypeHintsByKeyRef.current.get(partTypeHintKey) : undefined;
|
||||
|
||||
const resolvedPartType =
|
||||
part.type ||
|
||||
existingPartType ||
|
||||
hintedPartType ||
|
||||
'text';
|
||||
|
||||
const messagePartBase: Part = {
|
||||
...part,
|
||||
type: part.type || 'text',
|
||||
type: resolvedPartType,
|
||||
} as Part;
|
||||
|
||||
const messagePart: Part = {
|
||||
...messagePartBase,
|
||||
} as Part;
|
||||
|
||||
if (partTypeHintKey && typeof resolvedPartType === 'string' && resolvedPartType.length > 0) {
|
||||
writePartTypeHint(partTypeHintKey, resolvedPartType);
|
||||
}
|
||||
|
||||
if (roleInfo === 'assistant') {
|
||||
const partType = (messagePart as { type?: unknown }).type;
|
||||
const partTime = (messagePart as { time?: { end?: unknown } }).time;
|
||||
@@ -1112,10 +1268,9 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
typeof currentStatus.confirmedAt === 'number' &&
|
||||
Date.now() - currentStatus.confirmedAt < 1200;
|
||||
if (!currentStatus || currentStatus.type === 'idle') {
|
||||
if (recentlyConfirmedIdle) {
|
||||
break;
|
||||
if (!recentlyConfirmedIdle) {
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated');
|
||||
}
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1151,37 +1306,44 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
pendingMessageStallTimersRef.current.delete(sessionId);
|
||||
}
|
||||
|
||||
const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
||||
if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) {
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Skipping message.part.delta for trimmed message', {
|
||||
sessionId,
|
||||
messageId,
|
||||
trimmedHeadMaxId,
|
||||
});
|
||||
const existingMessage = getLatestMessageFromStore(sessionId, messageId);
|
||||
const existingPart = existingMessage?.parts?.find((item) => item?.id === partId);
|
||||
const existingRole = (existingMessage?.info as Record<string, unknown> | undefined)?.role;
|
||||
const roleInfo = typeof existingRole === 'string' ? existingRole : 'assistant';
|
||||
|
||||
if (!existingPart) {
|
||||
if (field === 'text' || field === 'content' || field === 'value') {
|
||||
const directory = readEventDirectory(props);
|
||||
const deltaPartTypeHint =
|
||||
readStringProp(props, ['partType', 'type', 'part_type']) ||
|
||||
readStringProp(props, ['kind']);
|
||||
const partTypeHintKey = `${directory}:${messageId}:${partId}`;
|
||||
const hintedPartType = partTypeHintsByKeyRef.current.get(partTypeHintKey);
|
||||
const bootstrappedPartType =
|
||||
typeof deltaPartTypeHint === 'string' && deltaPartTypeHint.trim().length > 0
|
||||
? deltaPartTypeHint
|
||||
: (typeof hintedPartType === 'string' && hintedPartType.trim().length > 0
|
||||
? hintedPartType
|
||||
: 'text');
|
||||
|
||||
const bootstrappedPart = {
|
||||
id: partId,
|
||||
type: bootstrappedPartType,
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
delta,
|
||||
[field]: '',
|
||||
} as unknown as Part;
|
||||
|
||||
if (typeof bootstrappedPartType === 'string' && bootstrappedPartType.length > 0) {
|
||||
writePartTypeHint(partTypeHintKey, bootstrappedPartType);
|
||||
}
|
||||
|
||||
addStreamingPart(sessionId, messageId, bootstrappedPart, roleInfo);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const existingMessage = getMessageFromStore(sessionId, messageId);
|
||||
const existingPart = existingMessage?.parts?.find((item) => item?.id === partId);
|
||||
if (!existingPart) {
|
||||
break;
|
||||
}
|
||||
|
||||
const existingPartRecord = existingPart as Record<string, unknown>;
|
||||
const existingFieldValue = existingPartRecord[field];
|
||||
const updatedPart: Part = {
|
||||
...existingPart,
|
||||
[field]: `${typeof existingFieldValue === 'string' ? existingFieldValue : ''}${delta}`,
|
||||
} as Part;
|
||||
|
||||
let roleInfo = 'assistant';
|
||||
const existingRole = (existingMessage?.info as Record<string, unknown> | undefined)?.role;
|
||||
if (typeof existingRole === 'string') {
|
||||
roleInfo = existingRole;
|
||||
}
|
||||
|
||||
if (roleInfo === 'assistant' && delta.length > 0) {
|
||||
const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId);
|
||||
const recentlyConfirmedIdle =
|
||||
@@ -1196,7 +1358,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
|
||||
trackMessage(messageId, 'part_delta_received', { role: roleInfo, field });
|
||||
addStreamingPart(sessionId, messageId, updatedPart, roleInfo);
|
||||
applyPartDelta(sessionId, messageId, partId, field, delta, roleInfo);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1232,18 +1394,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
pendingMessageStallTimersRef.current.delete(sessionId);
|
||||
}
|
||||
|
||||
const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
||||
if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) {
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Skipping message.updated for trimmed message', {
|
||||
sessionId,
|
||||
messageId,
|
||||
trimmedHeadMaxId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (streamDebugEnabled()) {
|
||||
try {
|
||||
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || [];
|
||||
@@ -1404,7 +1554,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
if (!missingMessageHydrationRef.current.has(hydrateKey)) {
|
||||
missingMessageHydrationRef.current.add(hydrateKey);
|
||||
void opencodeClient
|
||||
.getSessionMessages(sessionId, 50)
|
||||
.getSessionMessages(sessionId)
|
||||
.then((messages) => {
|
||||
useSessionStore.getState().syncMessages(sessionId, messages);
|
||||
})
|
||||
@@ -1415,6 +1565,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
|
||||
if (partsArray.length > 0) {
|
||||
const directory = readEventDirectory(props);
|
||||
for (let i = 0; i < partsArray.length; i++) {
|
||||
const serverPart = partsArray[i];
|
||||
const isSynthetic = (serverPart as Record<string, unknown>).synthetic === true;
|
||||
@@ -1434,6 +1585,9 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
sessionID: (serverPart as { sessionID?: string })?.sessionID || sessionId,
|
||||
messageID: (serverPart as { messageID?: string })?.messageID || messageId,
|
||||
} as Part;
|
||||
if (typeof enrichedPart.id === 'string' && typeof enrichedPart.type === 'string') {
|
||||
writePartTypeHint(`${directory}:${messageId}:${enrichedPart.id}`, enrichedPart.type);
|
||||
}
|
||||
addStreamingPart(sessionId, messageId, enrichedPart, 'user');
|
||||
}
|
||||
}
|
||||
@@ -1443,7 +1597,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
|
||||
const existingMessage = getMessageFromStore(sessionId, messageId);
|
||||
const existingLen = computeTextLength(existingMessage?.parts || []);
|
||||
const existingStopMarker = (existingMessage?.info as { finish?: string } | undefined)?.finish === 'stop';
|
||||
|
||||
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts;
|
||||
@@ -1461,26 +1614,10 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
|
||||
if (!hasParts && !completedFromServer && !hasCompletedStatus && !eventHasStopFinish && !eventHasErrorFinish) break;
|
||||
|
||||
if ((messageExt as { role?: unknown }).role === 'assistant' && hasParts) {
|
||||
const hasQuestionTool = partsArray.some((part) => (
|
||||
part?.type === 'tool'
|
||||
&& typeof (part as { tool?: unknown }).tool === 'string'
|
||||
&& (part as { tool: string }).tool.toLowerCase() === 'question'
|
||||
));
|
||||
if (hasQuestionTool) {
|
||||
requestPendingQuestionsRefresh();
|
||||
}
|
||||
const messageInfoOnly = { ...messageExt } as Record<string, unknown>;
|
||||
delete messageInfoOnly.parts;
|
||||
|
||||
const incomingLen = computeTextLength(partsArray);
|
||||
const wouldShrink = existingLen > 0 && incomingLen + TEXT_SHRINK_TOLERANCE < existingLen;
|
||||
|
||||
if (wouldShrink && !eventHasStopFinish) {
|
||||
trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageInfo(sessionId, messageId, message as unknown as Message);
|
||||
updateMessageInfo(sessionId, messageId, messageInfoOnly as unknown as Message);
|
||||
|
||||
const messageRole = typeof (message as { role?: unknown }).role === 'string'
|
||||
? (message as { role: string }).role
|
||||
@@ -1520,33 +1657,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasParts && (messageExt as { role?: unknown }).role !== 'user') {
|
||||
const storeState = useSessionStore.getState();
|
||||
const existingMessages = storeState.messages.get(sessionId) || [];
|
||||
const existingMessageForSession = existingMessages.find((m) => m.info.id === messageId);
|
||||
const needsInjection = !existingMessageForSession || existingMessageForSession.parts.length === 0;
|
||||
|
||||
trackMessage(
|
||||
messageId,
|
||||
needsInjection ? 'server_parts_injected' : 'server_parts_refreshed',
|
||||
{ count: partsArray.length }
|
||||
);
|
||||
|
||||
const partsToInject = partsArray;
|
||||
|
||||
for (let i = 0; i < partsToInject.length; i++) {
|
||||
const serverPart = partsToInject[i];
|
||||
const enrichedPart: Part = {
|
||||
...serverPart,
|
||||
type: serverPart?.type || 'text',
|
||||
sessionID: serverPart?.sessionID || sessionId,
|
||||
messageID: serverPart?.messageID || messageId,
|
||||
} as Part;
|
||||
addStreamingPart(sessionId, messageId, enrichedPart, (messageExt as { role?: string }).role as string);
|
||||
trackMessage(messageId, `server_part_${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
const messageTime = (message as { time?: { completed?: unknown } }).time;
|
||||
const completedCandidate = (messageTime as { completed?: unknown } | undefined)?.completed;
|
||||
const hasCompletedTimestamp = typeof completedCandidate === 'number' && Number.isFinite(completedCandidate);
|
||||
@@ -1593,7 +1703,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
void saveSessionCursor(sessionId, messageId, timeCompleted);
|
||||
|
||||
completeStreamingMessage(sessionId, messageId);
|
||||
// Removed: void refreshSessionStatus();
|
||||
repairSessionDerivedState('assistant_message_completed');
|
||||
|
||||
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
||||
const messageSessionId: string =
|
||||
@@ -1938,6 +2048,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}, [
|
||||
currentSessionId,
|
||||
addStreamingPart,
|
||||
applyPartDelta,
|
||||
completeStreamingMessage,
|
||||
updateMessageInfo,
|
||||
addPermission,
|
||||
@@ -1957,7 +2068,10 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
bootstrapState,
|
||||
effectiveDirectory,
|
||||
updateSessionStatus,
|
||||
updateSessionActivityPhase,
|
||||
repairSessionDerivedState,
|
||||
dispatchRuntimeNotification,
|
||||
writePartTypeHint,
|
||||
]);
|
||||
|
||||
// --- Stable callback refs (Part A) ---
|
||||
@@ -2029,7 +2143,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isCleaningUpRef.current = false;
|
||||
}, []);
|
||||
|
||||
@@ -2070,7 +2183,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
lastEventTimestampRef.current = Date.now();
|
||||
publishStatus('connected', null);
|
||||
checkConnection();
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('stream_open');
|
||||
|
||||
requestPendingPermissionsRefreshRef.current(shouldRefresh);
|
||||
|
||||
@@ -2154,6 +2267,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
requestSessionMetadataRefresh,
|
||||
stableHandleEvent,
|
||||
stableBootstrapState,
|
||||
repairSessionDerivedState,
|
||||
effectiveDirectory,
|
||||
debugConnectionState,
|
||||
]);
|
||||
@@ -2231,7 +2345,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
|
||||
clearPauseTimeout();
|
||||
maybeBootstrapIfStale('visibility_restore');
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('visibility_restore');
|
||||
|
||||
const isStalled = Date.now() - lastEventTimestampRef.current > 45000;
|
||||
if (isStalled) {
|
||||
@@ -2247,9 +2361,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
requestSessionMetadataRefresh(sessionId);
|
||||
}
|
||||
requestPendingPermissionsRefreshRef.current(false);
|
||||
|
||||
// Removed: void refreshSessionStatus();
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('visibility_restore_resume');
|
||||
publishStatus('connecting', 'Resuming stream');
|
||||
startStream({ resetAttempts: true });
|
||||
}
|
||||
@@ -2261,7 +2373,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
if (visibilityStateRef.current === 'visible') {
|
||||
clearPauseTimeout();
|
||||
maybeBootstrapIfStale('window_focus');
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('window_focus');
|
||||
|
||||
const isStalled = Date.now() - lastEventTimestampRef.current > 45000;
|
||||
if (isStalled) {
|
||||
@@ -2276,8 +2388,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
scheduleSoftResync(sessionId, 'window_focus', getMessageLimit());
|
||||
}
|
||||
requestPendingPermissionsRefreshRef.current(false);
|
||||
// Removed: void refreshSessionStatus();
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('window_focus_resume');
|
||||
|
||||
publishStatus('connecting', 'Resuming stream');
|
||||
startStream({ resetAttempts: true });
|
||||
@@ -2288,9 +2399,9 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
const handleOnline = () => {
|
||||
onlineStatusRef.current = true;
|
||||
maybeBootstrapIfStale('network_restored');
|
||||
repairSessionDerivedState('network_restored');
|
||||
requestPendingPermissionsRefreshRef.current(false);
|
||||
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
||||
triggerSessionStatusPoll();
|
||||
publishStatus('connecting', 'Network restored');
|
||||
startStream({ resetAttempts: true });
|
||||
}
|
||||
@@ -2320,8 +2431,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
requestSessionMetadataRefresh(sessionId);
|
||||
}
|
||||
requestPendingPermissionsRefreshRef.current(false);
|
||||
// Removed: void refreshSessionStatus();
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('page_show');
|
||||
startStream({ resetAttempts: true });
|
||||
}
|
||||
};
|
||||
@@ -2355,7 +2465,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
);
|
||||
|
||||
if (hasBusySessions) {
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('stale_check_busy_sessions');
|
||||
}
|
||||
if (now - lastEventTimestampRef.current > 45000) {
|
||||
Promise.resolve().then(async () => {
|
||||
@@ -2420,6 +2530,8 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
staleCheckIntervalRef.current = null;
|
||||
}
|
||||
|
||||
clearSessionActivityTimers();
|
||||
|
||||
messageCache.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
|
||||
notifiedMessagesRef.current.clear();
|
||||
@@ -2451,6 +2563,9 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
scheduleReconnect,
|
||||
loadMessages,
|
||||
requestSessionMetadataRefresh,
|
||||
refreshSessionActivityStatus,
|
||||
clearSessionActivityTimers,
|
||||
repairSessionDerivedState,
|
||||
|
||||
|
||||
shouldHoldConnection,
|
||||
|
||||
@@ -394,7 +394,7 @@ export const useKeyboardShortcuts = () => {
|
||||
if (primedUntil && now < primedUntil) {
|
||||
e.preventDefault();
|
||||
resetAbortPriming();
|
||||
void abortCurrentOperation();
|
||||
void abortCurrentOperation(sessionId || undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { animate, type AnimationPlaybackControls } from 'motion';
|
||||
|
||||
type ScrollEngineOptions = {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -7,85 +8,106 @@ type ScrollEngineOptions = {
|
||||
|
||||
type ScrollOptions = {
|
||||
instant?: boolean;
|
||||
followBottom?: boolean; // Dynamically track bottom during animation
|
||||
followBottom?: boolean; // Dynamically track bottom during streaming
|
||||
};
|
||||
|
||||
type ScrollEngineResult = {
|
||||
handleScroll: () => void;
|
||||
scrollToPosition: (position: number, options?: ScrollOptions) => void;
|
||||
forceManualMode: () => void;
|
||||
cancelFollow: () => void;
|
||||
isAtTop: boolean;
|
||||
isFollowingBottom: boolean;
|
||||
isManualOverrideActive: () => boolean;
|
||||
getScrollTop: () => number;
|
||||
getScrollHeight: () => number;
|
||||
getClientHeight: () => number;
|
||||
};
|
||||
|
||||
const ANIMATION_DURATION_MS = 160;
|
||||
// Spring config for one-shot scroll-to-bottom (button click, session switch).
|
||||
const FAST_SPRING = {
|
||||
type: 'spring' as const,
|
||||
visualDuration: 0.35,
|
||||
bounce: 0,
|
||||
};
|
||||
|
||||
// Exponential smoothing factor for the follow-bottom rAF loop.
|
||||
// Each frame: scrollTop += (target - scrollTop) * LERP_FACTOR
|
||||
// ~0.12-0.18 gives a smooth camera-follow feel at 60fps.
|
||||
const LERP_FACTOR = 0.14;
|
||||
|
||||
// When the remaining distance is below this, snap exactly to bottom.
|
||||
const SNAP_EPSILON = 0.5;
|
||||
|
||||
export const useScrollEngine = ({
|
||||
containerRef,
|
||||
}: ScrollEngineOptions): ScrollEngineResult => {
|
||||
const [isAtTop, setIsAtTop] = React.useState(true);
|
||||
const [isFollowingBottom, setIsFollowingBottom] = React.useState(false);
|
||||
|
||||
const atTopRef = React.useRef(true);
|
||||
const manualOverrideRef = React.useRef(false);
|
||||
const animationFrameRef = React.useRef<number | null>(null);
|
||||
const animationStartRef = React.useRef<number | null>(null);
|
||||
const animationFromRef = React.useRef(0);
|
||||
const animationTargetRef = React.useRef(0);
|
||||
const followBottomRef = React.useRef(false);
|
||||
|
||||
const cancelAnimation = React.useCallback(() => {
|
||||
if (animationFrameRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(animationFrameRef.current);
|
||||
// One-shot spring animation (for scroll-to-bottom button etc.)
|
||||
const scrollAnimRef = React.useRef<AnimationPlaybackControls | undefined>(undefined);
|
||||
|
||||
// Continuous follow-bottom rAF loop (for streaming)
|
||||
const followRafRef = React.useRef<number | null>(null);
|
||||
const followActiveRef = React.useRef(false);
|
||||
|
||||
const cancelSpring = React.useCallback(() => {
|
||||
if (scrollAnimRef.current) {
|
||||
scrollAnimRef.current.stop();
|
||||
scrollAnimRef.current = undefined;
|
||||
}
|
||||
|
||||
animationFrameRef.current = null;
|
||||
animationStartRef.current = null;
|
||||
followBottomRef.current = false;
|
||||
}, []);
|
||||
|
||||
const runAnimationFrame = React.useCallback(
|
||||
(timestamp: number) => {
|
||||
const cancelFollow = React.useCallback(() => {
|
||||
if (followRafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(followRafRef.current);
|
||||
followRafRef.current = null;
|
||||
}
|
||||
followActiveRef.current = false;
|
||||
setIsFollowingBottom(false);
|
||||
}, []);
|
||||
|
||||
const cancelAll = React.useCallback(() => {
|
||||
cancelSpring();
|
||||
cancelFollow();
|
||||
}, [cancelSpring, cancelFollow]);
|
||||
|
||||
// Continuous lerp loop that chases scrollHeight - clientHeight.
|
||||
const startFollowLoop = React.useCallback(() => {
|
||||
if (followActiveRef.current) return; // already running
|
||||
followActiveRef.current = true;
|
||||
setIsFollowingBottom(true);
|
||||
|
||||
const tick = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
cancelAnimation();
|
||||
if (!container || !followActiveRef.current) {
|
||||
followActiveRef.current = false;
|
||||
followRafRef.current = null;
|
||||
setIsFollowingBottom(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (animationStartRef.current === null) {
|
||||
animationStartRef.current = timestamp;
|
||||
}
|
||||
const target = container.scrollHeight - container.clientHeight;
|
||||
const current = container.scrollTop;
|
||||
const delta = target - current;
|
||||
|
||||
// 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 easedProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const from = animationFromRef.current;
|
||||
const target = animationTargetRef.current;
|
||||
const nextTop = from + (target - from) * easedProgress;
|
||||
|
||||
container.scrollTop = nextTop;
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame);
|
||||
if (Math.abs(delta) <= SNAP_EPSILON) {
|
||||
container.scrollTop = target;
|
||||
// Don't stop — keep running so next content growth is caught immediately.
|
||||
followRafRef.current = window.requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTop = target;
|
||||
cancelAnimation();
|
||||
container.scrollTop = current + delta * LERP_FACTOR;
|
||||
followRafRef.current = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
if (atTopRef.current) {
|
||||
atTopRef.current = false;
|
||||
setIsAtTop(false);
|
||||
}
|
||||
},
|
||||
[cancelAnimation, containerRef, setIsAtTop]
|
||||
);
|
||||
followRafRef.current = window.requestAnimationFrame(tick);
|
||||
}, [containerRef]);
|
||||
|
||||
const scrollToPosition = React.useCallback(
|
||||
(position: number, options?: ScrollOptions) => {
|
||||
@@ -98,8 +120,9 @@ export const useScrollEngine = ({
|
||||
|
||||
manualOverrideRef.current = false;
|
||||
|
||||
// Instant scroll (session switch, etc.)
|
||||
if (typeof window === 'undefined' || preferInstant) {
|
||||
cancelAnimation();
|
||||
cancelAll();
|
||||
container.scrollTop = target;
|
||||
|
||||
const atTop = target <= 1;
|
||||
@@ -107,37 +130,41 @@ export const useScrollEngine = ({
|
||||
atTopRef.current = atTop;
|
||||
setIsAtTop(atTop);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If followBottom animation is already running, don't restart - let it continue
|
||||
if (followBottom && followBottomRef.current && animationFrameRef.current !== null) {
|
||||
// Follow-bottom mode: start the continuous lerp loop
|
||||
if (followBottom) {
|
||||
cancelSpring();
|
||||
startFollowLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation();
|
||||
// One-shot scroll: stop everything and use spring animation
|
||||
cancelAll();
|
||||
|
||||
const distance = Math.abs(target - container.scrollTop);
|
||||
if (distance <= 0.5) {
|
||||
if (distance <= SNAP_EPSILON) {
|
||||
container.scrollTop = target;
|
||||
|
||||
const atTop = target <= 1;
|
||||
if (atTopRef.current !== atTop) {
|
||||
atTopRef.current = atTop;
|
||||
setIsAtTop(atTop);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
animationFromRef.current = container.scrollTop;
|
||||
animationTargetRef.current = target;
|
||||
animationStartRef.current = null;
|
||||
followBottomRef.current = followBottom;
|
||||
animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame);
|
||||
scrollAnimRef.current = animate(container.scrollTop, target, {
|
||||
...FAST_SPRING,
|
||||
onUpdate: (v) => {
|
||||
container.scrollTop = v;
|
||||
},
|
||||
onComplete: () => {
|
||||
scrollAnimRef.current = undefined;
|
||||
},
|
||||
});
|
||||
},
|
||||
[cancelAnimation, containerRef, runAnimationFrame, setIsAtTop]
|
||||
[cancelAll, cancelSpring, containerRef, setIsAtTop, startFollowLoop]
|
||||
);
|
||||
|
||||
const forceManualMode = React.useCallback(() => {
|
||||
@@ -146,7 +173,8 @@ export const useScrollEngine = ({
|
||||
|
||||
const markManualOverride = React.useCallback(() => {
|
||||
manualOverrideRef.current = true;
|
||||
}, []);
|
||||
cancelFollow();
|
||||
}, [cancelFollow]);
|
||||
|
||||
const isManualOverrideActive = React.useCallback(() => {
|
||||
return manualOverrideRef.current;
|
||||
@@ -168,17 +196,16 @@ export const useScrollEngine = ({
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
if (manualOverrideRef.current && animationFrameRef.current !== null) {
|
||||
cancelAnimation();
|
||||
if (manualOverrideRef.current && scrollAnimRef.current) {
|
||||
cancelSpring();
|
||||
}
|
||||
|
||||
const atTop = container.scrollTop <= 1;
|
||||
|
||||
if (atTopRef.current !== atTop) {
|
||||
atTopRef.current = atTop;
|
||||
setIsAtTop(atTop);
|
||||
}
|
||||
}, [cancelAnimation, containerRef]);
|
||||
}, [cancelSpring, containerRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -195,16 +222,18 @@ export const useScrollEngine = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimation();
|
||||
cancelAll();
|
||||
};
|
||||
}, [cancelAnimation]);
|
||||
}, [cancelAll]);
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
handleScroll,
|
||||
scrollToPosition,
|
||||
forceManualMode,
|
||||
cancelFollow,
|
||||
isAtTop,
|
||||
isFollowingBottom,
|
||||
isManualOverrideActive,
|
||||
getScrollTop,
|
||||
getScrollHeight,
|
||||
@@ -214,7 +243,9 @@ export const useScrollEngine = ({
|
||||
handleScroll,
|
||||
scrollToPosition,
|
||||
forceManualMode,
|
||||
cancelFollow,
|
||||
isAtTop,
|
||||
isFollowingBottom,
|
||||
isManualOverrideActive,
|
||||
getScrollTop,
|
||||
getScrollHeight,
|
||||
|
||||
Reference in New Issue
Block a user