Merge main (anchored-turn chat scrolling) into perf/switch-and-scroll

Main replaced the chat timeline scroll engine while this branch was in
flight, which obsoletes two of its subareas and reshapes a third:

- Chat timeline: main's LegendList-based MessageList/ChatContainer win;
  the activation-overscan staircase targeted the removed tanstack path
  (LegendList provides adaptive rendering natively) and is dropped along
  with its test.
- Scroll shadows: main's hook-based masks stay (the virtualized list owns
  its scroll element — there is no wrapper to hand the styling to); the
  viewport-wrapper ScrollShadow rewrite, its index.css replacement, its
  test, and the call-site viewportClassName adaptations are reverted to
  main. The chat OverlayScrollbar keeps this branch's disableHorizontal.
- OverlayScrollbar: the direct-DOM rewrite lands, but binding now follows
  the live container node instead of binding once per ref object — the
  chat scroller remounts on every session switch, and a bind-once
  contract left the scrollbar attached to a dead element.
- Markdown renderer: the detached-DOM cache and warm-block fast path
  merge with main's block-commit reveal (enter cascade), streaming code
  highlighting, and gutter reservation; the per-block reconcile keeps
  both the decoration-refresh path and the reveal cascade.
This commit is contained in:
Bohdan Triapitsyn
2026-08-26 00:59:45 +03:00
69 changed files with 3108 additions and 2677 deletions
@@ -628,7 +628,6 @@ const MobileDiffDetail: React.FC<{
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
) : (
<ScrollShadow
viewportClassName="h-full"
className="h-full overflow-y-auto overflow-x-hidden p-3"
data-diff-virtual-root
data-diff-virtual-content
+1 -1
View File
@@ -278,7 +278,7 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
</div>
</div>
<ScrollShadow viewportClassName="min-h-0 flex-1" className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
{directoryError ? (
<MobileFilesState message={directoryError} />
) : query.trim() ? (
+1 -1
View File
@@ -1455,7 +1455,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
// clipped overflow swallowed the footer.
const surfaceContent = (
<div ref={contentRootRef} className="flex min-h-0 flex-1 flex-col">
<ScrollShadow viewportClassName="min-h-0 flex-1" className="min-h-0 flex-1 overflow-y-auto pb-4">
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto pb-4">
{/* The search bar scrolls WITH the list (iOS-style): the open-time
auto-scroll to the current session naturally tucks it away, and
scrolling to the very top brings it back. */}
+241 -117
View File
@@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useScrollShadow } from '@/components/ui/useScrollShadow';
import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { TimelineDialog } from './TimelineDialog';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
@@ -151,11 +151,15 @@ type ChatViewportProps = {
currentSessionKey: string;
isDesktopExpandedInput: boolean;
isMobile: boolean;
stickyUserHeader: boolean;
directory?: string;
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
pendingRevealWork: boolean;
registerList: (list: TimelineListHandle | null) => void;
anchorMessageId: string | null;
onAnchorReady: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: string) => void;
onIsAtEndChange: (isAtEnd: boolean) => void;
onTimelineDataChange: () => void;
renderedMessages: SessionMessageRecord[];
isLoadingOlder: boolean;
sessionIsWorking: boolean;
@@ -167,10 +171,11 @@ type ChatViewportProps = {
confirmedAt?: number;
fallbackTimestamp?: number;
} | null;
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
handleHistoryScroll: () => void;
scrollToBottom: () => void;
endPinningReleased: boolean;
// One-shot fade for content that replaced the hydration skeleton;
// cached sessions render instantly without it.
revealContent: boolean;
sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean;
@@ -190,21 +195,24 @@ const ChatViewport = React.memo(({
currentSessionKey,
isDesktopExpandedInput,
isMobile,
stickyUserHeader,
directory,
scrollRef,
messageListRef,
pendingRevealWork,
registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onTimelineDataChange,
renderedMessages,
isLoadingOlder,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
retryOverlay,
handleMessageContentChange,
getAnimationHandlers,
handleHistoryScroll,
scrollToBottom,
endPinningReleased,
revealContent,
sessionQuestions,
sessionPermissions,
isProgrammaticFollowActive,
@@ -315,89 +323,96 @@ const ChatViewport = React.memo(({
scrollRef.current?.focus({ preventScroll: true });
}, [scrollRef]);
// Everything that used to sit beside the list inside the scroll container
// now renders as the list's header/footer, so it keeps scrolling with the
// rows exactly as before.
const listHeader = React.useMemo(() => (
showLoadOlderButton ? (
<div className="flex justify-center pt-3 pb-1">
<Button
variant="secondary"
size="sm"
onClick={onLoadOlder}
disabled={isLoadingOlder}
>
{isLoadingOlder && (
<Icon name="loader-4" className="size-4 animate-spin" />
)}
{t('chat.history.loadOlder')}
</Button>
</div>
) : null
), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]);
const listFooter = React.useMemo(() => (
<>
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
<div>
{sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
</>
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
const scrollContainerProps = React.useMemo(() => ({
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
style: CHAT_SCROLL_STYLE,
tabIndex: 0,
onClick: focusScrollContainer,
'data-scrollbar': 'chat',
'data-scroll-shadow': 'true',
'data-orientation': 'vertical',
}), [focusScrollContainer]);
return (
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
: 'flex-1',
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
)}
aria-hidden={isDesktopExpandedInput}
>
<div className="absolute inset-0">
<ScrollShadow
viewportClassName="absolute inset-0"
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden chat-scroll overlay-scrollbar-target"
ref={scrollRef}
style={CHAT_SCROLL_STYLE}
observeMutations={false}
hideTopShadow={isMobile && stickyUserHeader}
tabIndex={0}
onClick={focusScrollContainer}
onScroll={handleHistoryScroll}
data-scrollbar="chat"
>
<div className="relative z-0 min-h-full">
{showLoadOlderButton && (
<div className="flex justify-center pt-3 pb-1">
<Button
variant="secondary"
size="sm"
onClick={onLoadOlder}
disabled={isLoadingOlder}
>
{isLoadingOlder && (
<Icon name="loader-4" className="size-4 animate-spin" />
)}
{t('chat.history.loadOlder')}
</Button>
</div>
)}
<MessageList
key={currentSessionKey}
ref={messageListRef}
sessionKey={currentSessionId}
disableStaging={pendingRevealWork}
messages={renderedMessages}
sessionIsWorking={sessionIsWorking}
activeStreamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
isLoadingOlder={isLoadingOlder}
scrollToBottom={scrollToBottom}
scrollRef={scrollRef}
directory={directory}
/>
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
<div>
{sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
<div className="mb-3">
<StatusRowContainer />
</div>
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
</div>
</ScrollShadow>
<OverlayScrollbar
containerRef={scrollRef}
disableHorizontal
suppressVisibility={isProgrammaticFollowActive}
userIntentOnly
observeMutations={false}
<MessageList
key={currentSessionKey}
ref={messageListRef}
sessionKey={currentSessionId}
messages={renderedMessages}
sessionIsWorking={sessionIsWorking}
activeStreamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
isLoadingOlder={isLoadingOlder}
scrollToBottom={scrollToBottom}
endPinningReleased={endPinningReleased}
directory={directory}
registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
// Zero end inset: the footer spacer already reserves the
// zone the floating status row covers; adding its height
// again produced a double-tall blank band at rest.
composerOverlayHeight={0}
onIsAtEndChange={onIsAtEndChange}
onTimelineDataChange={onTimelineDataChange}
listHeader={listHeader}
listFooter={listFooter}
scrollContainerProps={scrollContainerProps}
/>
<OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
{showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail
turnIds={promptTurnIds}
@@ -417,21 +432,18 @@ const ChatViewport = React.memo(({
&& prev.currentSessionKey === next.currentSessionKey
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
&& prev.isMobile === next.isMobile
&& prev.stickyUserHeader === next.stickyUserHeader
&& prev.directory === next.directory
&& prev.scrollRef === next.scrollRef
&& prev.messageListRef === next.messageListRef
&& prev.pendingRevealWork === next.pendingRevealWork
&& prev.renderedMessages === next.renderedMessages
&& prev.isLoadingOlder === next.isLoadingOlder
&& prev.sessionIsWorking === next.sessionIsWorking
&& prev.streamingMessageId === next.streamingMessageId
&& prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.retryOverlay === next.retryOverlay
&& prev.handleMessageContentChange === next.handleMessageContentChange
&& prev.getAnimationHandlers === next.getAnimationHandlers
&& prev.handleHistoryScroll === next.handleHistoryScroll
&& prev.scrollToBottom === next.scrollToBottom
&& prev.endPinningReleased === next.endPinningReleased
&& prev.revealContent === next.revealContent
&& prev.sessionQuestions === next.sessionQuestions
&& prev.sessionPermissions === next.sessionPermissions
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
@@ -805,6 +817,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return () => setWorkStatusPanelVisible(false);
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
const messageListRef = React.useRef<MessageListHandle | null>(null);
// Session keys that showed the hydration skeleton this app run; their
// content gets a one-shot reveal fade once it replaces the skeleton.
const hydrationRevealKeyRef = React.useRef<string | null>(null);
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
@@ -897,23 +913,67 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
activeTurnChangeRef.current(turnId);
}, []);
// The composer sits below the timeline, but the status/working row floats
// OVER the timeline's bottom edge; its measured height keeps the live
// streaming line above it and reserves matching end inset in the list.
const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0);
const composerOverlayHeight = statusOverlayHeight;
const statusOverlayObserverRef = React.useRef<ResizeObserver | null>(null);
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
statusOverlayObserverRef.current?.disconnect();
statusOverlayObserverRef.current = null;
if (!node || !globalThis.ResizeObserver) {
setStatusOverlayHeight(0);
return;
}
const update = () => {
// +8 for the mb-2 gap between the row and the composer, which the
// node's own box does not include.
const height = node.getBoundingClientRect().height + 8;
setStatusOverlayHeight((prev) => (Math.abs(prev - height) < 1 ? prev : height));
};
const observer = new ResizeObserver(update);
observer.observe(node);
statusOverlayObserverRef.current = observer;
update();
}, []);
React.useEffect(() => () => {
statusOverlayObserverRef.current?.disconnect();
statusOverlayObserverRef.current = null;
}, []);
const lastUserMessageId = React.useMemo(() => {
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
const message = sessionMessages[index];
if (message.info.role === 'user') {
return message.info.id;
}
}
return null;
}, [sessionMessages]);
const {
scrollRef,
notifyContentChange: handleMessageContentChange,
getAnimationHandlers,
scrollNode,
registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onManualNavigation,
onTimelineDataChange,
goToBottom,
scrollToBottomOnSend,
releaseAutoFollow,
restoreSnapshot,
isPinned,
isFollowingProgrammatically,
showScrollButton,
} = useChatAutoFollow({
userOwnsScroll,
} = useChatTimelineScroll({
currentSessionId,
currentSessionKey,
sessionMessageCount,
sessionIsWorking,
isMobile,
composerOverlayHeight,
lastUserMessageId,
onActiveTurnChange: handleActiveTurnChange,
});
@@ -928,33 +988,49 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
messageListRef,
loadMoreMessages,
goToBottom,
releaseAutoFollow,
releaseAutoFollow: onManualNavigation,
isPinned,
showScrollButton,
});
// The list owns the scroll element, so the shadows and the load-older
// trigger bind to its node rather than to a wrapper we render.
const scrollNodeRef = React.useMemo(() => ({ current: scrollNode }), [scrollNode]);
useScrollShadow(scrollNodeRef, {
observeMutations: false,
hideTopShadow: isMobile && stickyUserHeader,
});
const handleHistoryScroll = timelineController.handleHistoryScroll;
React.useEffect(() => {
if (!scrollNode) return;
const onScroll = () => handleHistoryScroll();
scrollNode.addEventListener('scroll', onScroll, { passive: true });
return () => {
scrollNode.removeEventListener('scroll', onScroll);
};
}, [handleHistoryScroll, scrollNode]);
const resumeToLatestInstant = React.useCallback(() => {
goToBottom('instant');
}, [goToBottom]);
// Mobile loads older history via an explicit top button instead of a
// scroll-position trigger (see handleHistoryScroll in the controller).
const showLoadOlderButton = isMobileSurfaceRuntime()
&& timelineController.historySignals.canLoadEarlier;
const timelineLoadEarlier = timelineController.loadEarlier;
const handleLoadOlderClick = React.useCallback(() => {
// Loading older history is an explicit move INTO the past: release
// live follow first, or the prepend's content growth would trigger an
// end correction and throw the viewport to the bottom.
onManualNavigation();
void timelineLoadEarlier({ userInitiated: true });
}, [timelineLoadEarlier]);
}, [onManualNavigation, timelineLoadEarlier]);
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
}, [timelineController.handleActiveTurnChange]);
React.useEffect(() => {
if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
return;
}
handleMessageContentChange('permission');
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
const navigation = useChatTurnNavigation({
sessionId: currentSessionId,
turnIds: timelineController.turnIds,
@@ -964,7 +1040,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
resumeToBottom: timelineController.resumeToBottomInstant,
});
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' });
// Instant on purpose: a long smooth scroll through a virtualized
// timeline gets cancelled by row remounts and lands mid-way or on the
// wrong message; a teleport always arrives.
void navigation.scrollToTurnId(turnId, { behavior: 'auto' });
}, [navigation]);
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
const showPromptNavigator = !isMobile
@@ -1077,6 +1156,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const isSessionHydrating =
Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot;
React.useEffect(() => {
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
// One-shot: forget the key after the reveal animation has played so a
// later (now cached) visit to the same session opens instantly.
const timer = setTimeout(() => {
hydrationRevealKeyRef.current = null;
}, 400);
return () => clearTimeout(timer);
}, [isSessionHydrating, currentSessionKey]);
const retrySessionLoad = React.useCallback(() => {
if (!messagesEnabled || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
@@ -1090,7 +1178,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
lastScrolledSessionKeyRef.current = currentSessionKey;
if (hasHashTarget) {
// Hash navigation handler will scroll to target; we just release auto-follow.
releaseAutoFollow();
onManualNavigation();
return;
}
@@ -1102,7 +1190,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
} else {
window.requestAnimationFrame(run);
}
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
}, [active, currentSessionId, currentSessionKey, onManualNavigation, restoreSnapshot]);
React.useEffect(() => {
if (!messagesEnabled || !currentSessionId) return;
@@ -1197,7 +1285,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return <DraftWelcome exiting={draftPresentationExiting} />;
}
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
if (showHydrationSkeleton) {
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
}
if (showHydrationSkeleton) {
if (sessionMessageLoadState.status === 'error') {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
@@ -1217,6 +1309,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return (
<div
data-chat-hydration-skeleton=""
className={cn(
'relative min-h-0',
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
@@ -1271,21 +1364,24 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
directory={effectiveSessionDirectory}
scrollRef={scrollRef}
registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
onIsAtEndChange={onIsAtEndChange}
onTimelineDataChange={onTimelineDataChange}
messageListRef={messageListRef}
pendingRevealWork={timelineController.pendingRevealWork}
renderedMessages={timelineController.renderedMessages}
isLoadingOlder={timelineController.isLoadingOlder}
sessionIsWorking={sessionIsWorking}
streamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
handleHistoryScroll={timelineController.handleHistoryScroll}
scrollToBottom={resumeToLatestInstant}
endPinningReleased={userOwnsScroll}
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isFollowingProgrammatically}
@@ -1320,10 +1416,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
)}
>
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
<>
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
working={sessionIsWorking}
onClick={navigation.resumeToLatest}
/>
{/* Same anchor and column as the pill, so the status
row and the pill it hands off to share the exact
distance from the input and the same left edge. */}
<div
className={cn(
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
userOwnsScroll && 'opacity-0',
)}
>
<div className="chat-input-column">
{/* The glass chip itself is rendered inside
StatusRow (its root is a size container
that cannot shrink-wrap). */}
<div
ref={onStatusOverlayNode}
className={cn(
'[&:not(:has(*))]:hidden',
userOwnsScroll ? 'pointer-events-none' : 'pointer-events-auto',
)}
>
<StatusRowContainer />
</div>
</div>
</div>
</>
)}
{promptReadOnly ? (
<ReadOnlyPromptBanner />
@@ -1331,6 +1454,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<ChatInput
active={active}
scrollToBottom={scrollToBottomOnSend}
scrollToLatest={resumeToLatestInstant}
draftPresentationExiting={draftPresentationExiting}
/>
)}
+20 -13
View File
@@ -48,7 +48,7 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
import { cn } from "@/lib/utils";
import { ModelControls } from './ModelControls';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { StatusRow } from './StatusRow';
import { ComposerStatusBar } from './ComposerStatusBar';
import { PendingChangesBar } from './PendingChangesBar';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
@@ -220,11 +220,15 @@ const MemoModelControls = React.memo(ModelControls);
const MemoComposerDictation = React.memo(ComposerDictation);
const MemoMobileAgentButton = React.memo(MobileAgentButton);
const MemoMobileModelButton = React.memo(MobileModelButton);
const MemoStatusRow = React.memo(StatusRow);
const MemoComposerStatusBar = React.memo(ComposerStatusBar);
interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: () => void;
// Queued sends do not create a user row (the queue delivers later), so
// the anchor-arming scrollToBottom is wrong for them; this returns the
// viewport to the live edge instead.
scrollToLatest?: () => void;
active?: boolean;
draftPresentationExiting?: boolean;
}
@@ -243,6 +247,7 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
const ChatInputComponent: React.FC<ChatInputProps> = ({
onOpenSettings,
scrollToBottom,
scrollToLatest,
active = true,
draftPresentationExiting = false,
}) => {
@@ -898,6 +903,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
} : undefined,
});
// Sending while the agent works must still take the reader to the
// live edge — a queued message produces no user row yet, so the
// anchor path has nothing to claim and would leave the viewport
// parked mid-history.
scrollToLatest?.();
// Clear input and attachments
// Note: confirmedMentionsRef is NOT cleared here because queued messages
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
@@ -910,7 +921,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
if (!isMobile) {
composerRef.current?.focus();
}
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]);
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]);
const handleQueuedMessageEdit = React.useCallback((content: string) => {
setMessage(content);
@@ -1289,6 +1300,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
...additionalParts.flatMap(p => p.attachments ?? []),
];
// Arm the timeline anchor BEFORE the optimistic user row can commit;
// arming after (or a frame later) races the commit and the anchor
// never claims the new message.
scrollToBottom?.();
const sendPromise = sendMessage(
primaryText,
providerIdToSend,
@@ -1307,14 +1323,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}
};
if (typeof window === 'undefined') {
scrollToBottom?.();
} else {
window.requestAnimationFrame(() => {
scrollToBottom?.();
});
}
void sendPromise.then(() => {
// Record what this session was pointed at, so the work-status panel
// can show it as a context source long after the message scrolled
@@ -2648,9 +2656,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
/>
<MemoStatusRow
<MemoComposerStatusBar
showAbortStatus={showAbortStatus}
showAssistantStatus={false}
showTodos={composerStatusExtrasEnabled}
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
? null
@@ -14,7 +14,6 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageBody from './message/MessageBody';
import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types';
@@ -132,8 +131,6 @@ interface ChatMessageProps {
info: Message;
parts: Part[];
};
onContentChange?: (reason?: ContentChangeReason) => void;
animationHandlers?: AnimationHandlers;
scrollToBottom?: () => void;
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
@@ -148,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
message,
previousMessage,
nextMessage,
onContentChange,
animationHandlers,
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn = false,
@@ -850,35 +845,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
});
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
const resolvedAnimationHandlers = animationHandlers ?? null;
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
const animationCompletedRef = React.useRef(false);
const hasRequestedReservationRef = React.useRef(false);
const animationStartNotifiedRef = React.useRef(false);
const hasTriggeredReservationOnceRef = React.useRef(false);
const hasEverStreamedRef = React.useRef(false);
React.useEffect(() => {
animationCompletedRef.current = false;
hasRequestedReservationRef.current = false;
animationStartNotifiedRef.current = false;
hasTriggeredReservationOnceRef.current = false;
hasAnnouncedAuxiliaryScrollRef.current = false;
hasEverStreamedRef.current = false;
}, [message.info.id]);
const handleAuxiliaryContentComplete = React.useCallback(() => {
if (isUser) {
return;
}
if (hasAnnouncedAuxiliaryScrollRef.current) {
return;
}
hasAnnouncedAuxiliaryScrollRef.current = true;
onContentChange?.('structural');
}, [isUser, onContentChange]);
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
@@ -901,114 +873,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
hasEverStreamedRef.current = true;
}
const hasReasoningParts = React.useMemo(() => {
if (isUser) {
return false;
}
return visibleParts.some((part) => part.type === 'reasoning');
}, [isUser, visibleParts]);
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
return;
}
if (!shouldReserveAnimationSpace) {
if (hasRequestedReservationRef.current) {
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
resolvedAnimationHandlers.onReasoningBlock();
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
resolvedAnimationHandlers.onReservationCancelled();
}
hasRequestedReservationRef.current = false;
}
return;
}
if (hasTriggeredReservationOnceRef.current) {
return;
}
hasTriggeredReservationOnceRef.current = true;
resolvedAnimationHandlers.onStreamingCandidate();
hasRequestedReservationRef.current = true;
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onAnimationStart) {
return;
}
if (!allowAnimation) {
return;
}
if (animationStartNotifiedRef.current) {
return;
}
resolvedAnimationHandlers.onAnimationStart();
animationStartNotifiedRef.current = true;
}, [resolvedAnimationHandlers, allowAnimation]);
React.useEffect(() => {
if (isUser) {
return;
}
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
if (!handler) {
return;
}
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
if (!shouldTrackHeight) {
return;
}
const element = messageContainerRef.current;
if (!element) {
return;
}
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
handler(element.getBoundingClientRect().height);
return;
}
let rafId: number | null = null;
const notifyHeight = (height: number) => {
if (typeof window === 'undefined') {
handler(height);
return;
}
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(() => {
handler(height);
});
};
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
notifyHeight(entry.contentRect.height);
});
observer.observe(element);
notifyHeight(element.getBoundingClientRect().height);
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
observer.disconnect();
};
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
if (shouldHideUserMessage) {
return null;
@@ -1070,13 +935,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
@@ -1106,13 +969,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
@@ -1152,12 +1013,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={shouldShowHeader}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
showReasoningTraces={showReasoningTraces}
agentMention={agentMention}
turnGroupingContext={turnGroupingContext}
@@ -0,0 +1,305 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The bar that sits in the composer stack: pending-changes accessory, abort
// status, and the todos dropdown. Deliberately a separate component from
// StatusRow — that one is the floating assistant-status chip above the
// composer, and sharing markup meant every restyle of the chip (glass,
// placement) silently restyled this bar and its dropdown too.
type TodoItem = Todo & { id?: string };
const COMPOSER_STATUS_BAR_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "composer-status-bar" };
const statusConfig = {
in_progress: { textClassName: "text-foreground" },
pending: { textClassName: "text-foreground" },
completed: { textClassName: "text-muted-foreground line-through" },
cancelled: { textClassName: "text-muted-foreground line-through" },
};
const priorityClassName = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true" />,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true" />,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabelKey = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
// SAFETY: todo.status / todo.priority arrive from the SDK as open strings;
// lookups treat them as candidate keys and every call site falls back to a
// default entry when the value is outside the known set.
const knownStatus = (status: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
status as keyof typeof statusConfig;
const knownPriority = (priority: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
priority as keyof typeof priorityClassName;
const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[knownStatus(todo.status)] || statusConfig.pending;
// SAFETY: the label keys are literal members of the i18n dictionary; the
// lookup narrows an open SDK string with a known fallback, and t() accepts
// only the generated key union.
const statusKey = (statusLabelKey[knownStatus(todo.status)] ?? statusLabelKey.pending) as Parameters<typeof t>[0];
// SAFETY: same literal-member narrowing as statusKey above.
const priorityKey = (priorityLabelKey[knownPriority(todo.priority)] ?? priorityLabelKey.medium) as Parameters<typeof t>[0];
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey)}
</TooltipContent>
</Tooltip>
<span className={cn("flex-1 typography-ui-label", config.textClassName)}>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[knownPriority(todo.priority)] ?? priorityClassName.medium,
)}
>
{priorityIcon[knownPriority(todo.priority)] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface ComposerStatusBarProps {
showAbortStatus?: boolean;
showTodos?: boolean;
leftAccessory?: React.ReactNode;
}
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
showAbortStatus,
showTodos = true,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((todo) => todo.status === "in_progress") ||
visibleTodos.find((todo) => todo.status === "pending") ||
null
);
}, [visibleTodos]);
const progress = React.useMemo(() => {
const total = todos.filter((todo) => todo.status !== "cancelled").length;
const completed = todos.filter((todo) => todo.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((todo) => todo.status === "in_progress").length;
const left = visibleTodos.filter((todo) => todo.status === "in_progress" || todo.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasLeftAccessory = Boolean(leftAccessory);
const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory;
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
// SAFETY: mousedown targets are DOM nodes; contains() only needs Node.
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{!isCompact && activeTodo ? (
<span className="composer-status-bar__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
if (!hasContent) {
return null;
}
return (
<div className="mb-2" style={COMPOSER_STATUS_BAR_CONTAINER_STYLE}>
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: abort status | pending-changes accessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true" />
{t('chat.statusRow.aborted')}
</span>
</div>
) : leftAccessory ? (
leftAccessory
) : null}
</div>
{/* Right: todos dropdown */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory && "pr-1.5")} ref={popoverRef}>
{todoTrigger}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150",
)}
>
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -352,6 +352,13 @@ const useFileReferenceInteractions = ({
if (!container) {
return;
}
// Wait for the real directory: annotating against an empty/fallback
// directory issues stat probes under the wrong cache key (and the wrong
// server directory), and the pass reruns anyway once the directory
// resolves — every link ended up verified twice.
if (enabled && !effectiveDirectory) {
return;
}
let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
// On mobile surfaces, file-reference highlighting is disabled entirely — not
@@ -405,6 +412,19 @@ const useFileReferenceInteractions = ({
};
const annotateFileLinks = () => {
annotationWriteDepth += 1;
try {
annotateFileLinksInner();
} finally {
// Let the mutation events from our own writes flush before the
// observer starts listening for real content changes again.
queueMicrotask(() => {
annotationWriteDepth -= 1;
});
}
};
const annotateFileLinksInner = () => {
if (fileReferencesEnabled) {
wrapBlockCodePathTokens(container);
}
@@ -533,7 +553,12 @@ const useFileReferenceInteractions = ({
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
// Our own annotation writes (path-token wrapping, attribute updates) fire
// childList mutations too; observing them re-ran the whole pass — every
// link was scanned and verified twice per render.
let annotationWriteDepth = 0;
const observer = new MutationObserver(() => {
if (annotationWriteDepth > 0) return;
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
});
observer.observe(container, {
@@ -918,13 +943,19 @@ const useMorphdomMarkdown = ({
if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
// Reconcile per block: only re-morph blocks whose content changed, leaving
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
// to the trailing (growing) block instead of the whole message.
let enteredThisPass = 0;
blocks.forEach((block, index) => {
let el = existing[index];
let isNewBlock = false;
if (!el) {
el = document.createElement('div');
el.setAttribute('data-md-block', '');
el.style.display = 'contents';
target.appendChild(el);
isNewBlock = true;
}
if (el.getAttribute('data-md-id') === block.id) {
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
@@ -952,6 +983,23 @@ const useMorphdomMarkdown = ({
const temp = document.createElement('div');
temp.innerHTML = block.html;
decorateMarkdown(temp, ctx);
if (isNewBlock && streaming && index > 0) {
// A freshly committed block enters with a short reveal. The class
// goes on the block's children — the wrapper is display:contents
// and cannot animate — and the transform never changes layout, so
// row measurement stays exact. Skipped for the first block so a
// full initial render does not shimmer. Several blocks committed
// in one tick cascade with a small stagger instead of popping in
// together.
const delayMs = Math.min(enteredThisPass, 4) * 55;
enteredThisPass += 1;
for (const child of Array.from(temp.children)) {
child.classList.add('oc-md-block-enter');
if (delayMs > 0 && child instanceof HTMLElement) {
child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
}
}
}
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
morphdom(el, temp, {
@@ -1,102 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useActivationOverscan } from './useActivationOverscan';
type Frame = FrameRequestCallback;
describe('MessageList activation overscan', () => {
let windowInstance: Window;
let host: HTMLDivElement;
let root: Root;
let pendingFrames: Map<number, Frame>;
let nextFrameId: number;
let renderCount: number;
beforeEach(() => {
windowInstance = new Window();
Object.assign(globalThis, {
window: windowInstance,
document: windowInstance.document,
HTMLElement: windowInstance.HTMLElement,
Element: windowInstance.Element,
Node: windowInstance.Node,
IS_REACT_ACT_ENVIRONMENT: true,
});
pendingFrames = new Map();
nextFrameId = 1;
renderCount = 0;
Object.defineProperty(windowInstance, 'requestAnimationFrame', {
configurable: true,
value: (callback: Frame) => {
const frameId = nextFrameId;
nextFrameId += 1;
pendingFrames.set(frameId, callback);
return frameId;
},
});
Object.defineProperty(windowInstance, 'cancelAnimationFrame', {
configurable: true,
value: (id: number) => {
pendingFrames.delete(id);
},
});
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
windowInstance.close();
});
const Harness = ({ normalOverscan }: { normalOverscan: number }) => {
renderCount += 1;
const overscan = useActivationOverscan(true, normalOverscan);
return <div data-overscan={overscan} />;
};
const runNextFrame = async (timestamp: number): Promise<void> => {
const nextFrame = pendingFrames.entries().next();
if (nextFrame.done) throw new Error('No animation frame is pending');
const [frameId, callback] = nextFrame.value;
pendingFrames.delete(frameId);
await act(async () => callback(timestamp));
};
test('restores normal overscan in at most two renders after the first paint opportunity', async () => {
await act(async () => root.render(<Harness normalOverscan={8} />));
expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('0');
expect(renderCount).toBe(1);
await runNextFrame(0);
expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('0');
expect(renderCount).toBe(1);
await runNextFrame(16);
expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('4');
expect(renderCount).toBe(2);
await runNextFrame(32);
expect(host.firstElementChild?.getAttribute('data-overscan')).toBe('8');
expect(renderCount).toBe(3);
});
test('cancels every pending restoration frame when the list unmounts', async () => {
for (const framesToRun of [0, 1, 2]) {
await act(async () => root.render(<Harness normalOverscan={16} />));
for (let frame = 0; frame < framesToRun; frame += 1) {
await runNextFrame(frame * 16);
}
expect(pendingFrames.size).toBe(1);
await act(async () => root.render(null));
expect(pendingFrames.size).toBe(0);
}
});
});
File diff suppressed because it is too large Load Diff
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
>
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
<span className="composer-status-bar__changed-label min-w-0 typography-ui-label text-foreground truncate">
{t('chat.pendingChanges.changedInWorkspace')}
</span>
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
+17 -320
View File
@@ -1,123 +1,18 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
// Compat aliases for old TodoItem shape
type TodoItem = Todo & { id?: string };
type TodoStatus = string;
type TodoPriority = string;
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The floating assistant-status chip that hovers above the composer while the
// agent works ("Claude is working…", abort notice). ONLY that. The composer's
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
// to share this component, and every restyle of this chip (glass, placement)
// silently dragged the composer bar and its dropdown along with it.
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
textClassName: "text-foreground",
},
pending: {
textClassName: "text-foreground",
},
completed: {
textClassName: "text-muted-foreground line-through",
},
cancelled: {
textClassName: "text-muted-foreground line-through",
},
};
const priorityClassName: Record<TodoPriority, string> = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true"/>,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
};
const statusLabelKey: Record<TodoStatus, string> = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey: Record<TodoPriority, string> = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
interface TodoItemRowProps {
todo: TodoItem;
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[todo.status] || statusConfig.pending;
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true"/>
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true"/>
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"/>
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey as never)}
</TooltipContent>
</Tooltip>
<span
className={cn(
"flex-1 typography-ui-label",
config.textClassName
)}
>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[todo.priority] ?? priorityClassName.medium
)}
>
{priorityIcon[todo.priority] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey as never)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface StatusRowProps {
// Working state
isWorking?: boolean;
statusText?: string | null;
isGenericStatus?: boolean;
@@ -125,17 +20,10 @@ interface StatusRowProps {
wasAborted?: boolean;
abortActive?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
// Abort status display
showAbortStatus?: boolean;
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string;
modelName?: string | null;
providerId?: string | null;
leftAccessory?: React.ReactNode;
}
export const StatusRow: React.FC<StatusRowProps> = ({
@@ -146,192 +34,43 @@ export const StatusRow: React.FC<StatusRowProps> = ({
wasAborted,
abortActive,
retryInfo,
showAbort,
onAbort,
showAbortStatus,
showAssistantStatus = true,
showTodos = true,
agentName,
modelName,
providerId,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
// Filter out cancelled todos for display and keep original order.
// This prevents items from jumping around when status changes.
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
// Find the current active todo (first in_progress, or first pending)
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((t) => t.status === "in_progress") ||
visibleTodos.find((t) => t.status === "pending") ||
null
);
}, [visibleTodos]);
// Calculate progress
const progress = React.useMemo(() => {
const total = todos.filter((t) => t.status !== "cancelled").length;
const completed = todos.filter((t) => t.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasAssistantContent = showAssistantStatus && (
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus)
);
const hasLeftAccessory = Boolean(leftAccessory);
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus);
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
// Abort button for mobile/vscode
const abortButton = showAbort && onAbort ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
>
<Icon name="close-circle" aria-hidden="true"/>
</button>
) : null;
// Todo trigger button
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
// Don't render if nothing to show
if (!hasContent) {
return null;
}
return (
<div
// This row must land exactly where the assistant turn footer (mt-2
// inside the message) appears when the turn completes. Measured against
// the live DOM: the gap ABOVE already matches (message pb-2 = footer
// mt-2 = 8px), but the chat is bottom-anchored and the finished message
// carries ~12px more structure BELOW its footer than this row has — so
// the swap used to lift the line up. mb-6 (24px) reserves that space
// under this row instead (verified: row top 636 == footer top 636).
// The reservation belongs to the assistant-status swap only: a row that
// renders just an accessory (the pending-changes bar) takes the normal
// 8px, or it floats a stray gap above the composer.
className={cn(showAssistantStatus ? "mb-6" : "mb-2", !hasLeftAccessory && "chat-column")}
// The row renders inside the composer-anchored overlay, which owns the
// distance to the input and the horizontal column (the same ones the
// scroll-to-bottom pill uses).
style={STATUS_ROW_CONTAINER_STYLE}
>
{/* h-8 matches the turn footer's real row height: its h-8 action
buttons define the footer line, with the meta text centered in it. */}
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: Abort status | Working placeholder | leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAssistantStatus && showAbortStatus ? (
{/* The glass chip lives here, not on the container: the root above is
an inline-size query container, whose width ignores its children
a shrink-to-fit wrapper around it always collapsed to zero. */}
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true"/>
{t('chat.statusRow.aborted')}
</span>
</div>
) : showAssistantStatus && shouldRenderPlaceholder ? (
) : shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
isWorking={isWorking}
@@ -343,50 +82,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
modelName={modelName}
providerId={providerId}
/>
) : leftAccessory ? (
leftAccessory
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
>
{/* Header */}
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
@@ -46,8 +46,6 @@ export const StatusRowContainer: React.FC = React.memo(() => {
wasAborted={wasAborted || working.wasAborted}
abortActive={wasAborted || working.abortActive}
retryInfo={working.retryInfo}
showAssistantStatus
showTodos={false}
agentName={currentAgentName}
modelName={modelDisplayName}
providerId={activeModel?.providerId ?? null}
@@ -26,9 +26,6 @@ import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const };
/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */
const NOOP_CONTENT_CHANGE = (): void => {};
/**
* The `/btw` peek panel.
*
@@ -446,7 +443,6 @@ const BtwMessages: React.FC<{
message={record}
previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]}
onContentChange={NOOP_CONTENT_CHANGE}
isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
@@ -1,33 +1,85 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
/**
* Compact one-line mirror of the status row for the pill: same label, none of
* the status row's animation machinery (which does not survive being squeezed
* into a 32px chip).
*/
const PillWorkingStatus: React.FC = () => {
const { t } = useI18n();
const { activeModel, working } = useAssistantStatus();
const providers = useConfigStore((state) => state.providers);
const modelName = React.useMemo(() => {
if (!activeModel) return null;
const provider = providers.find((candidate) => candidate.id === activeModel.providerId);
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
}, [activeModel, providers]);
if (!working.isWorking || !working.statusText) return null;
const status = working.statusText;
const label = modelName && modelName.trim().length > 0
? t('chat.statusRow.modelStatus', { model: modelName.trim(), status })
: status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className="min-w-0 truncate pr-3 text-sm text-muted-foreground">
{label}
<span className="animate-pulse"> </span>
</span>
);
};
interface ScrollToBottomButtonProps {
visible: boolean;
/** The session is still streaming: the pill carries the status label
while the floating status row is hidden away from the live edge. */
working?: boolean;
onClick: () => void;
}
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, working = false, onClick }) => {
const { t } = useI18n();
return (
<div
className={cn(
'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 transition-all duration-150',
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none',
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
visible ? 'opacity-100' : 'opacity-0',
)}
>
<Button
variant="outline"
size="sm"
onClick={onClick}
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label={t('chat.scrollToBottom.aria')}
>
<Icon name="arrow-down" className="h-4 w-4" />
</Button>
{/* The same column that centres the composer, so the pill's left
edge lines up exactly with the input frame. */}
<div className="chat-input-column">
{/* The soft shadow lives on this wrapper, away from the glass
button's backdrop-filter: sharing one element made the
shadow intermittently drop after hide/show cycles. */}
<div className="inline-flex max-w-full rounded-full shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.10)] dark:shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.35)]">
<button
type="button"
onClick={onClick}
aria-label={t('chat.scrollToBottom.aria')}
className={cn(
// Glass material with a hairline real border — much
// lighter than the oc-glass-floating stack.
'oc-glass-popover inline-flex h-8 max-w-full items-center rounded-full [corner-shape:round] text-left',
'border border-black/[0.06] dark:border-white/[0.08]',
visible ? 'pointer-events-auto' : 'pointer-events-none',
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground">
<Icon name="arrow-down" className="h-4 w-4" />
</span>
{working && visible ? <PillWorkingStatus /> : null}
</button>
</div>
</div>
</div>
);
};
@@ -4,7 +4,6 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
import type { TurnActivityRecord } from '../lib/turns/types';
import type { ToolPopupContent } from '../message/types';
import type { StreamPhase } from '../message/types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
interface DiffStats {
additions: number;
@@ -21,7 +20,6 @@ interface TurnActivityProps {
expandedTools: Set<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
@@ -0,0 +1,227 @@
import { describe, expect, test } from 'bun:test';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom,
resolveChatListAnchoredEndSpace,
resolveTimelineIsAtEnd,
type TimelineListMeasurementState,
} from './timelineScrollAnchoring';
const buildState = ({
positions,
sizes,
scroll = 0,
scrollLength = 700,
}: {
readonly positions: readonly number[];
readonly sizes: readonly number[];
readonly scroll?: number;
readonly scrollLength?: number;
}): TimelineListMeasurementState => ({
data: positions.map((_, index) => index),
scroll,
scrollLength,
positionAtIndex: (index) => positions[index],
sizeAtIndex: (index) => sizes[index],
});
describe('getRowBottom', () => {
test('measures row bottoms from list row position and size', () => {
const state = buildState({ positions: [0, 120], sizes: [80, 40] });
expect(getRowBottom(state, 1)).toBe(160);
});
test('returns null for unmeasured rows', () => {
const state = buildState({ positions: [0], sizes: [80] });
expect(getRowBottom(state, 5)).toBeNull();
});
test('treats a zero-height row as one pixel tall', () => {
const state = buildState({ positions: [0, 120], sizes: [120, 0] });
expect(getRowBottom(state, 1)).toBe(121);
});
});
describe('getAnchoredTurnMetrics', () => {
test('returns null for an empty timeline', () => {
const state = buildState({ positions: [], sizes: [] });
expect(getAnchoredTurnMetrics({
state,
anchorIndex: 0,
composerOverlayHeight: 180,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
})).toBeNull();
});
test('treats the active turn as fitting when it fits above the composer', () => {
const state = buildState({
positions: [0, 300, 460],
sizes: [240, 80, 140],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(300);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(false);
expect(metrics?.targetScrollToRevealEnd).toBe(36);
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
});
test('targets the real row end instead of any temporary reserved tail', () => {
const state = buildState({
positions: [0, 1720, 1880],
sizes: [1600, 80, 120],
scroll: 1900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(2000);
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
});
test('reports overflow only for the current anchored turn', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 300],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(580);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(true);
});
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 360],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(1540);
expect(metrics?.visibleUsableBottom).toBe(1464);
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
});
test('subtracts composer height from usable viewport height', () => {
const state = buildState({
positions: [0, 300],
sizes: [120, 470],
scrollLength: 700,
});
const withoutComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 0,
anchorOffset: 16,
});
const withComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 220,
anchorOffset: 16,
});
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
expect(withComposer?.overflowsUsableViewport).toBe(true);
});
test('clamps an out-of-range anchor index to the last row', () => {
const state = buildState({
positions: [0, 300],
sizes: [240, 80],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 99,
composerOverlayHeight: 0,
anchorOffset: 16,
});
expect(metrics?.anchorTop).toBe(300);
expect(metrics?.turnHeight).toBe(80);
});
});
describe('resolveTimelineIsAtEnd', () => {
test('uses a tight distance band against the full content length', () => {
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
});
test('falls back to the list flags when distances are unavailable', () => {
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
});
test('reports nothing without a state', () => {
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
});
});
describe('resolveChatListAnchoredEndSpace', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
test('returns nothing when no anchor is set', () => {
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
});
test('returns nothing when the anchor is not in the list', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
});
test('resolves the last occurrence so a resent message anchors to its live row', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
anchorIndex: 2,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
test('honours an explicit anchor offset', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
anchorIndex: 1,
anchorOffset: 40,
});
});
});
@@ -0,0 +1,167 @@
// Anchored-turn scroll geometry for the chat timeline.
//
// The timeline has three mutually exclusive scroll modes:
//
// • `following-end` — stay pinned to the live edge as content grows.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
// of the viewport and the reply streams into reserved space below it. The
// viewport does NOT move until the turn outgrows the usable viewport.
// • `free-scrolling` — the user took over; nothing moves the scroll
// position until they opt back in.
//
// This module is pure geometry: it reads measurements from the virtualized
// list and answers "how far, if at all, must we scroll to reveal the end of
// the anchored turn". Keeping it free of DOM and React makes the mode machine
// testable without a renderer.
//
// "Usable viewport" is the visible height minus the composer overlay (the
// composer floats over the list) minus the anchor offset, so a turn is only
// considered overflowing when it genuinely cannot be read.
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
// Distance from the top of the viewport at which an anchored user message
// parks. Small enough to read as "at the top", large enough not to collide
// with the timeline's top fade.
export const CHAT_LIST_ANCHOR_OFFSET = 16;
export interface TimelineListMeasurementState {
readonly data: readonly unknown[];
readonly scroll: number;
readonly scrollLength: number;
readonly positionAtIndex: (index: number) => number | undefined;
readonly sizeAtIndex: (index: number) => number | undefined;
}
export interface AnchoredTurnMetrics {
readonly anchorTop: number;
readonly lastBottom: number;
readonly turnHeight: number;
readonly usableViewportHeight: number;
readonly visibleUsableBottom: number;
readonly overflowsUsableViewport: boolean;
readonly targetScrollToRevealEnd: number;
readonly scrollDeltaToRevealEnd: number;
}
export const getRowBottom = (
state: TimelineListMeasurementState,
index: number,
): number | null => {
const top = state.positionAtIndex(index);
const height = state.sizeAtIndex(index);
if (
typeof top !== 'number'
|| typeof height !== 'number'
|| !Number.isFinite(top)
|| !Number.isFinite(height)
) {
return null;
}
// Rows measured at zero height would make an anchored turn look empty and
// suppress the reveal scroll; treat them as one pixel tall instead.
return top + Math.max(1, height);
};
export const getAnchoredTurnMetrics = ({
state,
anchorIndex,
composerOverlayHeight,
anchorOffset,
}: {
readonly state: TimelineListMeasurementState;
readonly anchorIndex: number;
readonly composerOverlayHeight: number;
readonly anchorOffset: number;
}): AnchoredTurnMetrics | null => {
if (state.data.length === 0) return null;
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
// The LAST row bottom, not the content length: the reserved anchored end
// space lives past it, and targeting that reserved tail would scroll the
// real content off the top.
const lastBottom = getRowBottom(state, state.data.length - 1);
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
return null;
}
const usableViewportHeight = Math.max(
0,
state.scrollLength - composerOverlayHeight - anchorOffset,
);
const turnHeight = Math.max(0, lastBottom - anchorTop);
const visibleUsableBottom = state.scroll + usableViewportHeight;
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
// Never negative: revealing the end must not scroll the timeline backwards.
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
return {
anchorTop,
lastBottom,
turnHeight,
usableViewportHeight,
visibleUsableBottom,
overflowsUsableViewport: turnHeight > usableViewportHeight,
targetScrollToRevealEnd,
scrollDeltaToRevealEnd,
};
};
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
// follow while the user had genuinely scrolled away, yanking them back on the
// next stream chunk. Distance is measured against the full content length —
// reserved anchored end space included — so a parked anchored turn counts as
// the live edge.
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
export const resolveTimelineIsAtEnd = (
state: {
readonly contentLength?: number;
readonly scroll?: number;
readonly scrollLength?: number;
readonly isNearEnd?: boolean;
readonly isAtEnd?: boolean;
} | undefined,
): boolean | undefined => {
if (!state) return undefined;
const { contentLength, scroll, scrollLength } = state;
if (
typeof contentLength === 'number'
&& typeof scroll === 'number'
&& typeof scrollLength === 'number'
&& Number.isFinite(contentLength)
) {
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
}
return state.isNearEnd ?? state.isAtEnd;
};
export interface ChatListAnchoredEndSpace {
readonly anchorIndex: number;
readonly anchorOffset: number;
}
// Finds the anchored row from the BACK of the list: a retried or re-sent
// message id can appear more than once, and the live one is always the last.
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
items: readonly Item[],
anchorId: AnchorId | null,
getAnchorId: (item: Item) => AnchorId | null,
options: { readonly anchorOffset?: number } = {},
): ChatListAnchoredEndSpace | undefined => {
if (anchorId === null) return undefined;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item !== undefined && getAnchorId(item) === anchorId) {
return {
anchorIndex: index,
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
};
}
}
return undefined;
};
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test';
import { commitStreamedText } from './streamTextCommit';
describe('commitStreamedText', () => {
test('holds an incomplete short paragraph entirely', () => {
expect(commitStreamedText('An unfinished thought abo')).toBe('');
});
test('commits up to the last complete line', () => {
expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
});
test('reveals code fences line by line', () => {
const text = '```py\nprint("a")\nprint("b';
expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
});
test('releases a long held paragraph at the last sentence boundary', () => {
const sentence = 'A finished sentence lives here. ';
const text = sentence.repeat(12) + 'and an unfinished trail';
expect(commitStreamedText(text)).toBe(sentence.repeat(12));
});
test('falls back to the last word boundary without sentences', () => {
const words = 'word '.repeat(70);
const text = words + 'unfinishe';
expect(commitStreamedText(text)).toBe(words);
});
test('keeps unbreakable runs intact rather than splitting them', () => {
const run = 'x'.repeat(400);
expect(commitStreamedText(run)).toBe(run);
});
test('empty input stays empty', () => {
expect(commitStreamedText('')).toBe('');
});
});
@@ -0,0 +1,47 @@
// Block-level streaming reveal.
//
// Token-by-token streaming mutates the trailing paragraph in place on every
// tick: words rewrap, the last line jitters, and the reader's eye fights the
// motion. Committing only up to the last COMPLETE line keeps every rendered
// block immutable once it appears — prose arrives a paragraph at a time (a
// markdown paragraph is one logical line), code fences reveal line by line,
// tables row by row — and the only remaining motion is the follow scroll.
//
// A paragraph with no newline for a long stretch must not stall the stream,
// so once the held tail outgrows a threshold it is committed at the last
// sentence boundary (falling back to the last word boundary).
const HOLD_MAX_CHARS = 320;
const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
export const commitStreamedText = (text: string): string => {
if (text.length === 0) return text;
const lastNewline = text.lastIndexOf('\n');
const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
const held = text.slice(committed.length);
if (held.length <= HOLD_MAX_CHARS) {
return committed;
}
// The held paragraph got long: release it up to the last finished
// sentence so the block still never mutates mid-sentence.
let lastSentenceEnd = -1;
for (const match of held.matchAll(SENTENCE_END)) {
lastSentenceEnd = match.index + match[0].length;
}
if (lastSentenceEnd > 0) {
return committed + held.slice(0, lastSentenceEnd);
}
// No sentence boundary either (a URL, a very long token run): release up
// to the last word boundary, keeping only the incomplete word held.
const lastSpace = held.lastIndexOf(' ');
if (lastSpace > 0) {
return committed + held.slice(0, lastSpace + 1);
}
return text;
};
@@ -64,8 +64,7 @@ describe('buildLiveStreamingEntry', () => {
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_other',
liveParts: [textPart('part_live', 'live')],
livePartsByMessageId: { assistant_other: [textPart('part_live', 'live')] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -79,8 +78,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [reasoningPart('part_1_live', 'thinking')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
livePartsByMessageId: { assistant_1: liveParts },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -102,8 +100,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [textPart('part_1_live', 'live')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
livePartsByMessageId: { assistant_1: liveParts },
showTextJustificationActivity: false,
showTurnChangedFiles: false,
});
@@ -121,8 +118,7 @@ describe('buildLiveStreamingEntry', () => {
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts: [synthetic, visible],
livePartsByMessageId: { assistant_1: [synthetic, visible] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -131,4 +127,39 @@ describe('buildLiveStreamingEntry', () => {
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
});
test('keeps a finished step message on its live parts after the stream moves on', () => {
const finished = message('assistant_1', 'assistant', 'user_1', []);
const streaming = message('assistant_2', 'assistant', 'user_1', []);
const entry = turnEntry(finished);
if (entry.kind !== 'turn') return;
entry.turn.assistantMessageIds = ['assistant_1', 'assistant_2'];
entry.turn.assistantMessages = [finished, streaming];
const finishedLive = [textPart('part_tool_done', 'tool output')];
const streamingLive = [textPart('part_streaming', 'streaming')];
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: finishedLive, assistant_2: streamingLive },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual(finishedLive);
expect(next.turn.assistantMessages[1]?.parts).toEqual(streamingLive);
});
test('never erases record parts with an empty live array', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'kept')]);
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: [] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).toBe(entry);
});
});
@@ -15,8 +15,13 @@ export type StreamingTailEntry =
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type BuildLiveStreamingEntryOptions = {
activeStreamingMessageId: string | null | undefined;
liveParts: Part[];
// Live parts for EVERY message of the streaming tail, not only the one
// currently streaming: when the stream moves to the next step message, the
// previous message's base record can still lag behind the part store, and
// rendering it from that stale snapshot briefly drops its completed tool
// parts — remounting them (and replaying their reveal animation) once the
// record catches up.
livePartsByMessageId: Readonly<Record<string, Part[]>>;
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
mergeHiddenUserTurns?: { planModeEnabled: boolean };
@@ -24,10 +29,12 @@ type BuildLiveStreamingEntryOptions = {
const withLiveParts = (
message: ChatMessageEntry,
activeStreamingMessageId: string,
liveParts: Part[],
livePartsByMessageId: Readonly<Record<string, Part[]>>,
): ChatMessageEntry => {
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
const liveParts = livePartsByMessageId[message.info.id];
// An empty live array is ambiguous — the store may simply not have loaded
// this message's parts — and must never erase parts the record does have.
if (!liveParts || liveParts.length === 0 || message.parts === liveParts) {
return message;
}
@@ -41,13 +48,10 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
entry: TEntry,
options: BuildLiveStreamingEntryOptions,
): TEntry => {
const activeStreamingMessageId = options.activeStreamingMessageId;
if (!activeStreamingMessageId) {
return entry;
}
const livePartsByMessageId = options.livePartsByMessageId;
if (entry.kind === 'ungrouped') {
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
const message = withLiveParts(entry.message, livePartsByMessageId);
if (message === entry.message) {
return entry;
}
@@ -59,7 +63,7 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
let changed = false;
const assistantMessages = entry.turn.assistantMessages.map((message) => {
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
const next = withLiveParts(message, livePartsByMessageId);
if (next !== message) {
changed = true;
}
@@ -133,6 +133,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
const code = pre.querySelector<HTMLElement>(':scope > code');
if (!code || code.hasAttribute('data-md-code-lines')) return;
// The real gutter takes over the reserved footprint.
pre.removeAttribute('data-md-gutter-reserved');
const text = code.textContent ?? '';
const hasTrailingNewline = text.endsWith('\n');
const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
@@ -265,7 +268,15 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
pre.style.margin = '0';
pre.style.background = 'transparent';
pre.classList.add('min-w-0', 'w-full', 'flex-1');
if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre);
if (!ctx.deferCodeLineNumberSync) {
layoutCodeLines(pre);
} else {
// Streaming defers the per-line gutter markup, but the gutter's
// horizontal footprint is reserved immediately — otherwise the
// end-of-stream decorate pass shifts every code line right by the
// gutter column and the finished message visibly jumps.
pre.setAttribute('data-md-gutter-reserved', '');
}
body.appendChild(pre);
wrapper.appendChild(header);
wrapper.appendChild(body);
@@ -178,9 +178,11 @@ type MarkdownBlock = {
raw: string;
src: string;
mode: 'full' | 'live';
// When false, skip syntax highlighting for this block. Set for the actively
// streaming open code fence so we don't re-tokenize a growing block ~40x/sec
// (O(n^2)); it highlights once the fence closes and becomes a stable block.
// When false, skip syntax highlighting for this block. Block-level commit
// feeds the open fence whole lines at the throttle cadence (<=10/sec), so a
// partial fence highlights too and streamed code arrives colored; only a
// very large open fence falls back to plain text until it closes, keeping
// the repeated worker re-tokenization bounded.
highlight: boolean;
};
@@ -201,6 +203,11 @@ const hasOpenFence = (raw: string): boolean => {
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
};
// Above this, re-highlighting the still-open fence on every committed line
// costs more than the colored preview is worth; the block highlights in one
// pass when the fence closes.
const OPEN_FENCE_HIGHLIGHT_LINE_LIMIT = 300;
const heal = (text: string): string => {
try {
return remend(text, { linkMode: 'text-only' });
@@ -250,11 +257,13 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
const raw = token.raw ?? '';
const isLast = i === tail;
const openFence = token.type === 'code' && hasOpenFence(raw);
const openFenceHighlight = openFence
&& raw.split('\n').length <= OPEN_FENCE_HIGHLIGHT_LINE_LIMIT;
blocks.push({
raw,
src: openFence ? raw : heal(raw),
mode: isLast ? 'live' : 'full',
highlight: !openFence,
highlight: !openFence || openFenceHighlight,
});
}
@@ -19,7 +19,6 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -419,13 +418,10 @@ interface MessageBodyProps {
onShowPopup: (content: ToolPopupContent) => void;
streamPhase: StreamPhase;
allowAnimation: boolean;
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
shouldShowHeader?: boolean;
hasTextContent?: boolean;
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
copiedMessage?: boolean;
onAuxiliaryContentComplete?: () => void;
showReasoningTraces?: boolean;
agentMention?: AgentMentionInfo;
turnGroupingContext?: TurnGroupingContext;
@@ -1112,10 +1108,8 @@ const AssistantMessageBody = React.memo(({
onShowPopup,
streamPhase: _streamPhase,
allowAnimation: _allowAnimation,
onContentChange,
hasTextContent = false,
onCopyMessage,
onAuxiliaryContentComplete,
showReasoningTraces = false,
turnGroupingContext,
errorMessage,
@@ -1423,50 +1417,6 @@ const AssistantMessageBody = React.memo(({
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
const hasAuxiliaryContent = hasTools || reasoningParts.length > 0;
const isTextlessAssistantMessage = assistantTextParts.length === 0;
const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete;
const auxiliaryCompletionAnnouncedRef = React.useRef(false);
const soloReasoningScrollTriggeredRef = React.useRef(false);
React.useEffect(() => {
soloReasoningScrollTriggeredRef.current = false;
}, [messageId]);
React.useEffect(() => {
if (!auxiliaryContentComplete) {
auxiliaryCompletionAnnouncedRef.current = false;
return;
}
if (auxiliaryCompletionAnnouncedRef.current) {
return;
}
auxiliaryCompletionAnnouncedRef.current = true;
onAuxiliaryContentComplete?.();
}, [auxiliaryContentComplete, onAuxiliaryContentComplete]);
React.useEffect(() => {
if (awaitingMessageCompletion) {
soloReasoningScrollTriggeredRef.current = false;
return;
}
if (hasTools) {
soloReasoningScrollTriggeredRef.current = false;
return;
}
if (reasoningParts.length === 0) {
return;
}
if (shouldHoldReasoning || !reasoningComplete) {
return;
}
if (soloReasoningScrollTriggeredRef.current) {
return;
}
soloReasoningScrollTriggeredRef.current = true;
onContentChange?.('structural');
}, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]);
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
const handleForkClick = React.useCallback(
@@ -1821,7 +1771,6 @@ const AssistantMessageBody = React.memo(({
expandedTools={expandedTools}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
streamPhase={effectiveStreamPhase}
showHeader={true}
animateRows={animateActivityRows}
@@ -1898,7 +1847,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId}
streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
/>
</div>
@@ -1933,7 +1881,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId}
streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
/>
);
@@ -1945,7 +1892,6 @@ const AssistantMessageBody = React.memo(({
part={part}
messageId={messageId}
streamPhase={effectiveStreamPhase}
onContentChange={onContentChange}
/>
);
}
@@ -1989,7 +1935,6 @@ const AssistantMessageBody = React.memo(({
onToggle={onToggleTool}
isMobile={isMobile}
alwaysShowActions={alwaysShowMessageActions}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
/>
@@ -2061,7 +2006,6 @@ const AssistantMessageBody = React.memo(({
messageActionButtons,
renderJustificationActions,
sessionId,
onContentChange,
onShowPopup,
onToggleTool,
shouldRenderActivityGroup,
@@ -2,7 +2,6 @@ import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import type { StreamPhase, ToolPopupContent } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
@@ -17,7 +16,6 @@ interface AssistantTextPartProps {
messageId: string;
streamPhase: StreamPhase;
chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
onShowPopup?: (content: ToolPopupContent) => void;
}
@@ -1,6 +1,5 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useUIStore } from '@/stores/useUIStore';
import { ReasoningTimelineBlock } from './ReasoningPart';
@@ -22,14 +21,12 @@ const cleanJustificationText = (text: string): string => {
interface JustificationBlockProps {
part: Part;
messageId: string;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode;
}
const JustificationBlock: React.FC<JustificationBlockProps> = ({
part,
messageId,
onContentChange,
actions,
}) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -47,7 +44,6 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
<ReasoningTimelineBlock
text={textContent}
variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-justification`}
time={time}
showDuration={chatRenderMode !== 'sorted'}
@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils';
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import ToolPart from './ToolPart';
import { MinDurationShineText } from './MinDurationShineText';
@@ -40,7 +39,6 @@ interface ProgressiveGroupProps {
expandedTools: Set<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
@@ -376,9 +374,7 @@ interface ExpandableToolRowProps {
isMobile: boolean;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
animateTailText: boolean;
animateRows: boolean;
}
const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
@@ -387,9 +383,7 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
isMobile,
onToggleTool,
onShowPopup,
onContentChange,
animateTailText,
animateRows,
}) => {
const handleToggle = React.useCallback(() => {
onToggleTool(activity.id);
@@ -401,23 +395,22 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
isExpanded={isExpanded}
onToggle={handleToggle}
isMobile={isMobile}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
animateTailText={animateTailText}
/>
);
const maybeWrapped = animateTailText ? (
<ToolRevealOnMount animate={true} wipe>
{content}
</ToolRevealOnMount>
) : content;
if (!animateRows) {
return maybeWrapped;
}
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
// Wrappers are unconditional: a conditional wrapper changes the element
// type at this position when animateTailText/animateRows flip (message
// completion), remounting the tool subtree and replaying the reveal wipe.
// Both wrappers are inert with animation off.
return (
<FadeInOnReveal>
<ToolRevealOnMount animate={animateTailText} wipe>
{content}
</ToolRevealOnMount>
</FadeInOnReveal>
);
};
const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
@@ -425,9 +418,7 @@ const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
&& prev.isMobile === next.isMobile
&& prev.onToggleTool === next.onToggleTool
&& prev.onShowPopup === next.onShowPopup
&& prev.onContentChange === next.onContentChange
&& prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& prev.activity.id === next.activity.id
&& prev.activity.kind === next.activity.kind
&& prev.activity.endedAt === next.activity.endedAt
@@ -438,14 +429,12 @@ interface StaticGroupedToolRowProps {
toolName: string;
activities: TurnActivityPart[];
animateTailText: boolean;
animateRows: boolean;
}
const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
toolName,
activities,
animateTailText,
animateRows,
}) => {
const content = (
<StaticToolRow
@@ -455,23 +444,22 @@ const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
/>
);
const maybeWrapped = animateTailText ? (
<ToolRevealOnMount animate={true} wipe>
{content}
</ToolRevealOnMount>
) : content;
if (!animateRows) {
return maybeWrapped;
}
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
// Wrappers are unconditional: a conditional wrapper changes the element
// type at this position when animateTailText/animateRows flip (message
// completion), remounting the tool subtree and replaying the reveal wipe.
// Both wrappers are inert with animation off.
return (
<FadeInOnReveal>
<ToolRevealOnMount animate={animateTailText} wipe>
{content}
</ToolRevealOnMount>
</FadeInOnReveal>
);
};
const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => {
return prev.toolName === next.toolName
&& prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& areActivityListsEqual(prev.activities, next.activities);
});
@@ -795,9 +783,8 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
/**
* Inline reasoning text block rendered as dimmed italic markdown.
*/
const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: {
const InlineReasoningBlock = React.memo(({ activity, streamPhase }: {
activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
}) => {
return (
@@ -805,7 +792,6 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
part={activity.part}
messageId={activity.messageId}
streamPhase={streamPhase}
onContentChange={onContentChange}
/>
);
});
@@ -813,16 +799,14 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
/**
* Inline justification text block rendered as normal assistant text between tools.
*/
const InlineJustificationBlock = React.memo(({ activity, onContentChange, actions }: {
const InlineJustificationBlock = React.memo(({ activity, actions }: {
activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode;
}) => {
return (
<JustificationBlock
part={activity.part}
messageId={activity.messageId}
onContentChange={onContentChange}
actions={actions}
/>
);
@@ -837,7 +821,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
expandedTools,
onToggleTool,
onShowPopup,
onContentChange,
streamPhase,
showHeader,
animateRows = true,
@@ -898,7 +881,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<InlineReasoningBlock
activity={row.activity}
streamPhase={streamPhase}
onContentChange={onContentChange}
/>
</>
);
@@ -909,7 +891,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<>
<InlineJustificationBlock
activity={row.activity}
onContentChange={onContentChange}
actions={renderJustificationActions?.(row.activity)}
/>
</>
@@ -924,9 +905,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/>
);
@@ -937,7 +916,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
toolName={row.toolName}
activities={row.activities}
animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))}
animateRows={animateRows}
/>
);
@@ -950,9 +928,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/>
);
@@ -2,7 +2,6 @@ import React from 'react';
import { animate, type AnimationPlaybackControls } from 'motion';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { BusyDots } from './BusyDots';
@@ -10,6 +9,7 @@ import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { commitStreamedText } from '../../lib/streamTextCommit';
import type { StreamPhase } from '../types';
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
@@ -81,7 +81,6 @@ const getReasoningSummary = (text: string): string => {
type ReasoningTimelineBlockProps = {
text: string;
variant: ReasoningVariant;
onContentChange?: (reason?: ContentChangeReason) => void;
blockId: string;
time?: { start?: number; end?: number };
showDuration?: boolean;
@@ -99,7 +98,6 @@ type ExpansionState = {
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
text,
variant,
onContentChange,
blockId,
time,
isStreaming = false,
@@ -123,11 +121,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const contentRef = React.useRef<HTMLDivElement>(null);
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const contentMountedRef = React.useRef(false);
// Stable handle to onContentChange so the height-animation layout effect can
// signal auto-follow without taking onContentChange as a dependency (which
// would risk re-running — and thus restarting — the animation on re-render).
const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange;
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const toggleAriaLabel = isExpanded
@@ -137,8 +130,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const handleToggle = React.useCallback(() => {
setShouldRenderExpandedContent(true);
setExpansion({ expanded: !isExpanded, source: 'user' });
onContentChange?.('structural');
}, [isExpanded, onContentChange]);
}, [isExpanded]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -159,13 +151,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
});
}, [canAutoExpand]);
React.useEffect(() => {
if (text.trim().length === 0) {
return;
}
onContentChange?.('structural');
}, [onContentChange, text]);
React.useEffect(() => {
if (isExpanded || isStreaming) {
setShouldRenderExpandedContent(true);
@@ -239,11 +224,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
element.style.height = '0px';
} else {
element.style.height = `${element.scrollHeight}px`;
// Only the COLLAPSE animation needs the guard: it shrinks the
// timeline and the trailing async scroll events can be misread as a
// user scroll-away. Expansion grows the timeline and re-pins cleanly,
// and guarding it caused a faint scroll fight while thinking streams.
onContentChangeRef.current?.('animation');
}
const animation = animate(
@@ -436,14 +416,12 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
type ReasoningPartProps = {
part: Part;
onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string;
streamPhase?: StreamPhase;
};
const ReasoningPart = React.memo(({
part,
onContentChange,
messageId,
streamPhase,
}: ReasoningPartProps) => {
@@ -454,11 +432,14 @@ const ReasoningPart = React.memo(({
const time = partWithText.time;
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
const throttledText = useStreamingTextThrottle({
const throttledTextRaw = useStreamingTextThrottle({
text: textContent,
isStreaming,
identityKey: `${messageId}:${part.id ?? 'reasoning'}`,
});
// Same block-level reveal as assistant text: a shown reasoning paragraph
// never mutates in place.
const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw;
// Show reasoning even if time.end isn't set yet (during streaming)
// Only hide if there's no text content
@@ -470,7 +451,6 @@ const ReasoningPart = React.memo(({
<ReasoningTimelineBlock
text={throttledText}
variant="thinking"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`}
time={time}
isStreaming={isStreaming}
@@ -20,7 +20,6 @@ import { toast } from '@/components/ui';
import { Text } from '@/components/ui/text';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import { PlainDiffFallback } from './PlainDiffFallback';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -82,7 +81,6 @@ interface ToolPartProps {
onToggle: (toolId: string) => void;
isMobile: boolean;
alwaysShowActions?: boolean;
onContentChange?: (reason?: ContentChangeReason) => void;
onShowPopup?: (content: ToolPopupContent) => void;
animateTailText?: boolean;
}
@@ -1684,7 +1682,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
isExpanded,
onToggle,
isMobile,
onContentChange,
onShowPopup,
animateTailText = true,
}) => {
@@ -1754,10 +1751,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
});
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange;
const expandedContentRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
@@ -1772,11 +1765,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
element.style.height = isExpanded ? 'auto' : '0px';
element.style.overflow = isExpanded ? 'visible' : 'hidden';
if (shouldNotifyStructuralChange) {
onContentChangeRef.current?.('structural');
}
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
}, [isExpanded, isTaskTool]);
const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
const time = stateWithData.time;
@@ -1934,26 +1923,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
return metadataTaskSummaryEntries;
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
const taskSummaryRenderSignature = React.useMemo(() => {
return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000');
}, [taskSummaryEntries]);
const lastTaskSummaryRenderSignatureRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!isTaskTool) {
lastTaskSummaryRenderSignatureRef.current = null;
return;
}
const previous = lastTaskSummaryRenderSignatureRef.current;
lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature;
if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) {
return;
}
onContentChangeRef.current?.('structural');
}, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]);
const diffStats = React.useMemo(() => {
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
? parseDiffStats(metadata)
@@ -2351,7 +2320,6 @@ export default React.memo(ToolPart, (prev, next) => {
&& prev.isExpanded === next.isExpanded
&& prev.isMobile === next.isMobile
&& prev.alwaysShowActions === next.alwaysShowActions
&& prev.onContentChange === next.onContentChange
&& prev.onShowPopup === next.onShowPopup
&& prev.animateTailText === next.animateTailText;
});
@@ -229,12 +229,11 @@ export function WorkingPlaceholder({
return (
<div
// Styled to mirror the turn footer's model row (text-sm,
// muted-foreground/60, no left inset): when the turn completes this row
// disappears and the footer appears in the same visual spot, so the two
// must read as the same line swapping its text.
// Full muted-foreground, matching the scroll-to-bottom pill's status
// text: the row and the pill hand off to each other in the same spot
// and must read as one element changing chrome.
className={
'flex h-full items-center text-muted-foreground/60'
'flex h-full items-center text-muted-foreground'
}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
@@ -1,9 +1,16 @@
import { commitStreamedText } from '../../lib/streamTextCommit';
export const resolveAssistantDisplayText = (input: {
textContent: string;
throttledTextContent: string;
isStreaming: boolean;
}): string => {
return input.isStreaming ? input.throttledTextContent : input.textContent;
// While streaming, reveal whole blocks only: rendering stops at the last
// complete line so a shown paragraph never mutates in place. The held
// tail lands with the next line break (or the finalize pass).
return input.isStreaming
? commitStreamedText(input.throttledTextContent)
: input.textContent;
};
export const shouldRenderAssistantText = (input: {
@@ -1,34 +0,0 @@
import * as React from 'react';
export const useActivationOverscan = (enabled: boolean, normalOverscan: number): number => {
const [recoveryStep, setRecoveryStep] = React.useState(0);
React.useEffect(() => {
if (!enabled) {
setRecoveryStep(0);
return;
}
let halfOverscanFrame: number | undefined;
const paintOpportunityFrame = window.requestAnimationFrame(() => {
halfOverscanFrame = window.requestAnimationFrame(() => {
React.startTransition(() => setRecoveryStep(1));
});
});
return () => {
window.cancelAnimationFrame(paintOpportunityFrame);
if (halfOverscanFrame !== undefined) window.cancelAnimationFrame(halfOverscanFrame);
};
}, [enabled]);
React.useEffect(() => {
if (!enabled || recoveryStep !== 1) return;
const normalOverscanFrame = window.requestAnimationFrame(() => {
React.startTransition(() => setRecoveryStep(2));
});
return () => window.cancelAnimationFrame(normalOverscanFrame);
}, [enabled, recoveryStep]);
if (!enabled || recoveryStep >= 2) return normalOverscan;
return recoveryStep === 0 ? 0 : Math.ceil(normalOverscan / 2);
};
@@ -242,7 +242,6 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
<WorkStatusPresenceProvider onChange={setRenderedSections}>
<ScrollShadow
ref={restore}
viewportClassName="min-h-0 flex-1 [--scroll-shadow-color:color-mix(in_srgb,var(--surface-muted)_var(--oc-glass-opacity),transparent)]"
onScroll={handleScroll}
size={24}
className="oc-hide-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-2"
@@ -447,7 +447,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
</header>
) : null}
<ScrollShadow viewportClassName="flex-1 min-h-0" className="flex-1 min-h-0 overflow-auto" size={64} hideTopShadow>
<ScrollShadow className="flex-1 min-h-0 overflow-auto" size={64} hideTopShadow>
<div className="mx-auto w-full max-w-2xl px-4 sm:px-6 py-5">
<div className="flex flex-col gap-5">
@@ -302,6 +302,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const sessionGoalDefaultBudget = useUIStore(state => state.sessionGoalDefaultBudget);
const setSessionGoalDefaultBudget = useUIStore(state => state.setSessionGoalDefaultBudget);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const streamingAutoFollowEnabled = useUIStore(state => state.streamingAutoFollowEnabled);
const setStreamingAutoFollowEnabled = useUIStore(state => state.setStreamingAutoFollowEnabled);
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
@@ -1839,6 +1841,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
</SettingsSection>
)}
<SettingsSection
title={t('settings.openchamber.visual.section.streaming')}
settingsItem="chat.streaming"
contentClassName={SETTINGS_OPTION_STACK_CLASS}
>
<SettingsCheckboxRow
checked={streamingAutoFollowEnabled}
onChange={setStreamingAutoFollowEnabled}
label={t('settings.openchamber.visual.field.streamingAutoFollow')}
ariaLabel={t('settings.openchamber.visual.field.streamingAutoFollowAria')}
info={t('settings.openchamber.visual.field.streamingAutoFollowInfo')}
settingsItem="chat.streaming-auto-follow"
/>
</SettingsSection>
{(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('codeBlockLineWrap')) && (
<SettingsSection
@@ -1782,7 +1782,6 @@ export function ScheduledTaskEditorDialog(props: {
</header>
<ScrollShadow
viewportClassName="flex-1 min-h-0"
className="flex-1 min-h-0 overflow-auto [scrollbar-gutter:stable_both-edges]"
size={64}
hideTopShadow
@@ -349,7 +349,8 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
const verticalThumbRef = React.useRef<HTMLDivElement>(null);
const horizontalThumbRef = React.useRef<HTMLDivElement>(null);
const bindingRef = React.useRef<ReturnType<typeof bindScrollbar> | null>(null);
const initialOptionsRef = React.useRef<ScrollbarOptions>({
const boundContainerRef = React.useRef<HTMLElement | null>(null);
const optionsRef = React.useRef<ScrollbarOptions>({
minThumbSize,
hideDelayMs,
disableHorizontal,
@@ -357,21 +358,42 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
suppressVisibility,
userIntentOnly,
});
optionsRef.current = {
minThumbSize,
hideDelayMs,
disableHorizontal,
observeMutations,
suppressVisibility,
userIntentOnly,
};
// Follow the LIVE container node, not the ref object: the chat timeline's
// scroll element is owned by the virtualized list and remounts on every
// session switch (key={sessionKey}), so a bind-once contract would leave
// the scrollbar attached to a dead element after the first switch. This
// effect runs on every commit and rebinds only when the node identity
// actually changed — steady renders are a single pointer comparison.
React.useLayoutEffect(() => {
const container = containerRef.current;
if (container === boundContainerRef.current) return;
bindingRef.current?.disconnect();
bindingRef.current = null;
boundContainerRef.current = container;
const root = rootRef.current;
const verticalThumb = verticalThumbRef.current;
const horizontalThumb = horizontalThumbRef.current;
if (!container || !root || !verticalThumb || !horizontalThumb) return;
const binding = bindScrollbar(container, root, verticalThumb, horizontalThumb, initialOptionsRef.current);
bindingRef.current = binding;
return () => {
bindingRef.current = null;
binding.disconnect();
};
}, [containerRef]);
bindingRef.current = bindScrollbar(container, root, verticalThumb, horizontalThumb, optionsRef.current);
});
React.useLayoutEffect(() => () => {
bindingRef.current?.disconnect();
bindingRef.current = null;
boundContainerRef.current = null;
}, []);
React.useLayoutEffect(() => {
bindingRef.current?.update({
@@ -1,87 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { ScrollShadow } from './ScrollShadow';
describe('ScrollShadow', () => {
let windowInstance: Window;
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
windowInstance = new Window();
Object.assign(globalThis, {
window: windowInstance,
document: windowInstance.document,
HTMLElement: windowInstance.HTMLElement,
Element: windowInstance.Element,
Node: windowInstance.Node,
IS_REACT_ACT_ENVIRONMENT: true,
});
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
windowInstance.close();
});
test('keeps edge effects outside the forwarded scroll element', async () => {
let scrollElement: HTMLElement | null = null;
let scrollTop = 0;
await act(async () => {
root.render(
<ScrollShadow
ref={(element) => { scrollElement = element; }}
viewportClassName="absolute inset-0"
observeMutations={false}
>
<div>content</div>
</ScrollShadow>,
);
});
const scroller = host.querySelector<HTMLElement>('[data-scroll-shadow-scroller]');
if (!scroller) throw new Error('ScrollShadow did not render its scroll element');
expect(scrollElement).toBe(scroller);
Object.defineProperties(scroller, {
clientHeight: { configurable: true, get: () => 100 },
scrollHeight: { configurable: true, get: () => 500 },
scrollTop: {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value; },
},
});
const viewport = scroller.parentElement;
expect(viewport?.hasAttribute('data-scroll-shadow-viewport')).toBe(true);
expect(viewport?.classList.contains('absolute')).toBe(true);
expect(viewport?.classList.contains('relative')).toBe(false);
expect(scroller.hasAttribute('data-scroll-shadow-scroller')).toBe(true);
expect(viewport?.style.getPropertyValue('--scroll-shadow-size')).toBe('48px');
await act(async () => scroller.dispatchEvent(new window.Event('scroll')));
expect(viewport?.getAttribute('data-bottom-scroll')).toBe('true');
scrollTop = 200;
await act(async () => scroller.dispatchEvent(new window.Event('scroll')));
expect(viewport?.getAttribute('data-top-bottom-scroll')).toBe('true');
expect(viewport?.hasAttribute('data-bottom-scroll')).toBe(false);
let attributeWrites = 0;
if (!viewport) throw new Error('ScrollShadow did not render its viewport');
const setAttribute = viewport.setAttribute.bind(viewport);
viewport.setAttribute = (name, value) => {
attributeWrites += 1;
setAttribute(name, value);
};
await act(async () => scroller.dispatchEvent(new window.Event('scroll')));
expect(attributeWrites).toBe(0);
});
});
+42 -138
View File
@@ -1,36 +1,35 @@
import React from "react";
import { cn } from "@/lib/utils";
import { useScrollShadow, type ScrollShadowOrientation, type ScrollShadowVisibility } from "./useScrollShadow";
export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
as?: React.ElementType;
viewportClassName?: string;
orientation?: "vertical" | "horizontal";
orientation?: ScrollShadowOrientation;
offset?: number;
size?: number;
isEnabled?: boolean;
hideTopShadow?: boolean;
hideBottomShadow?: boolean;
observeMutations?: boolean;
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
onVisibilityChange?: (state: ScrollShadowVisibility) => void;
};
type EdgeState = "both" | "none" | "top" | "bottom" | "left" | "right";
type ScrollShadowViewportStyle = React.CSSProperties & { "--scroll-shadow-size": string };
const EDGE_ATTRIBUTES = [
"data-top-scroll",
"data-bottom-scroll",
"data-top-bottom-scroll",
"data-left-scroll",
"data-right-scroll",
"data-left-right-scroll",
] as const;
function mergeRefs<T>(...refs: Array<React.Ref<T>>): React.RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<T | null>).current = value;
}
});
};
}
export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
(
(
{
as: Component = "div",
viewportClassName,
orientation = "vertical",
offset = 0,
size = 48,
@@ -43,138 +42,43 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
className,
children,
...rest
},
},
ref,
) => {
const internalRef = React.useRef<HTMLElement>(null);
const viewportRef = React.useRef<HTMLDivElement>(null);
const visibleRef = React.useRef<EdgeState>("none");
const edgeStateRef = React.useRef("");
React.useImperativeHandle(ref, () => {
const element = internalRef.current;
if (!element) throw new Error("ScrollShadow scroll element is unavailable");
return element;
}, []);
const viewportStyle = React.useMemo<ScrollShadowViewportStyle>(
() => ({ "--scroll-shadow-size": `${size}px` }),
[size],
);
const dataScrollShadow = (rest as Record<string, unknown>)["data-scroll-shadow"];
delete (rest as Record<string, unknown>)["data-scroll-shadow"];
const clearAttributes = React.useCallback((el: HTMLElement) => {
EDGE_ATTRIBUTES.forEach((attribute) => el.removeAttribute(attribute));
}, []);
const setEdgeAttribute = React.useCallback((el: HTMLElement, state: EdgeState) => {
clearAttributes(el);
if (state === "none") return;
const attribute = state === "both"
? orientation === "vertical" ? "data-top-bottom-scroll" : "data-left-right-scroll"
: `data-${state}-scroll`;
el.setAttribute(attribute, "true");
}, [clearAttributes, orientation]);
const checkOverflow = React.useCallback(() => {
const el = internalRef.current;
const viewport = viewportRef.current;
if (!el || !viewport) return;
if (!isEnabled) {
clearAttributes(viewport);
edgeStateRef.current = "";
visibleRef.current = "none";
return;
}
// Subpixel tolerance: on hi-DPI (Retina) and with fractional scrollTop,
// scrollTop+clientHeight can fall ~0.5px short of scrollHeight at the very end,
// which would otherwise keep the bottom fade visible after fully scrolling.
const SUBPIXEL_TOLERANCE = 1;
const hasBefore =
orientation === "vertical"
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
const hasAfter =
orientation === "vertical"
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
const effectiveHasBefore = hasBefore && !(orientation === "vertical" && hideTopShadow);
const effectiveHasAfter = hasAfter && !(orientation === "vertical" && hideBottomShadow);
const beforeEdge = orientation === "vertical" ? "top" : "left";
const afterEdge = orientation === "vertical" ? "bottom" : "right";
let next: EdgeState = "none";
if (effectiveHasBefore && effectiveHasAfter) next = "both";
else if (effectiveHasBefore) next = beforeEdge;
else if (effectiveHasAfter) next = afterEdge;
const edgeState = `${orientation}:${next}`;
if (edgeState !== edgeStateRef.current) {
edgeStateRef.current = edgeState;
setEdgeAttribute(viewport, next);
}
if (next !== visibleRef.current) {
visibleRef.current = next;
onVisibilityChange?.(next);
}
}, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setEdgeAttribute]);
React.useEffect(() => {
const el = internalRef.current;
if (!el) return;
// Throttle with RAF to avoid excessive calls during rapid DOM changes
let rafId: number | null = null;
const throttledCheck = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
checkOverflow();
});
const mergedStyle = React.useMemo<React.CSSProperties>(() => {
const next: React.CSSProperties = {
...(style as React.CSSProperties),
};
(next as Record<string, string>)["--scroll-shadow-size"] = `${size}px`;
return next;
}, [size, style]);
const handleScroll = () => checkOverflow(); // Scroll should be immediate
const resizeObserver = "ResizeObserver" in globalThis ? new ResizeObserver(throttledCheck) : null;
const mutationObserver =
observeMutations && "MutationObserver" in globalThis ? new MutationObserver(throttledCheck) : null;
checkOverflow();
el.addEventListener("scroll", handleScroll, { passive: true });
resizeObserver?.observe(el);
// checkOverflow mutates our data-scroll attributes; observing attributes
// would make the component trigger its own observer indefinitely.
mutationObserver?.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
el.removeEventListener("scroll", handleScroll);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, [checkOverflow, observeMutations]);
useScrollShadow(internalRef, {
orientation,
offset,
isEnabled,
hideTopShadow,
hideBottomShadow,
observeMutations,
onVisibilityChange,
});
return (
<div
ref={viewportRef}
className={cn("relative flex min-h-0 min-w-0 flex-col", viewportClassName)}
data-scroll-shadow-viewport
<Component
{...rest}
ref={mergeRefs(internalRef, ref)}
className={className}
data-orientation={orientation}
style={viewportStyle}
data-scroll-shadow={dataScrollShadow ?? true}
style={mergedStyle}
>
<Component
{...rest}
ref={internalRef}
className={className}
data-scroll-shadow-scroller
style={style}
>
{children}
</Component>
</div>
{children}
</Component>
);
},
);
@@ -47,16 +47,6 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
...rest
}, ref) => {
const containerRef = React.useRef<HTMLElement | null>(null);
const containerSizeClassName = fillContainer
? "flex-1 min-h-0 w-full"
: "flex-none w-full h-auto";
const containerClassName = cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
containerSizeClassName,
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className,
);
React.useImperativeHandle(ref, () => containerRef.current as HTMLElement, []);
@@ -72,11 +62,16 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
<ScrollShadow
as={Component}
ref={containerRef as React.Ref<HTMLElement>}
viewportClassName={containerSizeClassName}
size={scrollShadowSize}
hideTopShadow={hideTopScrollShadow}
hideBottomShadow={hideBottomScrollShadow}
className={containerClassName}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
)}
style={style as React.CSSProperties}
observeMutations={observeMutations}
{...rest}
@@ -86,7 +81,13 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
) : (
<Component
ref={containerRef as React.Ref<HTMLElement>}
className={containerClassName}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
)}
style={style}
{...rest}
>
+5 -1
View File
@@ -43,7 +43,11 @@ const variants = [
{textContent.split("").map((char, index) => (
<motion.span
{...props}
key={char + String(index)}
// Index-only: keying by character remounted every span when
// the text mutated (a tool title resolving on completion) and
// replayed the whole fade. Same-index spans update in place;
// appended characters still mount with the reveal.
key={index}
className={cn(
"inline-block whitespace-pre align-baseline"
)}
@@ -0,0 +1,159 @@
import React from "react";
// Scroll-shadow state as data attributes on a scroll container.
//
// The logic lives in a hook rather than only inside <ScrollShadow> because the
// chat timeline's scroll container is owned by the virtualized list component,
// which renders its own element — there is no wrapper to hand the styling to.
// <ScrollShadow> is a thin wrapper over this hook, so both paths stay in sync.
export type ScrollShadowOrientation = "vertical" | "horizontal";
export type ScrollShadowVisibility = "both" | "none" | "top" | "bottom" | "left" | "right";
export type UseScrollShadowOptions = {
orientation?: ScrollShadowOrientation;
offset?: number;
isEnabled?: boolean;
hideTopShadow?: boolean;
hideBottomShadow?: boolean;
observeMutations?: boolean;
onVisibilityChange?: (state: ScrollShadowVisibility) => void;
};
const SCROLL_SHADOW_ATTRIBUTES = [
"top",
"bottom",
"top-bottom",
"left",
"right",
"left-right",
] as const;
const clearScrollShadowAttributes = (el: HTMLElement): void => {
SCROLL_SHADOW_ATTRIBUTES.forEach((attr) => {
el.removeAttribute(`data-${attr}-scroll`);
});
};
const setScrollShadowAttributes = (
el: HTMLElement,
hasBefore: boolean,
hasAfter: boolean,
prefix: "top" | "left",
suffix: "bottom" | "right",
): void => {
const bothKey = `${prefix}${suffix.charAt(0).toUpperCase()}${suffix.slice(1)}Scroll` as const;
if (hasBefore && hasAfter) {
(el.dataset as Record<string, string>)[bothKey] = "true";
el.removeAttribute(`data-${prefix}-scroll`);
el.removeAttribute(`data-${suffix}-scroll`);
} else {
el.dataset[`${prefix}Scroll`] = String(hasBefore);
el.dataset[`${suffix}Scroll`] = String(hasAfter);
el.removeAttribute(`data-${prefix}-${suffix}-scroll`);
}
};
export const useScrollShadow = (
elementRef: React.RefObject<HTMLElement | null>,
{
orientation = "vertical",
offset = 0,
isEnabled = true,
hideTopShadow = false,
hideBottomShadow = false,
observeMutations = true,
onVisibilityChange,
}: UseScrollShadowOptions = {},
): void => {
const visibleRef = React.useRef<ScrollShadowVisibility>("none");
const checkOverflow = React.useCallback(() => {
const el = elementRef.current;
if (!el) return;
if (!isEnabled) {
clearScrollShadowAttributes(el);
return;
}
// Subpixel tolerance: on hi-DPI (Retina) and with fractional scrollTop,
// scrollTop+clientHeight can fall ~0.5px short of scrollHeight at the very end,
// which would otherwise keep the bottom fade visible after fully scrolling.
const SUBPIXEL_TOLERANCE = 1;
const hasBefore =
orientation === "vertical"
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
let hasAfter =
orientation === "vertical"
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
const effectiveHasBefore = hideTopShadow && orientation === "vertical" ? false : hasBefore;
if (hideBottomShadow && orientation === "vertical") {
hasAfter = false;
}
setScrollShadowAttributes(
el,
effectiveHasBefore,
hasAfter,
orientation === "vertical" ? "top" : "left",
orientation === "vertical" ? "bottom" : "right",
);
const next: ScrollShadowVisibility = effectiveHasBefore && hasAfter
? "both"
: effectiveHasBefore
? (orientation === "vertical" ? "top" : "left")
: hasAfter
? (orientation === "vertical" ? "bottom" : "right")
: "none";
if (next !== visibleRef.current) {
visibleRef.current = next;
onVisibilityChange?.(next);
}
}, [elementRef, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation]);
React.useEffect(() => {
const el = elementRef.current;
if (!el) return;
// Throttle with RAF to avoid excessive calls during rapid DOM changes
let rafId: number | null = null;
const throttledCheck = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
checkOverflow();
});
};
const handleScroll = () => checkOverflow(); // Scroll should be immediate
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
const mutationObserver =
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
checkOverflow();
el.addEventListener("scroll", handleScroll, { passive: true });
resizeObserver?.observe(el);
// checkOverflow mutates our data-scroll attributes; observing attributes
// would make the hook trigger its own observer indefinitely.
mutationObserver?.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
el.removeEventListener("scroll", handleScroll);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, [checkOverflow, elementRef, observeMutations]);
};
@@ -488,7 +488,6 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
<ScrollShadow
ref={scrollRef}
viewportClassName="min-h-0 w-full flex-1"
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
>
{shouldVirtualize ? (
-938
View File
@@ -1,938 +0,0 @@
import React from 'react';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore } from '@/sync/viewport-store';
type AutoFollowState = 'following' | 'released';
export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation';
export interface AnimationHandlers {
onChunk: () => void;
onComplete: () => void;
onStreamingCandidate?: () => void;
onAnimationStart?: () => void;
onReservationCancelled?: () => void;
onReasoningBlock?: () => void;
onAnimatedHeightChange?: (height: number) => void;
}
interface UseChatAutoFollowOptions {
currentSessionId: string | null;
currentSessionKey: string | null;
sessionMessageCount: number;
sessionIsWorking: boolean;
isMobile: boolean;
onActiveTurnChange?: (turnId: string | null) => void;
}
export interface UseChatAutoFollowResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
state: AutoFollowState;
isPinned: boolean;
isOverflowing: boolean;
isFollowingProgrammatically: boolean;
showScrollButton: boolean;
notifyContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
goToBottom: (mode?: 'instant' | 'smooth') => void;
scrollToBottomOnSend: () => void;
releaseAutoFollow: () => void;
saveSnapshotNow: () => void;
restoreSnapshot: () => Promise<boolean>;
}
// ──────────────────────────────────────────────────────────────────────────
// Chat auto-follow. The model is deliberately simple, which is what makes it
// flicker-free:
//
// • Auto-follow is on unless the user scrolled up (`released`), AND passive
// following only acts while the session is active (working, plus a short
// settle window). When idle, content-size changes are layout churn
// (virtualizer re-measurement, async tool/code rendering) rather than live
// growth, so the hook leaves scroll alone — re-pinning then would fight the
// virtualizer and twitch the viewport.
// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the
// content ResizeObserver, which fires after layout and before paint. There
// is NO easing loop and NO settle burst, so there are never two writers
// racing for `scrollTop` (the root cause of the old jiggle/double-scroll).
// • A short-lived "auto" marker (position + 1500ms) lets the scroll handler
// distinguish our own programmatic writes from genuine user scrolling, so
// a scroll event that lands at our just-written bottom never trips a false
// release.
//
// The public interface below is unchanged from the old implementation so every
// consumer (ChatContainer, message parts, the timeline controller) keeps
// working without edits.
// ──────────────────────────────────────────────────────────────────────────
const BOTTOM_SPACER_DESKTOP_VH = 0.10;
const BOTTOM_SPACER_MOBILE_PX = 40;
const SAVE_DEBOUNCE_MS = 150;
const TOUCH_FINGER_DOWN_THRESHOLD = 2;
// How long an "auto" (programmatic) scroll position stays trusted. Browsers can
// dispatch the `scroll` event for our write asynchronously, after newer content
// has already changed the geometry; the window keeps us from reading that lag as
// a user scroll.
const AUTO_MARK_TTL_MS = 1500;
const AUTO_MATCH_TOLERANCE_PX = 2;
// While a tracked height animation runs (e.g. a Thinking block auto-collapsing
// mid-stream), the timeline shrinks/grows over a couple hundred ms and the
// virtualizer re-measures, producing transient geometry. Browsers dispatch the
// resulting `scroll` events asynchronously, so a stale event can land after we
// have already re-pinned — its position matching neither the bottom zone nor the
// freshly-moved auto marker — and be misread as a user scroll-away. During this
// guard window we treat any `following`-state scroll event as our own and never
// release via the heuristic. GENUINE user gestures still release instantly
// through releaseFromUserIntent, so this is not glue. Sized to the reasoning
// animation (200ms) plus headroom for trailing async scroll events.
const ANIMATION_GUARD_MS = 350;
// After streaming stops, keep following the bottom for a short window so the
// final content can settle into place.
const SETTLE_MS = 300;
// Entry-stick window. On the FIRST open of a session, late async data (most
// visibly a task/subagent tool whose nested rows are fetched from the child
// session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the
// timeline a beat or two AFTER we have already pinned to the bottom, leaving the
// viewport stranded mid-history. The steady-state idle gate deliberately ignores
// that growth (it can't tell entry from a user reading idle history). So instead
// of weakening the gate, we open a short, gesture-cancellable window on entry
// during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after
// growth stops (capped by MAX_MS), or instantly on any real user scroll gesture.
const ENTRY_STICK_QUIESCENCE_MS = 600;
const ENTRY_STICK_MAX_MS = 8000;
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
// — its height is exactly how far above scrollHeight the user can be while still
// looking at "empty" space. We use that same value as the threshold for both
// re-pinning auto-follow and showing the scroll-to-bottom button.
const computeBottomZoneThreshold = (isMobile: boolean, container?: HTMLElement | null): number => {
if (isMobile) return BOTTOM_SPACER_MOBILE_PX;
const height = container?.clientHeight ?? 0;
if (height <= 0) return 96;
return Math.max(48, height * BOTTOM_SPACER_DESKTOP_VH);
};
const distanceFromBottom = (el: HTMLElement): number => {
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
const canScroll = (el: HTMLElement): boolean => {
return el.scrollHeight - el.clientHeight > 1;
};
const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el);
};
const isReleaseKey = (event: KeyboardEvent): boolean => {
if (event.altKey || event.ctrlKey || event.metaKey) {
return false;
}
switch (event.key) {
case 'ArrowUp':
case 'PageUp':
case 'Home':
return true;
default:
return false;
}
};
const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
if (!(target instanceof Element)) return null;
const nested = target.closest('[data-scrollable]');
if (!nested || nested === root || !(nested instanceof HTMLElement)) return null;
return nested;
};
const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => {
const nested = nestedScrollableTarget(root, target);
if (!nested) return false;
return nested.scrollTop > 0;
};
export const useChatAutoFollow = ({
currentSessionId,
currentSessionKey,
sessionMessageCount,
sessionIsWorking,
isMobile,
onActiveTurnChange,
}: UseChatAutoFollowOptions): UseChatAutoFollowResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [containerEl, setContainerEl] = React.useState<HTMLDivElement | null>(null);
const lastSeenContainerRef = React.useRef<HTMLDivElement | null>(null);
const [state, setState] = React.useState<AutoFollowState>('following');
const [isOverflowing, setIsOverflowing] = React.useState(false);
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
// `stateRef` is the single source of truth for follow vs released; the React
// state above is a mirror for rendering. `released` means the user scrolled
// up and away from the bottom.
const stateRef = React.useRef<AutoFollowState>('following');
const isMobileRef = React.useRef(isMobile);
isMobileRef.current = isMobile;
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
sessionIsWorkingRef.current = sessionIsWorking;
// `settling` keeps passive follow alive for a short window after work stops
// so the final content can land at the bottom.
const settlingRef = React.useRef(false);
const settleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const sessionMessageCountRef = React.useRef(sessionMessageCount);
sessionMessageCountRef.current = sessionMessageCount;
const currentSessionIdRef = React.useRef(currentSessionId);
currentSessionIdRef.current = currentSessionId;
const currentSessionKeyRef = React.useRef(currentSessionKey);
currentSessionKeyRef.current = currentSessionKey;
const lastSessionKeyRef = React.useRef<string | null>(null);
// Programmatic-scroll marker: the bottom position we last
// wrote and when. A scroll event whose scrollTop matches `top` within a few
// px while still inside the TTL is OUR write, not the user's.
const autoRef = React.useRef<{ top: number; time: number } | null>(null);
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Timestamp until which a tracked height animation is in flight (see
// ANIMATION_GUARD_MS). 0 = no animation guard active.
const animationGuardUntilRef = React.useRef(0);
// True while the native (Capacitor iOS) keyboard slide choreography is in
// flight (between 'oc:keyboard-anim' and 'oc:keyboard-settled' from
// useNativeMobileChrome). During that window the pinned content is moved by a
// transform on the inner wrapper, so the ResizeObserver chase must stand down.
const keyboardAnimRef = React.useRef(false);
// Last observed scrollTop, used to derive scroll DIRECTION in the scroll
// handler so the bottom-zone re-engage only fires when arriving at the bottom
// by scrolling down — never when a user scrolling UP merely lands in the zone.
const lastScrollTopRef = React.useRef(0);
// Entry-stick window state (see ENTRY_STICK_* above).
const entryStickRef = React.useRef(false);
const entryStickQuietTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const entryStickCapTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const entryStickLastHeightRef = React.useRef(0);
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
// When restoreSnapshot is invoked while ChatViewport is still hydrating
// (skeleton rendered, no scroll container yet), we record the session here
// so a follow-up effect can replay the restore once the container mounts.
const pendingInitialRestoreRef = React.useRef<string | null>(null);
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
// Detect when the scroll container DOM element changes (mount, unmount, remount).
// Without this, listener-attach effects would only ever bind to the element that
// existed at the hook's first render, missing later mounts (e.g. after first send
// promotes a draft session to a real chat with messages).
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
if (scrollRef.current !== lastSeenContainerRef.current) {
lastSeenContainerRef.current = scrollRef.current;
setContainerEl(scrollRef.current);
}
});
// `active` is `working || settling`. Passive auto-follow
// (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs
// while active. When the session is idle, content-size changes are layout
// churn — virtualizer re-measurement, async tool/code rendering — NOT live
// growth, so we must NOT yank the user to the bottom. Forcing this gate is
// what stops the twitch when tall items (expanded tools) re-measure as the
// user scrolls.
const isActive = React.useCallback((): boolean => {
return sessionIsWorkingRef.current || settlingRef.current;
}, []);
const setStateValue = React.useCallback((next: AutoFollowState) => {
if (stateRef.current === next) return;
stateRef.current = next;
setState(next);
}, []);
// ── auto marker ────────────────────────────────────────────────────────
const markAuto = React.useCallback((el: HTMLElement) => {
autoRef.current = {
top: Math.max(0, el.scrollHeight - el.clientHeight),
time: now(),
};
if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
autoTimerRef.current = setTimeout(() => {
autoRef.current = null;
autoTimerRef.current = null;
}, AUTO_MARK_TTL_MS);
}, []);
const isAuto = React.useCallback((el: HTMLElement): boolean => {
const a = autoRef.current;
if (!a) return false;
if (now() - a.time > AUTO_MARK_TTL_MS) {
autoRef.current = null;
return false;
}
return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
}, []);
const isAnimationGuardActive = React.useCallback((): boolean => {
return now() < animationGuardUntilRef.current;
}, []);
// ── entry-stick window ───────────────────────────────────────────────────
const endEntryStick = React.useCallback(() => {
entryStickRef.current = false;
if (entryStickQuietTimerRef.current) {
clearTimeout(entryStickQuietTimerRef.current);
entryStickQuietTimerRef.current = null;
}
if (entryStickCapTimerRef.current) {
clearTimeout(entryStickCapTimerRef.current);
entryStickCapTimerRef.current = null;
}
}, []);
// (Re)arm the quiescence timer: the window closes this long after the last
// growth. Called once on begin and again on every growth-driven re-pin.
const armEntryStickQuiet = React.useCallback(() => {
if (entryStickQuietTimerRef.current) {
clearTimeout(entryStickQuietTimerRef.current);
}
entryStickQuietTimerRef.current = setTimeout(() => {
entryStickQuietTimerRef.current = null;
endEntryStick();
}, ENTRY_STICK_QUIESCENCE_MS);
}, [endEntryStick]);
const beginEntryStick = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
entryStickRef.current = true;
entryStickLastHeightRef.current = el.scrollHeight;
armEntryStickQuiet();
// Reset the absolute cap fresh on every entry (e.g. session switch) so a
// stale cap from a previous open can't cut this window short.
if (entryStickCapTimerRef.current) {
clearTimeout(entryStickCapTimerRef.current);
}
entryStickCapTimerRef.current = setTimeout(() => {
entryStickCapTimerRef.current = null;
endEntryStick();
}, ENTRY_STICK_MAX_MS);
}, [armEntryStickQuiet, endEntryStick]);
// ── overflow / scroll-to-bottom button ──────────────────────────────────
const updateOverflowAndButton = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
setIsOverflowing(false);
setShowScrollButton(false);
return;
}
const overflowing = canScroll(container);
setIsOverflowing(overflowing);
if (!overflowing) {
setShowScrollButton(false);
return;
}
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current);
setShowScrollButton(showButton);
}, []);
// ── core scroll primitives ───────────────────────────────────────────────
const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => {
const el = scrollRef.current;
if (!el) return;
markAuto(el);
// `scrollHeight` is rounded to an integer while the real content height
// is fractional (prose line-heights), so `scrollTop = scrollHeight`
// leaves a 01px remainder that oscillates per streamed token and makes
// bottom-anchored rows jitter vertically. An over-large target clamps to
// the exact fractional maximum instead, pinning content to the bottom.
const overshootTarget = el.scrollHeight + 4096;
if (behavior === 'smooth') {
el.scrollTo({ top: overshootTarget, behavior });
return;
}
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
// and lands in the same frame — no visible catch-up animation.
el.scrollTop = overshootTarget;
}, [markAuto]);
// `force` true = user-intent jump (clears released and always scrolls).
// `force` false = passive follow (only while still following).
const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => {
const el = scrollRef.current;
// Passive follow only while active (working/settling). Forced jumps
// (send, go-to-bottom, session restore) always proceed.
if (!force && !isActive()) return;
if (force && stateRef.current !== 'following') {
setStateValue('following');
}
if (!el) return;
if (!force && stateRef.current !== 'following') return;
// Always re-pin, even when already within tolerance of the bottom.
// Sub-tolerance growth (fractional line-height remainders) would
// otherwise leave the bottom drifting by up to ±AUTO_MATCH_TOLERANCE_PX
// between full re-pins, which reads as 1px vertical jitter on
// bottom-anchored rows during streaming. The write happens pre-paint
// (ResizeObserver) and is a no-op when the position is unchanged.
scrollToBottomNow(force ? behavior : 'auto');
}, [isActive, scrollToBottomNow, setStateValue]);
// User left the bottom — release auto-follow.
const stop = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
if (!canScroll(el)) {
setStateValue('following');
return;
}
if (stateRef.current === 'released') return;
setStateValue('released');
updateOverflowAndButton();
}, [setStateValue, updateOverflowAndButton]);
// ── public scroll API (mapped onto the primitives) ───────────────────────
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto');
}, [scrollToBottom]);
const scrollToBottomOnSend = React.useCallback(() => {
// Single movement to the just-sent message. Force re-pins to the bottom
// whether we were following or scrolled up; the content ResizeObserver
// keeps us pinned as the optimistic message and its reply stream in.
scrollToBottom(true);
}, [scrollToBottom]);
const releaseAutoFollow = React.useCallback(() => {
setStateValue('released');
updateOverflowAndButton();
}, [setStateValue, updateOverflowAndButton]);
const releaseFromUserIntent = React.useCallback(() => {
// A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry
// window immediately so we never fight the user's read position.
endEntryStick();
stop();
}, [endEntryStick, stop]);
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
const flushSave = React.useCallback(() => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const pending = pendingSaveRef.current;
if (!pending) return;
const container = scrollRef.current;
if (!container) {
pendingSaveRef.current = null;
return;
}
updateViewportAnchor(pending.sessionId, pending.anchor, {
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
pendingSaveRef.current = null;
}, [updateViewportAnchor]);
const queueSave = React.useCallback(() => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) return;
const container = scrollRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const anchorRatio = scrollHeight > 0
? (scrollTop + clientHeight / 2) / scrollHeight
: 0;
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
pendingSaveRef.current = { sessionId, anchor };
if (saveTimerRef.current !== null) return;
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
flushSave();
}, SAVE_DEBOUNCE_MS);
}, [flushSave]);
const saveSnapshotNow = React.useCallback(() => {
flushSave();
}, [flushSave]);
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
const sessionKey = currentSessionKeyRef.current;
if (!sessionKey) return false;
const container = scrollRef.current;
if (!container) {
// ChatViewport not mounted yet (e.g., session still hydrating).
// Record the request so the container-attach effect can replay it.
pendingInitialRestoreRef.current = sessionKey;
setStateValue('following');
return false;
}
pendingInitialRestoreRef.current = null;
// Always return to the bottom on session switch. The content
// ResizeObserver re-pins instantly as late
// history measures in, so there is no smooth scroll-from-mid artifact.
setStateValue('following');
scrollToBottom(true);
// Hold the bottom across late async growth (e.g. task/subagent child
// session data landing a beat after entry) until content quiesces or the
// user scrolls.
beginEntryStick();
updateOverflowAndButton();
return false;
}, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]);
// ── session change ───────────────────────────────────────────────────────
React.useEffect(() => {
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
return;
}
lastSessionKeyRef.current = currentSessionKey;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
flushSave();
autoRef.current = null;
// Drop any pending restore request inherited from a different session.
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionKey) {
pendingInitialRestoreRef.current = null;
}
}, [currentSessionId, currentSessionKey, flushSave]);
// When work begins and we are still
// following, pin to the bottom. When work stops, keep following alive for a
// short settle window so the final content lands at the bottom, then go
// idle (after which passive follow is disabled — see `isActive`).
React.useEffect(() => {
settlingRef.current = false;
if (settleTimerRef.current) {
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}
if (sessionIsWorking) {
if (stateRef.current === 'following') {
scrollToBottom(true);
}
return;
}
settlingRef.current = true;
settleTimerRef.current = setTimeout(() => {
settlingRef.current = false;
settleTimerRef.current = null;
}, SETTLE_MS);
}, [sessionIsWorking, scrollToBottom]);
// Suppress the overlay scrollbar thumb only while we are actively following a
// live stream (the thumb would otherwise jump on every instant re-pin). When
// idle or released the scrollbar behaves normally. Stable: changes only when
// follow-state or working-state flips, not on every frame.
React.useEffect(() => {
setIsFollowingProgrammatically(state === 'following' && sessionIsWorking);
}, [state, sessionIsWorking]);
// Replay a deferred restoreSnapshot once ChatViewport mounts.
// useLayoutEffect ensures scroll position is set before the browser paints,
// preventing a visible flash of content at the wrong scroll position.
React.useLayoutEffect(() => {
if (!containerEl) return;
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionKey) {
void restoreSnapshot();
}
}, [containerEl, currentSessionKey, restoreSnapshot]);
// ── scroll event handling ────────────────────────────────────────────────
const handleScrollEvent = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const previousTop = lastScrollTopRef.current;
lastScrollTopRef.current = el.scrollTop;
const scrollingDown = el.scrollTop > previousTop + 0.5;
updateOverflowAndButton();
if (!canScroll(el)) {
setStateValue('following');
return;
}
// Within the bottom zone → (re-)pin to following. This is how scrolling
// back DOWN to the bottom resumes auto-follow. Crucially, re-engage only
// when the user arrives by scrolling down (or is already following, or is
// essentially at the true bottom). A user scrolling UP that merely lands
// in the bottom spacer zone must NOT be yanked back into follow — that is
// the dead-zone fight that made small upward scrolls impossible while
// content streams.
if (isNearBottom(el, isMobileRef.current)) {
const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX;
if (scrollingDown || stateRef.current === 'following' || atTrueBottom) {
setStateValue('following');
}
queueSave();
return;
}
// Our own geometry change (a programmatic write that landed at the bottom
// but where content grew between the write and this event, OR a tracked
// height animation in flight) — keep following, don't release.
if (stateRef.current === 'following' && (isAuto(el) || isAnimationGuardActive())) {
scrollToBottom(false);
queueSave();
return;
}
// Genuine user scroll away from the bottom.
stop();
queueSave();
}, [isAnimationGuardActive, isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
React.useEffect(() => {
const container = containerEl;
if (!container) return;
lastScrollTopRef.current = container.scrollTop;
const handleWheel = (event: WheelEvent) => {
if (event.deltaY >= 0) return;
if (nestedScrollableCanConsumeUp(container, event.target)) return;
releaseFromUserIntent();
};
let touchLastY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches.item(0);
touchLastY = touch ? touch.clientY : null;
};
const handleTouchMove = (event: TouchEvent) => {
const touch = event.touches.item(0);
if (!touch) {
touchLastY = null;
return;
}
const previousY = touchLastY;
touchLastY = touch.clientY;
if (previousY === null) return;
const fingerDelta = touch.clientY - previousY;
if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return;
if (nestedScrollableCanConsumeUp(container, event.target)) return;
releaseFromUserIntent();
};
const handleTouchEnd = () => {
touchLastY = null;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!isReleaseKey(event)) return;
releaseFromUserIntent();
};
const handlePointerDownIntent = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (!target.closest('[data-overlay-scrollbar-thumb]')) return;
releaseFromUserIntent();
};
container.addEventListener('scroll', handleScrollEvent, { passive: true });
container.addEventListener('wheel', handleWheel, { passive: true });
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('touchmove', handleTouchMove, { passive: true });
container.addEventListener('touchend', handleTouchEnd, { passive: true });
container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
container.addEventListener('keydown', handleKeyDown);
if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', handlePointerDownIntent, true);
}
return () => {
container.removeEventListener('scroll', handleScrollEvent);
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
container.removeEventListener('keydown', handleKeyDown);
if (typeof window !== 'undefined') {
window.removeEventListener('pointerdown', handlePointerDownIntent, true);
}
};
}, [containerEl, handleScrollEvent, releaseFromUserIntent]);
// The heart of the follow behaviour: the content ResizeObserver fires after
// layout and before paint, so re-pinning to the bottom here is invisible —
// there is no "jump up then catch up". Observe both the container (composer
// growth shrinks the viewport) and the inner content (streaming growth).
React.useEffect(() => {
const container = containerEl;
if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
// Keyboard slide in flight: the container/composer resizes it reports
// are part of the transform choreography — the settle handler does the
// single deterministic re-pin, so chasing here would just fight it.
if (keyboardAnimRef.current) {
updateOverflowAndButton();
return;
}
const el = scrollRef.current;
if (el && !canScroll(el)) {
setStateValue('following');
updateOverflowAndButton();
return;
}
updateOverflowAndButton();
// Entry-stick window: on first session open, FORCE the bottom on
// every growth so late async data (task/subagent child rows, code
// highlight, mermaid) can't strand the viewport mid-history. Force
// overrides any false `released` from the growth itself; only a real
// user gesture clears the window (releaseFromUserIntent).
if (entryStickRef.current && el) {
const grew = el.scrollHeight > entryStickLastHeightRef.current + 1;
entryStickLastHeightRef.current = el.scrollHeight;
scrollToBottom(true);
if (grew) armEntryStickQuiet();
return;
}
// Idle resize = layout churn (virtualizer re-measurement, async
// tool/code rendering), NOT live growth. Never re-pin when idle, or
// tall items re-measuring as the user scrolls cause an endless
// scroll-to-bottom/re-measure twitch.
if (!isActive()) return;
if (stateRef.current !== 'following') return;
scrollToBottom(false);
});
observer.observe(container);
const inner = container.firstElementChild;
if (inner instanceof Element) {
observer.observe(inner);
}
return () => observer.disconnect();
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
// ── native keyboard transitions (Capacitor choreography) ────────────────
// The chat scroller gets NO transforms during the keyboard transition:
// transforming the scroll container (or its content) forces WebKit to
// rebuild the composited scrolling layers, which stalls for seconds on
// long chats. Instead the chat repositions with instant snaps that hide
// behind the keyboard itself:
// show: content stays put while the keyboard/composer slide over it; the
// settled event (shell layout snap) does ONE instant re-pin.
// hide: the shell layout is restored up-front — the scrollTop clamp
// happens while the keyboard still covers that region — and the
// settled event re-pins once at the end.
// During the window we only guard the scroll heuristics and the observer
// chase. These events never fire outside the Capacitor app.
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handleKeyboardAnim = (event: Event) => {
const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail;
if (!detail) return;
keyboardAnimRef.current = true;
// The clamp/resize during the choreography can dispatch scroll events
// that land away from the auto marker — never read those as a user
// scroll-away.
animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS;
};
const handleKeyboardSettled = () => {
keyboardAnimRef.current = false;
const el = scrollRef.current;
if (!el) {
updateOverflowAndButton();
return;
}
// Single deterministic re-pin, same task as the layout swap → lands
// before paint. (scrollToBottomNow, not scrollToBottom: this must not
// be gated on working/settling — the keyboard resize is a viewport
// change, not content growth.)
if (stateRef.current === 'following' && canScroll(el)) {
scrollToBottomNow('auto');
}
updateOverflowAndButton();
};
window.addEventListener('oc:keyboard-anim', handleKeyboardAnim);
window.addEventListener('oc:keyboard-settled', handleKeyboardSettled);
return () => {
window.removeEventListener('oc:keyboard-anim', handleKeyboardAnim);
window.removeEventListener('oc:keyboard-settled', handleKeyboardSettled);
keyboardAnimRef.current = false;
};
}, [scrollToBottomNow, updateOverflowAndButton]);
React.useEffect(() => {
updateOverflowAndButton();
}, [sessionMessageCount, updateOverflowAndButton]);
const notifyContentChange = React.useCallback((reason?: ContentChangeReason) => {
// A tracked height animation (e.g. Thinking auto-collapse) opens a guard
// window so its transient geometry / async scroll events are not misread
// as a user scroll-away. Real gestures still release through
// releaseFromUserIntent, so the user can always scroll up freely.
if (reason === 'animation') {
animationGuardUntilRef.current = now() + ANIMATION_GUARD_MS;
}
updateOverflowAndButton();
// Entry-stick window: late structural growth (notably the task/subagent
// summary landing from the child session — ToolPart emits 'structural'
// here) must keep us pinned and refresh the quiescence timer, even though
// the session is idle.
if (entryStickRef.current) {
scrollToBottom(true);
armEntryStickQuiet();
return;
}
if (stateRef.current === 'following') {
scrollToBottom(false);
}
}, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const cached = animationHandlersRef.current.get(messageId);
if (cached) return cached;
const kick = () => {
if (stateRef.current === 'following') {
scrollToBottom(false);
}
};
const handlers: AnimationHandlers = {
onChunk: kick,
onComplete: () => {
updateOverflowAndButton();
},
onStreamingCandidate: () => {},
onAnimationStart: () => {},
onAnimatedHeightChange: kick,
onReservationCancelled: () => {},
onReasoningBlock: () => {},
};
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [scrollToBottom, updateOverflowAndButton]);
React.useEffect(() => {
return () => {
if (autoTimerRef.current) {
clearTimeout(autoTimerRef.current);
autoTimerRef.current = null;
}
if (settleTimerRef.current) {
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}
endEntryStick();
flushSave();
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [endEntryStick, flushSave]);
React.useEffect(() => {
if (!onActiveTurnChange) return;
const container = containerEl;
if (!container) return;
let lastActiveTurnId: string | null = null;
const spy = createScrollSpy({
onActive: (turnId) => {
if (turnId === lastActiveTurnId) return;
lastActiveTurnId = turnId;
onActiveTurnChange(turnId);
},
});
spy.setContainer(container);
const elementByTurnId = new Map<string, HTMLElement>();
const registerTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
elementByTurnId.set(turnId, node);
spy.register(node, turnId);
return true;
};
const unregisterTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
if (elementByTurnId.get(turnId) !== node) return false;
elementByTurnId.delete(turnId);
spy.unregister(turnId);
return true;
};
const collectTurnNodes = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) return [];
const collected: HTMLElement[] = [];
if (node.matches('[data-turn-id]')) collected.push(node);
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
return collected;
};
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
spy.markDirty();
const mutationObserver = new MutationObserver((records) => {
let changed = false;
records.forEach((record) => {
record.removedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (unregisterTurnNode(turnNode)) changed = true;
});
});
record.addedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (registerTurnNode(turnNode)) changed = true;
});
});
});
if (changed) spy.markDirty();
});
mutationObserver.observe(container, { subtree: true, childList: true });
const onScroll = () => spy.onScroll();
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
mutationObserver.disconnect();
spy.destroy();
};
}, [containerEl, onActiveTurnChange]);
return {
scrollRef,
state,
isPinned: state === 'following',
isOverflowing,
isFollowingProgrammatically,
showScrollButton,
notifyContentChange,
getAnimationHandlers,
goToBottom,
scrollToBottomOnSend,
releaseAutoFollow,
saveSnapshotNow,
restoreSnapshot,
};
};
@@ -0,0 +1,867 @@
import React from 'react';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore } from '@/sync/viewport-store';
import { useUIStore } from '@/stores/useUIStore';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
resolveTimelineIsAtEnd,
type TimelineListMeasurementState,
type TimelineScrollMode,
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
// ──────────────────────────────────────────────────────────────────────────
// Chat timeline scroll ownership.
//
// The virtualized list owns the scroll position; this hook only decides which
// of three mutually exclusive modes is active and, when a mode calls for it,
// issues ONE deterministic scroll command:
//
// • `following-end` — pinned to the live edge. The list keeps us there
// through `maintainScrollAtEnd`; we only re-assert after a data change.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
// of the viewport and the reply streams into the reserved end space below
// it. The viewport does NOT move while the turn still fits; once the turn
// outgrows the usable viewport we scroll by the exact delta needed to keep
// its end visible.
// • `free-scrolling` — the user took over. Nothing moves until they opt
// back in by returning to the end.
//
// Opting out of automatic movement is driven by REAL gestures (wheel /
// touchmove / pointerdown), not by inferring intent from scroll positions. Each
// gesture bumps a generation counter; any in-flight automatic movement compares
// its captured generation against the current one and aborts if they differ.
// That comparison replaces the timer windows the previous implementation needed
// to tell its own writes apart from the user's, which is why there are no
// guard/settle/entry-stick timers here.
// ──────────────────────────────────────────────────────────────────────────
// The subset of the list ref this hook drives. Declared structurally so the
// hook stays testable without a renderer and does not hard-depend on the list
// implementation.
export interface TimelineListHandle {
getState: () => TimelineListMeasurementState & {
readonly scroll: number;
readonly listen?: (
listenerType: 'totalSize',
callback: (value: number) => void,
) => () => void;
};
getScrollableNode: () => HTMLElement | null;
scrollToEnd: (options?: { animated?: boolean }) => unknown;
scrollToOffset: (params: { offset: number; animated?: boolean }) => unknown;
scrollToIndex: (params: {
index: number;
animated?: boolean;
viewPosition?: number;
viewOffset?: number;
}) => unknown;
}
interface UseChatTimelineScrollOptions {
currentSessionId: string | null;
currentSessionKey: string | null;
sessionMessageCount: number;
composerOverlayHeight: number;
// Id of the newest user message in the rendered timeline. When a send has
// armed the anchor, the next new id here becomes the anchored row.
lastUserMessageId: string | null;
onActiveTurnChange?: (turnId: string | null) => void;
}
export interface UseChatTimelineScrollResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
// The live scroll element, as state, so effects that must re-bind when the
// list remounts (session switch) can depend on it.
scrollNode: HTMLDivElement | null;
isPinned: boolean;
registerList: (list: TimelineListHandle | null) => void;
anchorMessageId: string | null;
onAnchorReady: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: string) => void;
onIsAtEndChange: (isAtEnd: boolean) => void;
onManualNavigation: () => void;
onTimelineDataChange: () => void;
showScrollButton: boolean;
/** A real gesture took the scroll; flips back on any explicit opt-in. */
userOwnsScroll: boolean;
isFollowingProgrammatically: boolean;
goToBottom: (mode?: 'instant' | 'smooth') => void;
scrollToBottomOnSend: () => void;
saveSnapshotNow: () => void;
restoreSnapshot: () => Promise<boolean>;
}
// Showing the pill is debounced so it does not flash while a thread switch
// settles (the list reports isAtEnd=false until its initial end-scroll lands).
// Hiding is always immediate.
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
const SAVE_DEBOUNCE_MS = 150;
// The anchor scroll is animated; `scrollend` is the authoritative completion
// signal, and this bounds the wait for browsers that drop it.
const ANCHOR_SETTLE_FALLBACK_MS = 750;
// Re-running the anchor positioning while the list is still mounting rows.
const ANCHOR_POSITION_ATTEMPTS = 12;
// Anchor restores only correct sub-pixel drift; anything larger is the user or
// a genuine relayout and must not be undone.
const ANCHOR_RESTORE_TOLERANCE_PX = 2;
export const useChatTimelineScroll = ({
currentSessionId,
currentSessionKey,
sessionMessageCount,
composerOverlayHeight,
lastUserMessageId,
onActiveTurnChange,
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const listRef = React.useRef<TimelineListHandle | null>(null);
const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null);
const [anchorMessageId, setAnchorMessageId] = React.useState<string | null>(null);
const [showScrollButton, setShowScrollButton] = React.useState(false);
// "Pinned" is the live edge, which history pagination uses to decide whether
// it may load older pages without disturbing the read position.
const [isPinned, setIsPinned] = React.useState(true);
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
// True after a real gesture until an explicit opt back in; drives the
// overlay scrollbar suppression instead of the anchor's mere existence.
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
const modeRef = React.useRef<TimelineScrollMode>('following-end');
const isAtEndRef = React.useRef(true);
// Incremented by every real user gesture. Automatic movement is only valid
// while `liveFollowGenerationRef` still equals it.
const userGenerationRef = React.useRef(0);
const liveFollowGenerationRef = React.useRef<number | null>(0);
// Anchor lifecycle: armed on send → pending until the row exists → positioned
// while the animated scroll runs → settled once it has come to rest.
const armedForNextUserMessageRef = React.useRef(false);
const pendingAnchorRef = React.useRef<string | null>(null);
const positionedAnchorRef = React.useRef<string | null>(null);
const settledAnchorRef = React.useRef<string | null>(null);
const activeAnchorIndexRef = React.useRef<number | null>(null);
const pendingAnchorRestoreRef = React.useRef<{
readonly messageId: string;
readonly offset: number;
readonly userGeneration: number;
} | null>(null);
const anchorRestoreFrameRef = React.useRef<number | null>(null);
const showButtonTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
composerOverlayHeightRef.current = composerOverlayHeight;
const sessionMessageCountRef = React.useRef(sessionMessageCount);
sessionMessageCountRef.current = sessionMessageCount;
const currentSessionIdRef = React.useRef(currentSessionId);
currentSessionIdRef.current = currentSessionId;
const currentSessionKeyRef = React.useRef(currentSessionKey);
currentSessionKeyRef.current = currentSessionKey;
const updateViewportAnchor = useViewportStore((state) => state.updateViewportAnchor);
const cancelShowButtonTimer = React.useCallback(() => {
if (showButtonTimerRef.current !== null) {
clearTimeout(showButtonTimerRef.current);
showButtonTimerRef.current = null;
}
}, []);
const hideScrollButton = React.useCallback(() => {
cancelShowButtonTimer();
setShowScrollButton(false);
}, [cancelShowButtonTimer]);
const scheduleShowScrollButton = React.useCallback(() => {
if (showButtonTimerRef.current !== null) return;
showButtonTimerRef.current = setTimeout(() => {
showButtonTimerRef.current = null;
setShowScrollButton(true);
}, SHOW_SCROLL_BUTTON_DELAY_MS);
}, []);
const clearAnchor = React.useCallback(() => {
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
pendingAnchorRestoreRef.current = null;
if (anchorRestoreFrameRef.current !== null) {
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
setAnchorMessageId(null);
}, []);
// A real gesture: stop every automatic movement until the user opts back
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
// viewport back to the end — only the anchor machinery is disarmed.
const onManualNavigation = React.useCallback(() => {
userGenerationRef.current += 1;
modeRef.current = 'free-scrolling';
liveFollowGenerationRef.current = null;
setUserOwnsScroll(true);
// The end may already have been left by our own movement, in which
// case no further at-end transition will fire — and while an animated
// follow glide trails the live edge, isAtEndRef is deliberately not
// updated, so measure the real distance instead of trusting it. This
// is an explicit gesture — show the pill immediately, no debounce.
const listState = listRef.current?.getState();
const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current;
isAtEndRef.current = atEndNow;
if (!atEndNow) {
cancelShowButtonTimer();
setShowScrollButton(true);
}
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
pendingAnchorRestoreRef.current = null;
if (anchorRestoreFrameRef.current !== null) {
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
}, [cancelShowButtonTimer]);
const isLiveFollowActive = React.useCallback(() => (
liveFollowGenerationRef.current === userGenerationRef.current
), []);
// ── snapshot persistence ────────────────────────────────────────────────
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const flushSave = React.useCallback(() => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const pending = pendingSaveRef.current;
if (!pending) return;
const container = scrollRef.current;
if (!container) {
pendingSaveRef.current = null;
return;
}
updateViewportAnchor(pending.sessionId, pending.anchor, {
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
pendingSaveRef.current = null;
}, [updateViewportAnchor]);
const queueSave = React.useCallback(() => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) return;
const container = scrollRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const anchorRatio = scrollHeight > 0
? (scrollTop + clientHeight / 2) / scrollHeight
: 0;
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
pendingSaveRef.current = { sessionId, anchor };
if (saveTimerRef.current !== null) return;
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
flushSave();
}, SAVE_DEBOUNCE_MS);
}, [flushSave]);
const saveSnapshotNow = React.useCallback(() => {
flushSave();
}, [flushSave]);
// ── scroll commands ─────────────────────────────────────────────────────
const goToBottomReassertTimersRef = React.useRef<Array<ReturnType<typeof setTimeout>>>([]);
const clearGoToBottomReasserts = React.useCallback(() => {
for (const timer of goToBottomReassertTimersRef.current) clearTimeout(timer);
goToBottomReassertTimersRef.current = [];
}, []);
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
isAtEndRef.current = true;
setIsPinned(true);
setUserOwnsScroll(false);
modeRef.current = 'following-end';
// Returning to the end is an explicit opt back IN to live follow.
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
// While a stream is growing the content, a single jump lands on the
// end as of that moment and the list's own follow may not have
// re-armed yet — re-assert a few times until the edge holds, then the
// library follows onward. A new user gesture invalidates the window.
clearGoToBottomReasserts();
const generation = userGenerationRef.current;
for (const delay of [150, 400, 800]) {
goToBottomReassertTimersRef.current.push(setTimeout(() => {
if (userGenerationRef.current !== generation) return;
if (modeRef.current !== 'following-end') return;
const state = listRef.current?.getState();
if (state && resolveTimelineIsAtEnd(state) === true) return;
void listRef.current?.scrollToEnd({ animated: false });
}, delay));
}
}, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]);
// Sending arms the anchor. The message id is not known here (the optimistic
// row is created by the store), so the next new user message id claims it.
// Whether the send-time anchor positioning may animate. Sending from the
// live edge parks the new message with a short smooth scroll; sending
// from mid-history teleports — a long smooth scroll through the
// virtualized timeline gets cancelled by rows mounting and measuring
// along the way and dies partway there.
const anchorPositionInstantRef = React.useRef(false);
const scrollToBottomOnSend = React.useCallback(() => {
anchorPositionInstantRef.current = !isAtEndRef.current;
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'anchoring-new-turn';
liveFollowGenerationRef.current = userGenerationRef.current;
armedForNextUserMessageRef.current = true;
// The optimistic row is not committed yet; the next NEW user message id
// relative to this baseline claims the anchor, independent of whether
// the commit lands before or after this call.
armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
hideScrollButton();
}, [hideScrollButton]);
// Claim the anchor as soon as the sent row exists in the timeline. The
// comparison is against the baseline captured when the send armed the
// anchor, so the claim works whether the optimistic row committed before
// or after the arming call.
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
const armBaselineUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
React.useEffect(() => {
lastArmedUserMessageIdRef.current = lastUserMessageId;
if (!armedForNextUserMessageRef.current) return;
if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return;
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = lastUserMessageId;
setAnchorMessageId(lastUserMessageId);
}, [lastUserMessageId]);
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
const sessionKey = currentSessionKeyRef.current;
if (!sessionKey) return false;
// Entering a session always returns to the live edge. Late async growth
// is handled by the list staying at the end, not by a timed hold.
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
void listRef.current?.scrollToEnd({ animated: false });
return false;
}, [clearAnchor, hideScrollButton]);
// ── list callbacks ──────────────────────────────────────────────────────
const registerList = React.useCallback((list: TimelineListHandle | null) => {
listRef.current = list;
const node = (list?.getScrollableNode() as HTMLDivElement | null) ?? null;
scrollRef.current = node;
setScrollNode(node);
}, []);
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
// While an automatic movement owns the viewport, leaving the end is our
// own doing (the anchored turn parks mid-timeline, the glide trails its
// target between corrections) — not a reason to offer the pill. Only a
// real gesture (free-scrolling) shows it.
if (!isAtEnd && isLiveFollowActive()) {
hideScrollButton();
return;
}
if (isAtEndRef.current === isAtEnd) return;
isAtEndRef.current = isAtEnd;
setIsPinned(isAtEnd);
if (isAtEnd) {
if (modeRef.current !== 'anchoring-new-turn') {
modeRef.current = 'following-end';
}
liveFollowGenerationRef.current = userGenerationRef.current;
setUserOwnsScroll(false);
hideScrollButton();
} else {
modeRef.current = 'free-scrolling';
liveFollowGenerationRef.current = null;
scheduleShowScrollButton();
}
queueSave();
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
// Park the anchored row near the top once the list has measured it.
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
// The anchored end space can be remeasured long after the send (turn
// completion, images decoding). Only the send-time anchoring mode may
// position the viewport.
if (modeRef.current !== 'anchoring-new-turn') return;
if (pendingAnchorRef.current === messageId) {
pendingAnchorRef.current = null;
}
activeAnchorIndexRef.current = anchorIndex;
if (positionedAnchorRef.current === messageId) return;
positionedAnchorRef.current = messageId;
settledAnchorRef.current = null;
const positionAnchor = (remainingAttempts: number) => {
requestAnimationFrame(() => {
if (positionedAnchorRef.current !== messageId) return;
const list = listRef.current;
if (!list) {
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
return;
}
const scrollNode = list.getScrollableNode();
if (!scrollNode) {
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
return;
}
let finished = false;
const finishPositioning = () => {
if (finished) return;
finished = true;
clearTimeout(fallbackTimer);
scrollNode.removeEventListener('scrollend', finishPositioning);
if (positionedAnchorRef.current !== messageId) return;
// Re-assert the resting offset without animation so the
// smooth scroll's own momentum cannot drift past it.
const scrollOffset = list.getState().scroll;
void list.scrollToOffset({ offset: scrollOffset, animated: false });
settledAnchorRef.current = messageId;
};
const fallbackTimer = setTimeout(finishPositioning, ANCHOR_SETTLE_FALLBACK_MS);
scrollNode.addEventListener('scrollend', finishPositioning, { once: true });
void list.scrollToIndex({
index: anchorIndex,
animated: !anchorPositionInstantRef.current,
viewPosition: 0,
viewOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
};
requestAnimationFrame(() => positionAnchor(ANCHOR_POSITION_ATTEMPTS));
}, []);
// The anchored row can still change height after it settles (an image
// decoding, a code block highlighting). Hold the resting offset, but only
// against sub-pixel drift and only while the user has not taken over.
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
if (settledAnchorRef.current !== messageId) return;
if (!isLiveFollowActive()) return;
const scrollOffset = listRef.current?.getState().scroll;
if (scrollOffset === undefined) return;
if (pendingAnchorRestoreRef.current === null) {
pendingAnchorRestoreRef.current = {
messageId,
offset: scrollOffset,
userGeneration: userGenerationRef.current,
};
}
if (anchorRestoreFrameRef.current !== null) return;
anchorRestoreFrameRef.current = requestAnimationFrame(() => {
anchorRestoreFrameRef.current = null;
const pending = pendingAnchorRestoreRef.current;
pendingAnchorRestoreRef.current = null;
if (
!pending
|| settledAnchorRef.current !== pending.messageId
|| pending.userGeneration !== userGenerationRef.current
) {
return;
}
const list = listRef.current;
const currentOffset = list?.getState().scroll;
if (
typeof currentOffset === 'number'
&& Math.abs(currentOffset - pending.offset) <= ANCHOR_RESTORE_TOLERANCE_PX
) {
void list?.scrollToOffset({ offset: pending.offset, animated: false });
}
});
}, [isLiveFollowActive]);
// Whether the real rows (ignoring any reserved anchored end space) are tall
// enough to scroll. Without this, entering a short session would scroll into
// the reserved space and strand the content above the viewport.
const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => {
const state = list.getState();
if (state.data.length === 0) return false;
const lastIndex = state.data.length - 1;
const lastTop = state.positionAtIndex(lastIndex);
const lastHeight = state.sizeAtIndex(lastIndex);
if (
typeof lastTop !== 'number'
|| typeof lastHeight !== 'number'
|| !Number.isFinite(lastTop)
|| !Number.isFinite(lastHeight)
) {
return false;
}
const realContentBottom = lastTop + Math.max(1, lastHeight);
const visibleScrollLength = Math.max(
0,
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
);
return realContentBottom > visibleScrollLength;
}, []);
// One deterministic correction per data change, two frames out so the list
// has measured the new rows. Nothing runs while the user owns the scroll.
const dataChangeFramesRef = React.useRef<{ first: number | null; second: number | null }>({
first: null,
second: null,
});
// User preference: with auto-follow off, streaming growth never moves the
// viewport — the anchored user message still parks at the top on send, but
// no glide or end-follow correction runs afterwards.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
// While the list width is resizing, every pinning write fights the
// per-frame row re-measure and the pinned viewport shakes. Corrections
// stand down for the whole resize and the visible content is held by the
// list's size compensation instead. Deliberately NO snap back to the end
// afterwards: a slow drag settles repeatedly, and each snap reads as the
// very jump this suspension removes — geometry changed, staying where the
// reader is beats re-asserting the edge.
const widthResizingRef = React.useRef(false);
React.useEffect(() => {
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
let lastWidth: number | null = null;
let quietTimer: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver((observerEntries) => {
const width = observerEntries[observerEntries.length - 1]?.contentRect.width;
if (typeof width !== 'number') return;
if (lastWidth === null) {
lastWidth = width;
return;
}
if (Math.abs(width - lastWidth) < 1) return;
lastWidth = width;
widthResizingRef.current = true;
if (quietTimer !== null) clearTimeout(quietTimer);
quietTimer = setTimeout(() => {
quietTimer = null;
widthResizingRef.current = false;
}, 350);
});
observer.observe(scrollNode);
return () => {
observer.disconnect();
if (quietTimer !== null) clearTimeout(quietTimer);
};
}, [scrollNode]);
const onTimelineDataChange = React.useCallback(() => {
if (widthResizingRef.current) return;
if (!streamingAutoFollowEnabledRef.current) return;
if (!isLiveFollowActive()) return;
// Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content
// growth on its own — including a tail row growing in place — and
// releases when the user scrolls away. Following the end therefore
// needs no correction here; this handler only serves the
// anchored-turn glide below.
if (modeRef.current === 'following-end') return;
const frames = dataChangeFramesRef.current;
if (frames.first !== null) cancelAnimationFrame(frames.first);
if (frames.second !== null) cancelAnimationFrame(frames.second);
frames.first = requestAnimationFrame(() => {
frames.first = null;
frames.second = requestAnimationFrame(() => {
frames.second = null;
if (!isLiveFollowActive()) return;
// An anchor that exists but has not come to rest yet owns the
// viewport; correcting now would fight its animation.
if (pendingAnchorRef.current !== null) return;
if (
positionedAnchorRef.current !== null
&& settledAnchorRef.current !== positionedAnchorRef.current
) {
return;
}
const list = listRef.current;
if (!list) return;
if (modeRef.current === 'anchoring-new-turn') {
const anchorIndex = activeAnchorIndexRef.current;
if (anchorIndex === null) return;
const metrics = getAnchoredTurnMetrics({
state: list.getState(),
anchorIndex,
composerOverlayHeight: composerOverlayHeightRef.current,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
// The turn still fits: leave the viewport exactly where the
// user is reading.
if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) return;
// Animated: successive corrections restart the smooth scroll
// from the current position, so streaming reads as one
// continuous glide instead of a per-line hop. A real user
// gesture interrupts the native smooth scroll on its own.
void list.scrollToOffset({
offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd,
animated: true,
});
return;
}
});
});
}, [isLiveFollowActive]);
// The streaming tail grows inside one row without changing the entries
// array, so data-change callbacks are silent for the entire stream. The
// list's total content size is the authoritative growth signal; every
// change re-runs the same guarded correction.
const onTimelineDataChangeRef = React.useRef(onTimelineDataChange);
onTimelineDataChangeRef.current = onTimelineDataChange;
React.useEffect(() => {
if (!scrollNode) return;
const listen = listRef.current?.getState().listen;
if (!listen) return;
const unsubscribe = listen('totalSize', () => {
onTimelineDataChangeRef.current();
});
return unsubscribe;
}, [scrollNode]);
// ── gesture opt-out ─────────────────────────────────────────────────────
const onManualNavigationRef = React.useRef(onManualNavigation);
onManualNavigationRef.current = onManualNavigation;
React.useEffect(() => {
if (!scrollNode) return;
// A gesture is meaningful when the viewport can move up AT ALL:
// either the real rows overflow the viewport, or there is scrolled
// history above (an anchored turn parks mid-conversation with
// reserved space below — the real rows may not overflow yet, but
// wheel-up is still a genuine opt-out; swallowing it left live-follow
// armed, which suppressed the pill and kept corrections armed under a
// viewport the user had taken).
const canScrollUp = () => {
const list = listRef.current;
if (!list) return false;
if (list.getState().scroll > 1) return true;
return realContentOverflowsViewport(list);
};
const gesture = () => {
onManualNavigationRef.current();
};
const handleWheel = (event: WheelEvent) => {
// Scrolling toward the end is not opting out of follow.
if (event.deltaY < 0 && canScrollUp()) gesture();
};
// Touch mirrors wheel by finger direction, not by having already left
// the end: while a stream keeps re-pinning the viewport, waiting for
// an at-end transition means the drag never registers — the user
// cannot scroll, the pill never appears, and live-follow stays armed
// under a viewport they are fighting for.
let touchLastY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
touchLastY = event.touches[0]?.clientY ?? null;
};
const handleTouchMove = (event: TouchEvent) => {
const y = event.touches[0]?.clientY ?? null;
const lastY = touchLastY;
touchLastY = y;
if (y === null) return;
// A downward finger drags the content up — the touch wheel-up.
const draggedUp = lastY !== null && y > lastY;
if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture();
};
const handleTouchEnd = () => {
touchLastY = null;
};
const handlePointerDown = (event: PointerEvent) => {
// The scrollbar track is the scroll node itself; a tap on a row
// only breaks follow when the viewport already left the end.
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
};
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) {
gesture();
}
};
const handleScroll = () => {
queueSave();
};
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
scrollNode.addEventListener('keydown', handleKeyDown);
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollNode.removeEventListener('wheel', handleWheel);
scrollNode.removeEventListener('touchstart', handleTouchStart);
scrollNode.removeEventListener('touchmove', handleTouchMove);
scrollNode.removeEventListener('touchend', handleTouchEnd);
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
scrollNode.removeEventListener('pointerdown', handlePointerDown);
scrollNode.removeEventListener('keydown', handleKeyDown);
scrollNode.removeEventListener('scroll', handleScroll);
};
}, [queueSave, realContentOverflowsViewport, scrollNode]);
// ── session lifecycle ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
return;
}
lastSessionKeyRef.current = currentSessionKey;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
// Persist the outgoing session's position before the new one takes over.
flushSave();
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
// Suppress the overlay scrollbar thumb while automatic movement owns the
// scroll position, so it does not jump on each correction.
React.useEffect(() => {
setIsFollowingProgrammatically(!showScrollButton && !userOwnsScroll);
}, [showScrollButton, userOwnsScroll]);
React.useEffect(() => () => {
cancelShowButtonTimer();
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current);
const frames = dataChangeFramesRef.current;
if (frames.first !== null) cancelAnimationFrame(frames.first);
if (frames.second !== null) cancelAnimationFrame(frames.second);
}, [cancelShowButtonTimer]);
// ── active-turn spy ─────────────────────────────────────────────────────
// Reads turn positions straight from the DOM, so it is unaffected by which
// list implementation owns the container. Rows mounting and unmounting
// during virtualized scrolling are tracked through the mutation observer.
React.useEffect(() => {
if (!onActiveTurnChange) return;
const container = scrollNode;
if (!container) return;
let lastActiveTurnId: string | null = null;
const spy = createScrollSpy({
onActive: (turnId) => {
if (turnId === lastActiveTurnId) return;
lastActiveTurnId = turnId;
onActiveTurnChange(turnId);
},
});
spy.setContainer(container);
const elementByTurnId = new Map<string, HTMLElement>();
const registerTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
elementByTurnId.set(turnId, node);
spy.register(node, turnId);
return true;
};
const unregisterTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
if (elementByTurnId.get(turnId) !== node) return false;
elementByTurnId.delete(turnId);
spy.unregister(turnId);
return true;
};
const collectTurnNodes = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) return [];
const collected: HTMLElement[] = [];
if (node.matches('[data-turn-id]')) collected.push(node);
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
return collected;
};
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
spy.markDirty();
const mutationObserver = new MutationObserver((records) => {
let changed = false;
records.forEach((record) => {
record.removedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (unregisterTurnNode(turnNode)) changed = true;
});
});
record.addedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (registerTurnNode(turnNode)) changed = true;
});
});
});
if (changed) spy.markDirty();
});
mutationObserver.observe(container, { subtree: true, childList: true });
const onScroll = () => spy.onScroll();
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
mutationObserver.disconnect();
spy.destroy();
};
}, [onActiveTurnChange, scrollNode]);
return {
scrollRef,
scrollNode,
isPinned,
registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onManualNavigation,
onTimelineDataChange,
showScrollButton,
userOwnsScroll,
isFollowingProgrammatically,
goToBottom,
scrollToBottomOnSend,
saveSnapshotNow,
restoreSnapshot,
};
};
+126 -58
View File
@@ -256,81 +256,91 @@ div[data-chat-input-footer="true"] {
-webkit-appearance: none;
}
/* Scroll edge fades live on the viewport, never on the scrolling content. */
[data-scroll-shadow-viewport]::before,
[data-scroll-shadow-viewport]::after {
content: "";
pointer-events: none;
position: absolute;
z-index: 10;
opacity: 0;
/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */
[data-scroll-shadow="true"] {
/* A concrete default, not var(--scroll-shadow-size, 48px): a custom
property referencing itself is a cycle and computes to invalid, which
silently killed every mask below for consumers that don't set the
variable inline (the hook-based chat scroller). Inline styles from the
ScrollShadow component still override this. */
--scroll-shadow-size: 48px;
}
[data-scroll-shadow-viewport][data-orientation="vertical"]::before,
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
inset-inline: 0;
[data-scroll-shadow="true"][data-orientation="vertical"] {
mask-mode: alpha;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
[data-scroll-shadow-viewport][data-orientation="vertical"]::before {
top: 0;
height: var(--scroll-shadow-top-size, var(--scroll-shadow-size));
background: linear-gradient(
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-bottom-scroll="true"] {
mask-image: linear-gradient(
to bottom,
var(--scroll-shadow-color, var(--surface-background)) 0,
var(--scroll-shadow-color, var(--surface-background)) var(--scroll-shadow-top-clear-size, 0px),
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
}
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
bottom: 0;
height: var(--scroll-shadow-size);
background: linear-gradient(to top, var(--scroll-shadow-color, var(--surface-background)), transparent);
}
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before,
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
inset-block: 0;
width: var(--scroll-shadow-size);
}
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before {
left: 0;
background: linear-gradient(to right, var(--scroll-shadow-color, var(--surface-background)), transparent);
}
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
right: 0;
background: linear-gradient(to left, var(--scroll-shadow-color, var(--surface-background)), transparent);
}
[data-scroll-shadow-viewport][data-top-scroll="true"]::before,
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::before,
[data-scroll-shadow-viewport][data-bottom-scroll="true"]::after,
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::after,
[data-scroll-shadow-viewport][data-left-scroll="true"]::before,
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::before,
[data-scroll-shadow-viewport][data-right-scroll="true"]::after,
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::after {
opacity: 1;
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-scroll="true"] {
mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 100%
);
}
/* Sticky-fade overlay: the crisp duplicate identity stays
mounted and its visibility is driven synchronously by the viewport's
top-scroll data attribute, so the overlay appears in the exact frame the
edge fade covers the real header (no blink).
mounted and its visibility is driven synchronously by the scroller's own
top-scroll data attribute the same signal that engages the mask so the
overlay appears in the exact frame the mask hides the real header (no blink).
ScrollShadow sets data-top-scroll when nothing is below the viewport and
data-top-bottom-scroll when content is both above and below. */
.oc-sticky-fade-overlay {
opacity: 0;
}
.oc-sticky-fade-root:has(
[data-scroll-shadow-viewport]:is([data-top-scroll="true"], [data-top-bottom-scroll="true"])
) .oc-sticky-fade-overlay {
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-scroll="true"]) .oc-sticky-fade-overlay,
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-bottom-scroll="true"]) .oc-sticky-fade-overlay {
opacity: 1;
}
[data-scroll-shadow="true"][data-orientation="vertical"][data-bottom-scroll="true"] {
mask-image: linear-gradient(
to bottom,
#000 0%,
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
#000 0%,
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
}
@keyframes spin-once {
from {
transform: rotate(0deg);
@@ -885,15 +895,15 @@ html:not(.dark) .chat-scroll {
}
/* Hide the long active todo before it can collide with the changed-files summary. */
@container status-row (max-width: 38rem) {
.status-row__active-todo {
@container composer-status-bar (max-width: 38rem) {
.composer-status-bar__active-todo {
display: none;
}
}
/* Hide the secondary changed-files label on narrow mobile layouts. */
@container status-row (max-width: 30rem) {
.status-row__changed-label {
@container composer-status-bar (max-width: 30rem) {
.composer-status-bar__changed-label {
display: none;
}
}
@@ -1353,6 +1363,64 @@ html:not(.dark) .chat-scroll {
min-width: 100%;
}
/* First uncached open of a session shows a hydration skeleton; the real
timeline replacing it fades in once instead of popping. Cached session
switches never carry this class and stay instant. */
@keyframes oc-chat-hydration-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.oc-chat-hydration-reveal {
animation: oc-chat-hydration-reveal 180ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.oc-chat-hydration-reveal {
animation: none;
}
}
/* A block committed mid-stream enters with a fade and a gentle rise
compositor-only properties, deliberately nothing heavier: during a long
stream the follow scroll masks the entrance anyway, so the effect only
really shows on short replies, and those must not pay for a GPU filter.
Blocks committed in the same tick cascade via --oc-md-enter-delay set
inline by the renderer. */
@keyframes oc-md-block-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.oc-md-block-enter {
animation: oc-md-block-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: var(--oc-md-enter-delay, 0ms);
}
@media (prefers-reduced-motion: reduce) {
.oc-md-block-enter {
animation: none;
}
}
/* While streaming defers the per-line gutter markup, hold its horizontal
footprint (2rem column + 0.75rem gap) so the finished pass only fills in
the numbers instead of shifting every code line. */
.markdown-content pre[data-md-gutter-reserved] > code {
display: block;
padding-left: 2.75rem;
}
.markdown-content [data-md-code-line-number] {
align-self: stretch;
padding-right: 0.75rem;
@@ -7,6 +7,7 @@ import type { TerminalShell } from '@/lib/api/types';
type AppearanceSlice = {
showReasoningTraces: boolean;
streamingAutoFollowEnabled: boolean;
workStatusPanelEnabled: boolean;
workStatusHiddenSections: string[];
sessionRecapEnabled: boolean;
@@ -62,6 +63,7 @@ export const startAppearanceAutoSave = (): void => {
let previous: AppearanceSlice = {
showReasoningTraces: useUIStore.getState().showReasoningTraces,
streamingAutoFollowEnabled: useUIStore.getState().streamingAutoFollowEnabled,
workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled,
workStatusHiddenSections: useUIStore.getState().workStatusHiddenSections,
sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled,
@@ -104,6 +106,7 @@ export const startAppearanceAutoSave = (): void => {
useUIStore.subscribe((state) => {
const current: AppearanceSlice = {
showReasoningTraces: state.showReasoningTraces,
streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
workStatusPanelEnabled: state.workStatusPanelEnabled,
workStatusHiddenSections: state.workStatusHiddenSections,
sessionRecapEnabled: state.sessionRecapEnabled,
@@ -156,6 +159,9 @@ export const startAppearanceAutoSave = (): void => {
if (current.showReasoningTraces !== previous.showReasoningTraces) {
diff.showReasoningTraces = current.showReasoningTraces;
}
if (current.streamingAutoFollowEnabled !== previous.streamingAutoFollowEnabled) {
diff.streamingAutoFollowEnabled = current.streamingAutoFollowEnabled;
}
if (current.sessionRecapEnabled !== previous.sessionRecapEnabled) {
diff.sessionRecapEnabled = current.sessionRecapEnabled;
}
+1
View File
@@ -131,6 +131,7 @@ export type DesktopSettings = {
defaultVariant?: string;
defaultAgent?: string;
smallModelUseDefault?: boolean;
streamingAutoFollowEnabled?: boolean;
sessionRecapEnabled?: boolean;
sessionSuggestionEnabled?: boolean;
sessionGoalEnabled?: boolean;
@@ -1856,6 +1856,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Werkzeuge standardmäßig geöffnet anzeigen:',
'settings.openchamber.visual.section.sessionAssistance': 'Sitzungshilfe',
'settings.openchamber.visual.section.reasoning': 'Reasoning',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Neuen Inhalten beim Streaming folgen',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Neuen Inhalten automatisch folgen, während eine Antwort gestreamt wird',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Während eine Antwort eintrifft, folgt die Ansicht laufend dem neuesten Inhalt. Deaktivieren, um die Ansicht ruhig zu halten und manuell zu scrollen.',
'settings.openchamber.visual.section.messageAppearance': 'Nachrichten-Erscheinungsbild',
'settings.openchamber.visual.section.toolsAndFiles': 'Werkzeuge & Dateien',
'settings.openchamber.visual.section.composer': 'Komponist',
@@ -1929,6 +1929,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Show tools opened by default',
'settings.openchamber.visual.section.sessionAssistance': 'Session Assistance',
'settings.openchamber.visual.section.reasoning': 'Reasoning',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Follow new content while streaming',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatically follow new content while a response streams',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'While a reply streams in, the view keeps gliding to the newest content. Turn this off to keep the view still and scroll manually.',
'settings.openchamber.visual.section.messageAppearance': 'Message Appearance',
'settings.openchamber.visual.section.toolsAndFiles': 'Tools & Files',
'settings.openchamber.visual.section.composer': 'Composer',
@@ -1906,6 +1906,10 @@ export const settingsDict = {
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar herramientas abiertas por defecto",
"settings.openchamber.visual.section.sessionAssistance": "Asistencia de sesión",
"settings.openchamber.visual.section.reasoning": "Razonamiento",
"settings.openchamber.visual.section.streaming": "Streaming",
"settings.openchamber.visual.field.streamingAutoFollow": "Seguir el contenido nuevo durante el streaming",
"settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automáticamente el contenido nuevo mientras se transmite una respuesta",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Mientras llega una respuesta, la vista se desplaza hacia el contenido más reciente. Desactívalo para mantener la vista quieta y desplazarte manualmente.",
"settings.openchamber.visual.section.messageAppearance": "Apariencia de los mensajes",
"settings.openchamber.visual.section.toolsAndFiles": "Herramientas y archivos",
"settings.openchamber.visual.section.composer": "Compositor",
@@ -1820,6 +1820,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Afficher les outils ouverts par défaut',
'settings.openchamber.visual.section.sessionAssistance': 'Assistance de session',
'settings.openchamber.visual.section.reasoning': 'Raisonnement',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Suivre le nouveau contenu pendant le streaming',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Suivre automatiquement le nouveau contenu pendant la diffusion dune réponse',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Pendant quune réponse arrive, la vue glisse vers le contenu le plus récent. Désactivez pour garder la vue immobile et défiler manuellement.',
'settings.openchamber.visual.section.messageAppearance': 'Apparence des messages',
'settings.openchamber.visual.section.toolsAndFiles': 'Outils et fichiers',
'settings.openchamber.visual.section.composer': 'Zone de saisie',
@@ -1939,6 +1939,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'デフォルトで開くツールを表示',
'settings.openchamber.visual.section.sessionAssistance': 'セッション支援',
'settings.openchamber.visual.section.reasoning': '推論',
'settings.openchamber.visual.section.streaming': 'ストリーミング',
'settings.openchamber.visual.field.streamingAutoFollow': '応答のストリーミング中に新しい内容を追従',
'settings.openchamber.visual.field.streamingAutoFollowAria': '応答のストリーミング中に新しい内容へ自動スクロールする',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '応答の受信中、ビューは常に最新の内容へスクロールします。オフにするとビューは動かず、手動でスクロールできます。',
'settings.openchamber.visual.section.messageAppearance': 'メッセージの外観',
'settings.openchamber.visual.section.toolsAndFiles': 'ツールとファイル',
'settings.openchamber.visual.section.composer': '入力欄',
@@ -1906,6 +1906,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': '도구를 기본으로 펼쳐 표시',
'settings.openchamber.visual.section.sessionAssistance': '세션 지원',
'settings.openchamber.visual.section.reasoning': '추론',
'settings.openchamber.visual.section.streaming': '스트리밍',
'settings.openchamber.visual.field.streamingAutoFollow': '스트리밍 중 새 내용 따라가기',
'settings.openchamber.visual.field.streamingAutoFollowAria': '응답 스트리밍 중 새 내용으로 자동 스크롤',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '응답이 스트리밍되는 동안 화면이 최신 내용으로 계속 이동합니다. 끄면 화면이 고정되어 직접 스크롤할 수 있습니다.',
'settings.openchamber.visual.section.messageAppearance': '메시지 모양',
'settings.openchamber.visual.section.toolsAndFiles': '도구 및 파일',
'settings.openchamber.visual.section.composer': '입력창',
@@ -1209,6 +1209,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Pokaż narzędzia domyślnie otwarte',
'settings.openchamber.visual.section.sessionAssistance': 'Wsparcie sesji',
'settings.openchamber.visual.section.reasoning': 'Rozumowanie',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Podążaj za nową treścią podczas streamingu',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatycznie podążaj za nową treścią podczas streamowania odpowiedzi',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Podczas napływania odpowiedzi widok płynnie podąża za najnowszą treścią. Wyłącz, aby widok pozostał nieruchomy i przewijać ręcznie.',
'settings.openchamber.visual.section.messageAppearance': 'Wygląd wiadomości',
'settings.openchamber.visual.section.toolsAndFiles': 'Narzędzia i pliki',
'settings.openchamber.visual.section.composer': 'Pole wiadomości',
@@ -1906,6 +1906,10 @@ export const settingsDict = {
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar ferramentas abertas por padrão",
"settings.openchamber.visual.section.sessionAssistance": "Assistência da sessão",
"settings.openchamber.visual.section.reasoning": "Raciocínio",
"settings.openchamber.visual.section.streaming": "Streaming",
"settings.openchamber.visual.field.streamingAutoFollow": "Seguir o novo conteúdo durante o streaming",
"settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automaticamente o novo conteúdo enquanto uma resposta é transmitida",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Enquanto uma resposta chega, a visualização acompanha o conteúdo mais recente. Desative para manter a visualização parada e rolar manualmente.",
"settings.openchamber.visual.section.messageAppearance": "Aparência das mensagens",
"settings.openchamber.visual.section.toolsAndFiles": "Ferramentas e arquivos",
"settings.openchamber.visual.section.composer": "Campo de mensagem",
@@ -1906,6 +1906,10 @@ export const settingsDict = {
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Показувати інструменти відкритими за замовчуванням",
"settings.openchamber.visual.section.sessionAssistance": "Допомога із сесією",
"settings.openchamber.visual.section.reasoning": "Міркування",
"settings.openchamber.visual.section.streaming": "Стримінг",
"settings.openchamber.visual.field.streamingAutoFollow": "Слідкувати за новим вмістом під час стримінгу",
"settings.openchamber.visual.field.streamingAutoFollowAria": "Автоматично слідкувати за новим вмістом під час стримінгу відповіді",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Поки відповідь надходить, вигляд плавно рухається до найновішого вмісту. Вимкніть, щоб вигляд залишався нерухомим і гортати вручну.",
"settings.openchamber.visual.section.messageAppearance": "Вигляд повідомлень",
"settings.openchamber.visual.section.toolsAndFiles": "Інструменти та файли",
"settings.openchamber.visual.section.composer": "Поле вводу",
@@ -1906,6 +1906,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': '默认展开以下工具',
'settings.openchamber.visual.section.sessionAssistance': '会话辅助',
'settings.openchamber.visual.section.reasoning': '推理',
'settings.openchamber.visual.section.streaming': '流式输出',
'settings.openchamber.visual.field.streamingAutoFollow': '流式输出时跟随新内容',
'settings.openchamber.visual.field.streamingAutoFollowAria': '在回复流式输出时自动跟随新内容',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回复流式输出时,视图会持续滚动到最新内容。关闭后视图保持不动,可手动滚动。',
'settings.openchamber.visual.section.messageAppearance': '消息外观',
'settings.openchamber.visual.section.toolsAndFiles': '工具和文件',
'settings.openchamber.visual.section.composer': '输入框',
@@ -1813,6 +1813,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': '預設展開以下工具',
'settings.openchamber.visual.section.sessionAssistance': '工作階段輔助',
'settings.openchamber.visual.section.reasoning': '推理',
'settings.openchamber.visual.section.streaming': '串流',
'settings.openchamber.visual.field.streamingAutoFollow': '串流時跟隨新內容',
'settings.openchamber.visual.field.streamingAutoFollowAria': '回覆串流時自動跟隨新內容',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回覆串流時,畫面會持續捲動到最新內容。關閉後畫面保持不動,可手動捲動。',
'settings.openchamber.visual.section.messageAppearance': '訊息外觀',
'settings.openchamber.visual.section.toolsAndFiles': '工具與檔案',
'settings.openchamber.visual.section.composer': '輸入框',
+7
View File
@@ -531,6 +531,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
darkThemeId: DEFAULT_DARK_THEME_ID,
openInAppId: DEFAULT_OPEN_IN_APP_ID,
showReasoningTraces: defaults.showReasoningTraces,
streamingAutoFollowEnabled: defaults.streamingAutoFollowEnabled,
workStatusPanelEnabled: defaults.workStatusPanelEnabled,
workStatusHiddenSections: defaults.workStatusHiddenSections,
sessionRecapEnabled: defaults.sessionRecapEnabled,
@@ -637,6 +638,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
store.setShowReasoningTraces(settings.showReasoningTraces);
}
if (typeof settings.streamingAutoFollowEnabled === 'boolean' && settings.streamingAutoFollowEnabled !== store.streamingAutoFollowEnabled) {
store.setStreamingAutoFollowEnabled(settings.streamingAutoFollowEnabled);
}
if (typeof settings.sessionRecapEnabled === 'boolean' && settings.sessionRecapEnabled !== store.sessionRecapEnabled) {
store.setSessionRecapEnabled(settings.sessionRecapEnabled);
}
@@ -1158,6 +1162,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.streamingAutoFollowEnabled === 'boolean') {
result.streamingAutoFollowEnabled = candidate.streamingAutoFollowEnabled;
}
if (typeof candidate.sessionRecapEnabled === 'boolean') {
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
}
+13
View File
@@ -240,6 +240,19 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.openchamber.visual.section.reasoning',
keywords: ['thinking', 'traces'],
},
{
id: 'chat.streaming',
page: 'chat',
titleKey: 'settings.openchamber.visual.section.streaming',
keywords: ['stream', 'scroll'],
},
{
id: 'chat.streaming-auto-follow',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.streamingAutoFollow',
descriptionKey: 'settings.openchamber.visual.field.streamingAutoFollowInfo',
keywords: ['autoscroll', 'auto-scroll', 'follow', 'stick to bottom', 'streaming'],
},
{
id: 'chat.sticky-user-header',
page: 'chat',
+8
View File
@@ -674,6 +674,7 @@ interface UIStore {
eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null;
showReasoningTraces: boolean;
streamingAutoFollowEnabled: boolean;
sessionRecapEnabled: boolean;
sessionSuggestionEnabled: boolean;
sessionGoalEnabled: boolean;
@@ -859,6 +860,7 @@ interface UIStore {
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void;
setStreamingAutoFollowEnabled: (value: boolean) => void;
setSessionRecapEnabled: (value: boolean) => void;
setSessionSuggestionEnabled: (value: boolean) => void;
setSessionGoalEnabled: (value: boolean) => void;
@@ -1027,6 +1029,7 @@ export const useUIStore = create<UIStore>()(
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: true,
streamingAutoFollowEnabled: true,
sessionRecapEnabled: true,
sessionSuggestionEnabled: true,
sessionGoalEnabled: true,
@@ -1759,6 +1762,10 @@ export const useUIStore = create<UIStore>()(
set({ showReasoningTraces: value });
},
setStreamingAutoFollowEnabled: (value) => {
set({ streamingAutoFollowEnabled: value });
},
setSessionRecapEnabled: (value) => {
set({ sessionRecapEnabled: value });
},
@@ -2639,6 +2646,7 @@ export const useUIStore = create<UIStore>()(
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
// Note: isSettingsDialogOpen intentionally NOT persisted
showReasoningTraces: state.showReasoningTraces,
streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
sessionRecapEnabled: state.sessionRecapEnabled,
sessionSuggestionEnabled: state.sessionSuggestionEnabled,
sessionGoalEnabled: state.sessionGoalEnabled,
+1 -1
View File
@@ -473,7 +473,7 @@
choreography driven by useNativeMobileChrome:
show: shell keeps its full height for the whole 0.25s; the composer (and, when
pinned, the chat scroll container see useChatAutoFollow) slides up via
pinned, the chat scroll container see useChatTimelineScroll) slides up via
inline transforms in sync with the keyboard; at the end the shell snaps to
its final height (--oc-kb-layout, one reflow) and the shift is removed
in the same frame visually identical, so the swap is invisible.
+42
View File
@@ -2702,6 +2702,48 @@ export function useSessionParts(messageID: string, directory?: string) {
)
}
const EMPTY_PARTS_BY_MESSAGE: Record<string, Part[]> = {}
/**
* Get parts for several messages at once, keyed by message id. The snapshot
* keeps its identity until one of the requested part arrays changes, so a
* streaming turn can overlay every one of its step messages not only the
* currently streaming one without tearing between them when the stream
* moves to the next message.
*/
export function useSessionPartsForMessages(messageIDs: readonly string[], directory?: string): Record<string, Part[]> {
const store = useDirectoryStore(directory)
const cacheRef = React.useRef<{ ids: readonly string[]; parts: Record<string, Part[]> } | null>(null)
const getSnapshot = useCallback(() => {
if (messageIDs.length === 0) return EMPTY_PARTS_BY_MESSAGE
const state = store.getState()
const cached = cacheRef.current
if (
cached
&& cached.ids === messageIDs
&& messageIDs.every((id) => (state.part[id] ?? EMPTY_PARTS) === (cached.parts[id] ?? EMPTY_PARTS))
) {
return cached.parts
}
const parts: Record<string, Part[]> = {}
for (const id of messageIDs) parts[id] = state.part[id] ?? EMPTY_PARTS
cacheRef.current = { ids: messageIDs, parts }
return parts
}, [messageIDs, store])
const subscribe = useCallback((notify: () => void) => {
if (messageIDs.length === 0) return () => undefined
return store.subscribe((state, previous) => {
for (const id of messageIDs) {
if (state.part[id] !== previous.part[id]) {
notify()
return
}
}
})
}, [messageIDs, store])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
/** Get status for a specific session */
export function useSessionStatus(sessionID: string, directory?: string) {
const store = useDirectoryStore(directory)