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:
Bohdan Triapitsyn
2026-03-12 23:45:45 +02:00
committed by GitHub
parent d969ff4af1
commit fab32ca992
134 changed files with 9366 additions and 6147 deletions
+212 -28
View File
@@ -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,
};
};
+282 -167
View File
@@ -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;
}
+97 -66
View File
@@ -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,