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
+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 ? (