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:
@@ -628,7 +628,6 @@ const MobileDiffDetail: React.FC<{
|
||||
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
|
||||
) : (
|
||||
<ScrollShadow
|
||||
viewportClassName="h-full"
|
||||
className="h-full overflow-y-auto overflow-x-hidden p-3"
|
||||
data-diff-virtual-root
|
||||
data-diff-virtual-content
|
||||
|
||||
@@ -278,7 +278,7 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollShadow viewportClassName="min-h-0 flex-1" className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
|
||||
{directoryError ? (
|
||||
<MobileFilesState message={directoryError} />
|
||||
) : query.trim() ? (
|
||||
|
||||
@@ -1455,7 +1455,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
// clipped overflow swallowed the footer.
|
||||
const surfaceContent = (
|
||||
<div ref={contentRootRef} className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollShadow viewportClassName="min-h-0 flex-1" className="min-h-0 flex-1 overflow-y-auto pb-4">
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto pb-4">
|
||||
{/* The search bar scrolls WITH the list (iOS-style): the open-time
|
||||
auto-scroll to the current session naturally tucks it away, and
|
||||
scrolling to the very top brings it back. */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -1,938 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
|
||||
type AutoFollowState = 'following' | 'released';
|
||||
|
||||
export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation';
|
||||
|
||||
export interface AnimationHandlers {
|
||||
onChunk: () => void;
|
||||
onComplete: () => void;
|
||||
onStreamingCandidate?: () => void;
|
||||
onAnimationStart?: () => void;
|
||||
onReservationCancelled?: () => void;
|
||||
onReasoningBlock?: () => void;
|
||||
onAnimatedHeightChange?: (height: number) => void;
|
||||
}
|
||||
|
||||
interface UseChatAutoFollowOptions {
|
||||
currentSessionId: string | null;
|
||||
currentSessionKey: string | null;
|
||||
sessionMessageCount: number;
|
||||
sessionIsWorking: boolean;
|
||||
isMobile: boolean;
|
||||
onActiveTurnChange?: (turnId: string | null) => void;
|
||||
}
|
||||
|
||||
export interface UseChatAutoFollowResult {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
state: AutoFollowState;
|
||||
isPinned: boolean;
|
||||
isOverflowing: boolean;
|
||||
isFollowingProgrammatically: boolean;
|
||||
showScrollButton: boolean;
|
||||
notifyContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
||||
scrollToBottomOnSend: () => void;
|
||||
releaseAutoFollow: () => void;
|
||||
saveSnapshotNow: () => void;
|
||||
restoreSnapshot: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Chat auto-follow. The model is deliberately simple, which is what makes it
|
||||
// flicker-free:
|
||||
//
|
||||
// • Auto-follow is on unless the user scrolled up (`released`), AND passive
|
||||
// following only acts while the session is active (working, plus a short
|
||||
// settle window). When idle, content-size changes are layout churn
|
||||
// (virtualizer re-measurement, async tool/code rendering) rather than live
|
||||
// growth, so the hook leaves scroll alone — re-pinning then would fight the
|
||||
// virtualizer and twitch the viewport.
|
||||
// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the
|
||||
// content ResizeObserver, which fires after layout and before paint. There
|
||||
// is NO easing loop and NO settle burst, so there are never two writers
|
||||
// racing for `scrollTop` (the root cause of the old jiggle/double-scroll).
|
||||
// • A short-lived "auto" marker (position + 1500ms) lets the scroll handler
|
||||
// distinguish our own programmatic writes from genuine user scrolling, so
|
||||
// a scroll event that lands at our just-written bottom never trips a false
|
||||
// release.
|
||||
//
|
||||
// The public interface below is unchanged from the old implementation so every
|
||||
// consumer (ChatContainer, message parts, the timeline controller) keeps
|
||||
// working without edits.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOTTOM_SPACER_DESKTOP_VH = 0.10;
|
||||
const BOTTOM_SPACER_MOBILE_PX = 40;
|
||||
const SAVE_DEBOUNCE_MS = 150;
|
||||
const TOUCH_FINGER_DOWN_THRESHOLD = 2;
|
||||
// How long an "auto" (programmatic) scroll position stays trusted. Browsers can
|
||||
// dispatch the `scroll` event for our write asynchronously, after newer content
|
||||
// has already changed the geometry; the window keeps us from reading that lag as
|
||||
// a user scroll.
|
||||
const AUTO_MARK_TTL_MS = 1500;
|
||||
const AUTO_MATCH_TOLERANCE_PX = 2;
|
||||
// While a tracked height animation runs (e.g. a Thinking block auto-collapsing
|
||||
// mid-stream), the timeline shrinks/grows over a couple hundred ms and the
|
||||
// virtualizer re-measures, producing transient geometry. Browsers dispatch the
|
||||
// resulting `scroll` events asynchronously, so a stale event can land after we
|
||||
// have already re-pinned — its position matching neither the bottom zone nor the
|
||||
// freshly-moved auto marker — and be misread as a user scroll-away. During this
|
||||
// guard window we treat any `following`-state scroll event as our own and never
|
||||
// release via the heuristic. GENUINE user gestures still release instantly
|
||||
// through releaseFromUserIntent, so this is not glue. Sized to the reasoning
|
||||
// animation (200ms) plus headroom for trailing async scroll events.
|
||||
const ANIMATION_GUARD_MS = 350;
|
||||
// After streaming stops, keep following the bottom for a short window so the
|
||||
// final content can settle into place.
|
||||
const SETTLE_MS = 300;
|
||||
// Entry-stick window. On the FIRST open of a session, late async data (most
|
||||
// visibly a task/subagent tool whose nested rows are fetched from the child
|
||||
// session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the
|
||||
// timeline a beat or two AFTER we have already pinned to the bottom, leaving the
|
||||
// viewport stranded mid-history. The steady-state idle gate deliberately ignores
|
||||
// that growth (it can't tell entry from a user reading idle history). So instead
|
||||
// of weakening the gate, we open a short, gesture-cancellable window on entry
|
||||
// during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after
|
||||
// growth stops (capped by MAX_MS), or instantly on any real user scroll gesture.
|
||||
const ENTRY_STICK_QUIESCENCE_MS = 600;
|
||||
const ENTRY_STICK_MAX_MS = 8000;
|
||||
|
||||
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
|
||||
// — its height is exactly how far above scrollHeight the user can be while still
|
||||
// looking at "empty" space. We use that same value as the threshold for both
|
||||
// re-pinning auto-follow and showing the scroll-to-bottom button.
|
||||
const computeBottomZoneThreshold = (isMobile: boolean, container?: HTMLElement | null): number => {
|
||||
if (isMobile) return BOTTOM_SPACER_MOBILE_PX;
|
||||
const height = container?.clientHeight ?? 0;
|
||||
if (height <= 0) return 96;
|
||||
return Math.max(48, height * BOTTOM_SPACER_DESKTOP_VH);
|
||||
};
|
||||
|
||||
const distanceFromBottom = (el: HTMLElement): number => {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
};
|
||||
|
||||
const canScroll = (el: HTMLElement): boolean => {
|
||||
return el.scrollHeight - el.clientHeight > 1;
|
||||
};
|
||||
|
||||
const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
|
||||
return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el);
|
||||
};
|
||||
|
||||
const isReleaseKey = (event: KeyboardEvent): boolean => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
case 'PageUp':
|
||||
case 'Home':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const nested = target.closest('[data-scrollable]');
|
||||
if (!nested || nested === root || !(nested instanceof HTMLElement)) return null;
|
||||
return nested;
|
||||
};
|
||||
|
||||
const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => {
|
||||
const nested = nestedScrollableTarget(root, target);
|
||||
if (!nested) return false;
|
||||
return nested.scrollTop > 0;
|
||||
};
|
||||
|
||||
export const useChatAutoFollow = ({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
onActiveTurnChange,
|
||||
}: UseChatAutoFollowOptions): UseChatAutoFollowResult => {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [containerEl, setContainerEl] = React.useState<HTMLDivElement | null>(null);
|
||||
const lastSeenContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [state, setState] = React.useState<AutoFollowState>('following');
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
||||
|
||||
// `stateRef` is the single source of truth for follow vs released; the React
|
||||
// state above is a mirror for rendering. `released` means the user scrolled
|
||||
// up and away from the bottom.
|
||||
const stateRef = React.useRef<AutoFollowState>('following');
|
||||
const isMobileRef = React.useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
|
||||
sessionIsWorkingRef.current = sessionIsWorking;
|
||||
// `settling` keeps passive follow alive for a short window after work stops
|
||||
// so the final content can land at the bottom.
|
||||
const settlingRef = React.useRef(false);
|
||||
const settleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
||||
sessionMessageCountRef.current = sessionMessageCount;
|
||||
const currentSessionIdRef = React.useRef(currentSessionId);
|
||||
currentSessionIdRef.current = currentSessionId;
|
||||
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
||||
currentSessionKeyRef.current = currentSessionKey;
|
||||
|
||||
const lastSessionKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
// Programmatic-scroll marker: the bottom position we last
|
||||
// wrote and when. A scroll event whose scrollTop matches `top` within a few
|
||||
// px while still inside the TTL is OUR write, not the user's.
|
||||
const autoRef = React.useRef<{ top: number; time: number } | null>(null);
|
||||
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Timestamp until which a tracked height animation is in flight (see
|
||||
// ANIMATION_GUARD_MS). 0 = no animation guard active.
|
||||
const animationGuardUntilRef = React.useRef(0);
|
||||
|
||||
// True while the native (Capacitor iOS) keyboard slide choreography is in
|
||||
// flight (between 'oc:keyboard-anim' and 'oc:keyboard-settled' from
|
||||
// useNativeMobileChrome). During that window the pinned content is moved by a
|
||||
// transform on the inner wrapper, so the ResizeObserver chase must stand down.
|
||||
const keyboardAnimRef = React.useRef(false);
|
||||
|
||||
// Last observed scrollTop, used to derive scroll DIRECTION in the scroll
|
||||
// handler so the bottom-zone re-engage only fires when arriving at the bottom
|
||||
// by scrolling down — never when a user scrolling UP merely lands in the zone.
|
||||
const lastScrollTopRef = React.useRef(0);
|
||||
|
||||
// Entry-stick window state (see ENTRY_STICK_* above).
|
||||
const entryStickRef = React.useRef(false);
|
||||
const entryStickQuietTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const entryStickCapTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const entryStickLastHeightRef = React.useRef(0);
|
||||
|
||||
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||
// When restoreSnapshot is invoked while ChatViewport is still hydrating
|
||||
// (skeleton rendered, no scroll container yet), we record the session here
|
||||
// so a follow-up effect can replay the restore once the container mounts.
|
||||
const pendingInitialRestoreRef = React.useRef<string | null>(null);
|
||||
|
||||
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
|
||||
|
||||
// Detect when the scroll container DOM element changes (mount, unmount, remount).
|
||||
// Without this, listener-attach effects would only ever bind to the element that
|
||||
// existed at the hook's first render, missing later mounts (e.g. after first send
|
||||
// promotes a draft session to a real chat with messages).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
React.useLayoutEffect(() => {
|
||||
if (scrollRef.current !== lastSeenContainerRef.current) {
|
||||
lastSeenContainerRef.current = scrollRef.current;
|
||||
setContainerEl(scrollRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
// `active` is `working || settling`. Passive auto-follow
|
||||
// (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs
|
||||
// while active. When the session is idle, content-size changes are layout
|
||||
// churn — virtualizer re-measurement, async tool/code rendering — NOT live
|
||||
// growth, so we must NOT yank the user to the bottom. Forcing this gate is
|
||||
// what stops the twitch when tall items (expanded tools) re-measure as the
|
||||
// user scrolls.
|
||||
const isActive = React.useCallback((): boolean => {
|
||||
return sessionIsWorkingRef.current || settlingRef.current;
|
||||
}, []);
|
||||
|
||||
const setStateValue = React.useCallback((next: AutoFollowState) => {
|
||||
if (stateRef.current === next) return;
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
// ── auto marker ────────────────────────────────────────────────────────
|
||||
const markAuto = React.useCallback((el: HTMLElement) => {
|
||||
autoRef.current = {
|
||||
top: Math.max(0, el.scrollHeight - el.clientHeight),
|
||||
time: now(),
|
||||
};
|
||||
if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
|
||||
autoTimerRef.current = setTimeout(() => {
|
||||
autoRef.current = null;
|
||||
autoTimerRef.current = null;
|
||||
}, AUTO_MARK_TTL_MS);
|
||||
}, []);
|
||||
|
||||
const isAuto = React.useCallback((el: HTMLElement): boolean => {
|
||||
const a = autoRef.current;
|
||||
if (!a) return false;
|
||||
if (now() - a.time > AUTO_MARK_TTL_MS) {
|
||||
autoRef.current = null;
|
||||
return false;
|
||||
}
|
||||
return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
|
||||
}, []);
|
||||
|
||||
const isAnimationGuardActive = React.useCallback((): boolean => {
|
||||
return now() < animationGuardUntilRef.current;
|
||||
}, []);
|
||||
|
||||
// ── entry-stick window ───────────────────────────────────────────────────
|
||||
const endEntryStick = React.useCallback(() => {
|
||||
entryStickRef.current = false;
|
||||
if (entryStickQuietTimerRef.current) {
|
||||
clearTimeout(entryStickQuietTimerRef.current);
|
||||
entryStickQuietTimerRef.current = null;
|
||||
}
|
||||
if (entryStickCapTimerRef.current) {
|
||||
clearTimeout(entryStickCapTimerRef.current);
|
||||
entryStickCapTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// (Re)arm the quiescence timer: the window closes this long after the last
|
||||
// growth. Called once on begin and again on every growth-driven re-pin.
|
||||
const armEntryStickQuiet = React.useCallback(() => {
|
||||
if (entryStickQuietTimerRef.current) {
|
||||
clearTimeout(entryStickQuietTimerRef.current);
|
||||
}
|
||||
entryStickQuietTimerRef.current = setTimeout(() => {
|
||||
entryStickQuietTimerRef.current = null;
|
||||
endEntryStick();
|
||||
}, ENTRY_STICK_QUIESCENCE_MS);
|
||||
}, [endEntryStick]);
|
||||
|
||||
const beginEntryStick = React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
entryStickRef.current = true;
|
||||
entryStickLastHeightRef.current = el.scrollHeight;
|
||||
armEntryStickQuiet();
|
||||
// Reset the absolute cap fresh on every entry (e.g. session switch) so a
|
||||
// stale cap from a previous open can't cut this window short.
|
||||
if (entryStickCapTimerRef.current) {
|
||||
clearTimeout(entryStickCapTimerRef.current);
|
||||
}
|
||||
entryStickCapTimerRef.current = setTimeout(() => {
|
||||
entryStickCapTimerRef.current = null;
|
||||
endEntryStick();
|
||||
}, ENTRY_STICK_MAX_MS);
|
||||
}, [armEntryStickQuiet, endEntryStick]);
|
||||
|
||||
// ── overflow / scroll-to-bottom button ──────────────────────────────────
|
||||
const updateOverflowAndButton = React.useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
setIsOverflowing(false);
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
}
|
||||
const overflowing = canScroll(container);
|
||||
setIsOverflowing(overflowing);
|
||||
if (!overflowing) {
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
}
|
||||
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current);
|
||||
setShowScrollButton(showButton);
|
||||
}, []);
|
||||
|
||||
// ── core scroll primitives ───────────────────────────────────────────────
|
||||
const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
markAuto(el);
|
||||
// `scrollHeight` is rounded to an integer while the real content height
|
||||
// is fractional (prose line-heights), so `scrollTop = scrollHeight`
|
||||
// leaves a 0–1px remainder that oscillates per streamed token and makes
|
||||
// bottom-anchored rows jitter vertically. An over-large target clamps to
|
||||
// the exact fractional maximum instead, pinning content to the bottom.
|
||||
const overshootTarget = el.scrollHeight + 4096;
|
||||
if (behavior === 'smooth') {
|
||||
el.scrollTo({ top: overshootTarget, behavior });
|
||||
return;
|
||||
}
|
||||
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
|
||||
// and lands in the same frame — no visible catch-up animation.
|
||||
el.scrollTop = overshootTarget;
|
||||
}, [markAuto]);
|
||||
|
||||
// `force` true = user-intent jump (clears released and always scrolls).
|
||||
// `force` false = passive follow (only while still following).
|
||||
const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => {
|
||||
const el = scrollRef.current;
|
||||
|
||||
// Passive follow only while active (working/settling). Forced jumps
|
||||
// (send, go-to-bottom, session restore) always proceed.
|
||||
if (!force && !isActive()) return;
|
||||
|
||||
if (force && stateRef.current !== 'following') {
|
||||
setStateValue('following');
|
||||
}
|
||||
if (!el) return;
|
||||
if (!force && stateRef.current !== 'following') return;
|
||||
|
||||
// Always re-pin, even when already within tolerance of the bottom.
|
||||
// Sub-tolerance growth (fractional line-height remainders) would
|
||||
// otherwise leave the bottom drifting by up to ±AUTO_MATCH_TOLERANCE_PX
|
||||
// between full re-pins, which reads as 1px vertical jitter on
|
||||
// bottom-anchored rows during streaming. The write happens pre-paint
|
||||
// (ResizeObserver) and is a no-op when the position is unchanged.
|
||||
scrollToBottomNow(force ? behavior : 'auto');
|
||||
}, [isActive, scrollToBottomNow, setStateValue]);
|
||||
|
||||
// User left the bottom — release auto-follow.
|
||||
const stop = React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!canScroll(el)) {
|
||||
setStateValue('following');
|
||||
return;
|
||||
}
|
||||
if (stateRef.current === 'released') return;
|
||||
setStateValue('released');
|
||||
updateOverflowAndButton();
|
||||
}, [setStateValue, updateOverflowAndButton]);
|
||||
|
||||
// ── public scroll API (mapped onto the primitives) ───────────────────────
|
||||
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
||||
scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto');
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const scrollToBottomOnSend = React.useCallback(() => {
|
||||
// Single movement to the just-sent message. Force re-pins to the bottom
|
||||
// whether we were following or scrolled up; the content ResizeObserver
|
||||
// keeps us pinned as the optimistic message and its reply stream in.
|
||||
scrollToBottom(true);
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const releaseAutoFollow = React.useCallback(() => {
|
||||
setStateValue('released');
|
||||
updateOverflowAndButton();
|
||||
}, [setStateValue, updateOverflowAndButton]);
|
||||
|
||||
const releaseFromUserIntent = React.useCallback(() => {
|
||||
// A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry
|
||||
// window immediately so we never fight the user's read position.
|
||||
endEntryStick();
|
||||
stop();
|
||||
}, [endEntryStick, stop]);
|
||||
|
||||
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
|
||||
const flushSave = React.useCallback(() => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
const pending = pendingSaveRef.current;
|
||||
if (!pending) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
pendingSaveRef.current = null;
|
||||
return;
|
||||
}
|
||||
updateViewportAnchor(pending.sessionId, pending.anchor, {
|
||||
scrollTop: container.scrollTop,
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight,
|
||||
});
|
||||
pendingSaveRef.current = null;
|
||||
}, [updateViewportAnchor]);
|
||||
|
||||
const queueSave = React.useCallback(() => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (!sessionId) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const anchorRatio = scrollHeight > 0
|
||||
? (scrollTop + clientHeight / 2) / scrollHeight
|
||||
: 0;
|
||||
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
|
||||
|
||||
pendingSaveRef.current = { sessionId, anchor };
|
||||
if (saveTimerRef.current !== null) return;
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
flushSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}, [flushSave]);
|
||||
|
||||
const saveSnapshotNow = React.useCallback(() => {
|
||||
flushSave();
|
||||
}, [flushSave]);
|
||||
|
||||
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
|
||||
const sessionKey = currentSessionKeyRef.current;
|
||||
if (!sessionKey) return false;
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
// ChatViewport not mounted yet (e.g., session still hydrating).
|
||||
// Record the request so the container-attach effect can replay it.
|
||||
pendingInitialRestoreRef.current = sessionKey;
|
||||
setStateValue('following');
|
||||
return false;
|
||||
}
|
||||
pendingInitialRestoreRef.current = null;
|
||||
|
||||
// Always return to the bottom on session switch. The content
|
||||
// ResizeObserver re-pins instantly as late
|
||||
// history measures in, so there is no smooth scroll-from-mid artifact.
|
||||
setStateValue('following');
|
||||
scrollToBottom(true);
|
||||
// Hold the bottom across late async growth (e.g. task/subagent child
|
||||
// session data landing a beat after entry) until content quiesces or the
|
||||
// user scrolls.
|
||||
beginEntryStick();
|
||||
updateOverflowAndButton();
|
||||
return false;
|
||||
}, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
||||
|
||||
// ── session change ───────────────────────────────────────────────────────
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
lastSessionKeyRef.current = currentSessionKey;
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
flushSave();
|
||||
autoRef.current = null;
|
||||
// Drop any pending restore request inherited from a different session.
|
||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionKey) {
|
||||
pendingInitialRestoreRef.current = null;
|
||||
}
|
||||
}, [currentSessionId, currentSessionKey, flushSave]);
|
||||
|
||||
// When work begins and we are still
|
||||
// following, pin to the bottom. When work stops, keep following alive for a
|
||||
// short settle window so the final content lands at the bottom, then go
|
||||
// idle (after which passive follow is disabled — see `isActive`).
|
||||
React.useEffect(() => {
|
||||
settlingRef.current = false;
|
||||
if (settleTimerRef.current) {
|
||||
clearTimeout(settleTimerRef.current);
|
||||
settleTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (sessionIsWorking) {
|
||||
if (stateRef.current === 'following') {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
settlingRef.current = true;
|
||||
settleTimerRef.current = setTimeout(() => {
|
||||
settlingRef.current = false;
|
||||
settleTimerRef.current = null;
|
||||
}, SETTLE_MS);
|
||||
}, [sessionIsWorking, scrollToBottom]);
|
||||
|
||||
// Suppress the overlay scrollbar thumb only while we are actively following a
|
||||
// live stream (the thumb would otherwise jump on every instant re-pin). When
|
||||
// idle or released the scrollbar behaves normally. Stable: changes only when
|
||||
// follow-state or working-state flips, not on every frame.
|
||||
React.useEffect(() => {
|
||||
setIsFollowingProgrammatically(state === 'following' && sessionIsWorking);
|
||||
}, [state, sessionIsWorking]);
|
||||
|
||||
// Replay a deferred restoreSnapshot once ChatViewport mounts.
|
||||
// useLayoutEffect ensures scroll position is set before the browser paints,
|
||||
// preventing a visible flash of content at the wrong scroll position.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!containerEl) return;
|
||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionKey) {
|
||||
void restoreSnapshot();
|
||||
}
|
||||
}, [containerEl, currentSessionKey, restoreSnapshot]);
|
||||
|
||||
// ── scroll event handling ────────────────────────────────────────────────
|
||||
const handleScrollEvent = React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const previousTop = lastScrollTopRef.current;
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
const scrollingDown = el.scrollTop > previousTop + 0.5;
|
||||
|
||||
updateOverflowAndButton();
|
||||
|
||||
if (!canScroll(el)) {
|
||||
setStateValue('following');
|
||||
return;
|
||||
}
|
||||
|
||||
// Within the bottom zone → (re-)pin to following. This is how scrolling
|
||||
// back DOWN to the bottom resumes auto-follow. Crucially, re-engage only
|
||||
// when the user arrives by scrolling down (or is already following, or is
|
||||
// essentially at the true bottom). A user scrolling UP that merely lands
|
||||
// in the bottom spacer zone must NOT be yanked back into follow — that is
|
||||
// the dead-zone fight that made small upward scrolls impossible while
|
||||
// content streams.
|
||||
if (isNearBottom(el, isMobileRef.current)) {
|
||||
const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX;
|
||||
if (scrollingDown || stateRef.current === 'following' || atTrueBottom) {
|
||||
setStateValue('following');
|
||||
}
|
||||
queueSave();
|
||||
return;
|
||||
}
|
||||
|
||||
// Our own geometry change (a programmatic write that landed at the bottom
|
||||
// but where content grew between the write and this event, OR a tracked
|
||||
// height animation in flight) — keep following, don't release.
|
||||
if (stateRef.current === 'following' && (isAuto(el) || isAnimationGuardActive())) {
|
||||
scrollToBottom(false);
|
||||
queueSave();
|
||||
return;
|
||||
}
|
||||
|
||||
// Genuine user scroll away from the bottom.
|
||||
stop();
|
||||
queueSave();
|
||||
}, [isAnimationGuardActive, isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerEl;
|
||||
if (!container) return;
|
||||
|
||||
lastScrollTopRef.current = container.scrollTop;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY >= 0) return;
|
||||
if (nestedScrollableCanConsumeUp(container, event.target)) return;
|
||||
releaseFromUserIntent();
|
||||
};
|
||||
|
||||
let touchLastY: number | null = null;
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
const touch = event.touches.item(0);
|
||||
touchLastY = touch ? touch.clientY : null;
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
const touch = event.touches.item(0);
|
||||
if (!touch) {
|
||||
touchLastY = null;
|
||||
return;
|
||||
}
|
||||
const previousY = touchLastY;
|
||||
touchLastY = touch.clientY;
|
||||
if (previousY === null) return;
|
||||
const fingerDelta = touch.clientY - previousY;
|
||||
if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return;
|
||||
if (nestedScrollableCanConsumeUp(container, event.target)) return;
|
||||
releaseFromUserIntent();
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
touchLastY = null;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isReleaseKey(event)) return;
|
||||
releaseFromUserIntent();
|
||||
};
|
||||
|
||||
const handlePointerDownIntent = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (!target.closest('[data-overlay-scrollbar-thumb]')) return;
|
||||
releaseFromUserIntent();
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScrollEvent, { passive: true });
|
||||
container.addEventListener('wheel', handleWheel, { passive: true });
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pointerdown', handlePointerDownIntent, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScrollEvent);
|
||||
container.removeEventListener('wheel', handleWheel);
|
||||
container.removeEventListener('touchstart', handleTouchStart);
|
||||
container.removeEventListener('touchmove', handleTouchMove);
|
||||
container.removeEventListener('touchend', handleTouchEnd);
|
||||
container.removeEventListener('touchcancel', handleTouchEnd);
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('pointerdown', handlePointerDownIntent, true);
|
||||
}
|
||||
};
|
||||
}, [containerEl, handleScrollEvent, releaseFromUserIntent]);
|
||||
|
||||
// The heart of the follow behaviour: the content ResizeObserver fires after
|
||||
// layout and before paint, so re-pinning to the bottom here is invisible —
|
||||
// there is no "jump up then catch up". Observe both the container (composer
|
||||
// growth shrinks the viewport) and the inner content (streaming growth).
|
||||
React.useEffect(() => {
|
||||
const container = containerEl;
|
||||
if (!container || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
// Keyboard slide in flight: the container/composer resizes it reports
|
||||
// are part of the transform choreography — the settle handler does the
|
||||
// single deterministic re-pin, so chasing here would just fight it.
|
||||
if (keyboardAnimRef.current) {
|
||||
updateOverflowAndButton();
|
||||
return;
|
||||
}
|
||||
const el = scrollRef.current;
|
||||
if (el && !canScroll(el)) {
|
||||
setStateValue('following');
|
||||
updateOverflowAndButton();
|
||||
return;
|
||||
}
|
||||
updateOverflowAndButton();
|
||||
// Entry-stick window: on first session open, FORCE the bottom on
|
||||
// every growth so late async data (task/subagent child rows, code
|
||||
// highlight, mermaid) can't strand the viewport mid-history. Force
|
||||
// overrides any false `released` from the growth itself; only a real
|
||||
// user gesture clears the window (releaseFromUserIntent).
|
||||
if (entryStickRef.current && el) {
|
||||
const grew = el.scrollHeight > entryStickLastHeightRef.current + 1;
|
||||
entryStickLastHeightRef.current = el.scrollHeight;
|
||||
scrollToBottom(true);
|
||||
if (grew) armEntryStickQuiet();
|
||||
return;
|
||||
}
|
||||
// Idle resize = layout churn (virtualizer re-measurement, async
|
||||
// tool/code rendering), NOT live growth. Never re-pin when idle, or
|
||||
// tall items re-measuring as the user scrolls cause an endless
|
||||
// scroll-to-bottom/re-measure twitch.
|
||||
if (!isActive()) return;
|
||||
if (stateRef.current !== 'following') return;
|
||||
scrollToBottom(false);
|
||||
});
|
||||
observer.observe(container);
|
||||
const inner = container.firstElementChild;
|
||||
if (inner instanceof Element) {
|
||||
observer.observe(inner);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
||||
|
||||
// ── native keyboard transitions (Capacitor choreography) ────────────────
|
||||
// The chat scroller gets NO transforms during the keyboard transition:
|
||||
// transforming the scroll container (or its content) forces WebKit to
|
||||
// rebuild the composited scrolling layers, which stalls for seconds on
|
||||
// long chats. Instead the chat repositions with instant snaps that hide
|
||||
// behind the keyboard itself:
|
||||
// show: content stays put while the keyboard/composer slide over it; the
|
||||
// settled event (shell layout snap) does ONE instant re-pin.
|
||||
// hide: the shell layout is restored up-front — the scrollTop clamp
|
||||
// happens while the keyboard still covers that region — and the
|
||||
// settled event re-pins once at the end.
|
||||
// During the window we only guard the scroll heuristics and the observer
|
||||
// chase. These events never fire outside the Capacitor app.
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleKeyboardAnim = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail;
|
||||
if (!detail) return;
|
||||
keyboardAnimRef.current = true;
|
||||
// The clamp/resize during the choreography can dispatch scroll events
|
||||
// that land away from the auto marker — never read those as a user
|
||||
// scroll-away.
|
||||
animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS;
|
||||
};
|
||||
|
||||
const handleKeyboardSettled = () => {
|
||||
keyboardAnimRef.current = false;
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
updateOverflowAndButton();
|
||||
return;
|
||||
}
|
||||
// Single deterministic re-pin, same task as the layout swap → lands
|
||||
// before paint. (scrollToBottomNow, not scrollToBottom: this must not
|
||||
// be gated on working/settling — the keyboard resize is a viewport
|
||||
// change, not content growth.)
|
||||
if (stateRef.current === 'following' && canScroll(el)) {
|
||||
scrollToBottomNow('auto');
|
||||
}
|
||||
updateOverflowAndButton();
|
||||
};
|
||||
|
||||
window.addEventListener('oc:keyboard-anim', handleKeyboardAnim);
|
||||
window.addEventListener('oc:keyboard-settled', handleKeyboardSettled);
|
||||
return () => {
|
||||
window.removeEventListener('oc:keyboard-anim', handleKeyboardAnim);
|
||||
window.removeEventListener('oc:keyboard-settled', handleKeyboardSettled);
|
||||
keyboardAnimRef.current = false;
|
||||
};
|
||||
}, [scrollToBottomNow, updateOverflowAndButton]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateOverflowAndButton();
|
||||
}, [sessionMessageCount, updateOverflowAndButton]);
|
||||
|
||||
const notifyContentChange = React.useCallback((reason?: ContentChangeReason) => {
|
||||
// A tracked height animation (e.g. Thinking auto-collapse) opens a guard
|
||||
// window so its transient geometry / async scroll events are not misread
|
||||
// as a user scroll-away. Real gestures still release through
|
||||
// releaseFromUserIntent, so the user can always scroll up freely.
|
||||
if (reason === 'animation') {
|
||||
animationGuardUntilRef.current = now() + ANIMATION_GUARD_MS;
|
||||
}
|
||||
updateOverflowAndButton();
|
||||
// Entry-stick window: late structural growth (notably the task/subagent
|
||||
// summary landing from the child session — ToolPart emits 'structural'
|
||||
// here) must keep us pinned and refresh the quiescence timer, even though
|
||||
// the session is idle.
|
||||
if (entryStickRef.current) {
|
||||
scrollToBottom(true);
|
||||
armEntryStickQuiet();
|
||||
return;
|
||||
}
|
||||
if (stateRef.current === 'following') {
|
||||
scrollToBottom(false);
|
||||
}
|
||||
}, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]);
|
||||
|
||||
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
||||
|
||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
||||
const cached = animationHandlersRef.current.get(messageId);
|
||||
if (cached) return cached;
|
||||
|
||||
const kick = () => {
|
||||
if (stateRef.current === 'following') {
|
||||
scrollToBottom(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlers: AnimationHandlers = {
|
||||
onChunk: kick,
|
||||
onComplete: () => {
|
||||
updateOverflowAndButton();
|
||||
},
|
||||
onStreamingCandidate: () => {},
|
||||
onAnimationStart: () => {},
|
||||
onAnimatedHeightChange: kick,
|
||||
onReservationCancelled: () => {},
|
||||
onReasoningBlock: () => {},
|
||||
};
|
||||
animationHandlersRef.current.set(messageId, handlers);
|
||||
return handlers;
|
||||
}, [scrollToBottom, updateOverflowAndButton]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (autoTimerRef.current) {
|
||||
clearTimeout(autoTimerRef.current);
|
||||
autoTimerRef.current = null;
|
||||
}
|
||||
if (settleTimerRef.current) {
|
||||
clearTimeout(settleTimerRef.current);
|
||||
settleTimerRef.current = null;
|
||||
}
|
||||
endEntryStick();
|
||||
flushSave();
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [endEntryStick, flushSave]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onActiveTurnChange) return;
|
||||
const container = containerEl;
|
||||
if (!container) return;
|
||||
|
||||
let lastActiveTurnId: string | null = null;
|
||||
const spy = createScrollSpy({
|
||||
onActive: (turnId) => {
|
||||
if (turnId === lastActiveTurnId) return;
|
||||
lastActiveTurnId = turnId;
|
||||
onActiveTurnChange(turnId);
|
||||
},
|
||||
});
|
||||
spy.setContainer(container);
|
||||
|
||||
const elementByTurnId = new Map<string, HTMLElement>();
|
||||
const registerTurnNode = (node: HTMLElement) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) return false;
|
||||
elementByTurnId.set(turnId, node);
|
||||
spy.register(node, turnId);
|
||||
return true;
|
||||
};
|
||||
const unregisterTurnNode = (node: HTMLElement) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) return false;
|
||||
if (elementByTurnId.get(turnId) !== node) return false;
|
||||
elementByTurnId.delete(turnId);
|
||||
spy.unregister(turnId);
|
||||
return true;
|
||||
};
|
||||
const collectTurnNodes = (node: Node): HTMLElement[] => {
|
||||
if (!(node instanceof HTMLElement)) return [];
|
||||
const collected: HTMLElement[] = [];
|
||||
if (node.matches('[data-turn-id]')) collected.push(node);
|
||||
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
|
||||
return collected;
|
||||
};
|
||||
|
||||
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
|
||||
spy.markDirty();
|
||||
|
||||
const mutationObserver = new MutationObserver((records) => {
|
||||
let changed = false;
|
||||
records.forEach((record) => {
|
||||
record.removedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (unregisterTurnNode(turnNode)) changed = true;
|
||||
});
|
||||
});
|
||||
record.addedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (registerTurnNode(turnNode)) changed = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
if (changed) spy.markDirty();
|
||||
});
|
||||
mutationObserver.observe(container, { subtree: true, childList: true });
|
||||
|
||||
const onScroll = () => spy.onScroll();
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
mutationObserver.disconnect();
|
||||
spy.destroy();
|
||||
};
|
||||
}, [containerEl, onActiveTurnChange]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
state,
|
||||
isPinned: state === 'following',
|
||||
isOverflowing,
|
||||
isFollowingProgrammatically,
|
||||
showScrollButton,
|
||||
notifyContentChange,
|
||||
getAnimationHandlers,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
releaseAutoFollow,
|
||||
saveSnapshotNow,
|
||||
restoreSnapshot,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,867 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import {
|
||||
CHAT_LIST_ANCHOR_OFFSET,
|
||||
getAnchoredTurnMetrics,
|
||||
resolveTimelineIsAtEnd,
|
||||
type TimelineListMeasurementState,
|
||||
type TimelineScrollMode,
|
||||
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Chat timeline scroll ownership.
|
||||
//
|
||||
// The virtualized list owns the scroll position; this hook only decides which
|
||||
// of three mutually exclusive modes is active and, when a mode calls for it,
|
||||
// issues ONE deterministic scroll command:
|
||||
//
|
||||
// • `following-end` — pinned to the live edge. The list keeps us there
|
||||
// through `maintainScrollAtEnd`; we only re-assert after a data change.
|
||||
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
|
||||
// of the viewport and the reply streams into the reserved end space below
|
||||
// it. The viewport does NOT move while the turn still fits; once the turn
|
||||
// outgrows the usable viewport we scroll by the exact delta needed to keep
|
||||
// its end visible.
|
||||
// • `free-scrolling` — the user took over. Nothing moves until they opt
|
||||
// back in by returning to the end.
|
||||
//
|
||||
// Opting out of automatic movement is driven by REAL gestures (wheel /
|
||||
// touchmove / pointerdown), not by inferring intent from scroll positions. Each
|
||||
// gesture bumps a generation counter; any in-flight automatic movement compares
|
||||
// its captured generation against the current one and aborts if they differ.
|
||||
// That comparison replaces the timer windows the previous implementation needed
|
||||
// to tell its own writes apart from the user's, which is why there are no
|
||||
// guard/settle/entry-stick timers here.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The subset of the list ref this hook drives. Declared structurally so the
|
||||
// hook stays testable without a renderer and does not hard-depend on the list
|
||||
// implementation.
|
||||
export interface TimelineListHandle {
|
||||
getState: () => TimelineListMeasurementState & {
|
||||
readonly scroll: number;
|
||||
readonly listen?: (
|
||||
listenerType: 'totalSize',
|
||||
callback: (value: number) => void,
|
||||
) => () => void;
|
||||
};
|
||||
getScrollableNode: () => HTMLElement | null;
|
||||
scrollToEnd: (options?: { animated?: boolean }) => unknown;
|
||||
scrollToOffset: (params: { offset: number; animated?: boolean }) => unknown;
|
||||
scrollToIndex: (params: {
|
||||
index: number;
|
||||
animated?: boolean;
|
||||
viewPosition?: number;
|
||||
viewOffset?: number;
|
||||
}) => unknown;
|
||||
}
|
||||
|
||||
interface UseChatTimelineScrollOptions {
|
||||
currentSessionId: string | null;
|
||||
currentSessionKey: string | null;
|
||||
sessionMessageCount: number;
|
||||
composerOverlayHeight: number;
|
||||
// Id of the newest user message in the rendered timeline. When a send has
|
||||
// armed the anchor, the next new id here becomes the anchored row.
|
||||
lastUserMessageId: string | null;
|
||||
onActiveTurnChange?: (turnId: string | null) => void;
|
||||
}
|
||||
|
||||
export interface UseChatTimelineScrollResult {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
// The live scroll element, as state, so effects that must re-bind when the
|
||||
// list remounts (session switch) can depend on it.
|
||||
scrollNode: HTMLDivElement | null;
|
||||
isPinned: boolean;
|
||||
registerList: (list: TimelineListHandle | null) => void;
|
||||
anchorMessageId: string | null;
|
||||
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged: (messageId: string) => void;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onManualNavigation: () => void;
|
||||
onTimelineDataChange: () => void;
|
||||
showScrollButton: boolean;
|
||||
/** A real gesture took the scroll; flips back on any explicit opt-in. */
|
||||
userOwnsScroll: boolean;
|
||||
isFollowingProgrammatically: boolean;
|
||||
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
||||
scrollToBottomOnSend: () => void;
|
||||
saveSnapshotNow: () => void;
|
||||
restoreSnapshot: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
// Showing the pill is debounced so it does not flash while a thread switch
|
||||
// settles (the list reports isAtEnd=false until its initial end-scroll lands).
|
||||
// Hiding is always immediate.
|
||||
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
|
||||
const SAVE_DEBOUNCE_MS = 150;
|
||||
// The anchor scroll is animated; `scrollend` is the authoritative completion
|
||||
// signal, and this bounds the wait for browsers that drop it.
|
||||
const ANCHOR_SETTLE_FALLBACK_MS = 750;
|
||||
// Re-running the anchor positioning while the list is still mounting rows.
|
||||
const ANCHOR_POSITION_ATTEMPTS = 12;
|
||||
// Anchor restores only correct sub-pixel drift; anything larger is the user or
|
||||
// a genuine relayout and must not be undone.
|
||||
const ANCHOR_RESTORE_TOLERANCE_PX = 2;
|
||||
|
||||
export const useChatTimelineScroll = ({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
composerOverlayHeight,
|
||||
lastUserMessageId,
|
||||
onActiveTurnChange,
|
||||
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const listRef = React.useRef<TimelineListHandle | null>(null);
|
||||
|
||||
const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null);
|
||||
const [anchorMessageId, setAnchorMessageId] = React.useState<string | null>(null);
|
||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||
// "Pinned" is the live edge, which history pagination uses to decide whether
|
||||
// it may load older pages without disturbing the read position.
|
||||
const [isPinned, setIsPinned] = React.useState(true);
|
||||
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
||||
// True after a real gesture until an explicit opt back in; drives the
|
||||
// overlay scrollbar suppression instead of the anchor's mere existence.
|
||||
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
|
||||
|
||||
const modeRef = React.useRef<TimelineScrollMode>('following-end');
|
||||
const isAtEndRef = React.useRef(true);
|
||||
// Incremented by every real user gesture. Automatic movement is only valid
|
||||
// while `liveFollowGenerationRef` still equals it.
|
||||
const userGenerationRef = React.useRef(0);
|
||||
const liveFollowGenerationRef = React.useRef<number | null>(0);
|
||||
// Anchor lifecycle: armed on send → pending until the row exists → positioned
|
||||
// while the animated scroll runs → settled once it has come to rest.
|
||||
const armedForNextUserMessageRef = React.useRef(false);
|
||||
const pendingAnchorRef = React.useRef<string | null>(null);
|
||||
const positionedAnchorRef = React.useRef<string | null>(null);
|
||||
const settledAnchorRef = React.useRef<string | null>(null);
|
||||
const activeAnchorIndexRef = React.useRef<number | null>(null);
|
||||
const pendingAnchorRestoreRef = React.useRef<{
|
||||
readonly messageId: string;
|
||||
readonly offset: number;
|
||||
readonly userGeneration: number;
|
||||
} | null>(null);
|
||||
const anchorRestoreFrameRef = React.useRef<number | null>(null);
|
||||
const showButtonTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
|
||||
composerOverlayHeightRef.current = composerOverlayHeight;
|
||||
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
||||
sessionMessageCountRef.current = sessionMessageCount;
|
||||
const currentSessionIdRef = React.useRef(currentSessionId);
|
||||
currentSessionIdRef.current = currentSessionId;
|
||||
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
||||
currentSessionKeyRef.current = currentSessionKey;
|
||||
|
||||
const updateViewportAnchor = useViewportStore((state) => state.updateViewportAnchor);
|
||||
|
||||
const cancelShowButtonTimer = React.useCallback(() => {
|
||||
if (showButtonTimerRef.current !== null) {
|
||||
clearTimeout(showButtonTimerRef.current);
|
||||
showButtonTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hideScrollButton = React.useCallback(() => {
|
||||
cancelShowButtonTimer();
|
||||
setShowScrollButton(false);
|
||||
}, [cancelShowButtonTimer]);
|
||||
|
||||
const scheduleShowScrollButton = React.useCallback(() => {
|
||||
if (showButtonTimerRef.current !== null) return;
|
||||
showButtonTimerRef.current = setTimeout(() => {
|
||||
showButtonTimerRef.current = null;
|
||||
setShowScrollButton(true);
|
||||
}, SHOW_SCROLL_BUTTON_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
const clearAnchor = React.useCallback(() => {
|
||||
armedForNextUserMessageRef.current = false;
|
||||
pendingAnchorRef.current = null;
|
||||
positionedAnchorRef.current = null;
|
||||
settledAnchorRef.current = null;
|
||||
activeAnchorIndexRef.current = null;
|
||||
pendingAnchorRestoreRef.current = null;
|
||||
if (anchorRestoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||
anchorRestoreFrameRef.current = null;
|
||||
}
|
||||
setAnchorMessageId(null);
|
||||
}, []);
|
||||
|
||||
// A real gesture: stop every automatic movement until the user opts back
|
||||
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
|
||||
// viewport back to the end — only the anchor machinery is disarmed.
|
||||
const onManualNavigation = React.useCallback(() => {
|
||||
userGenerationRef.current += 1;
|
||||
modeRef.current = 'free-scrolling';
|
||||
liveFollowGenerationRef.current = null;
|
||||
setUserOwnsScroll(true);
|
||||
// The end may already have been left by our own movement, in which
|
||||
// case no further at-end transition will fire — and while an animated
|
||||
// follow glide trails the live edge, isAtEndRef is deliberately not
|
||||
// updated, so measure the real distance instead of trusting it. This
|
||||
// is an explicit gesture — show the pill immediately, no debounce.
|
||||
const listState = listRef.current?.getState();
|
||||
const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current;
|
||||
isAtEndRef.current = atEndNow;
|
||||
if (!atEndNow) {
|
||||
cancelShowButtonTimer();
|
||||
setShowScrollButton(true);
|
||||
}
|
||||
armedForNextUserMessageRef.current = false;
|
||||
pendingAnchorRef.current = null;
|
||||
positionedAnchorRef.current = null;
|
||||
settledAnchorRef.current = null;
|
||||
activeAnchorIndexRef.current = null;
|
||||
pendingAnchorRestoreRef.current = null;
|
||||
if (anchorRestoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||
anchorRestoreFrameRef.current = null;
|
||||
}
|
||||
}, [cancelShowButtonTimer]);
|
||||
|
||||
const isLiveFollowActive = React.useCallback(() => (
|
||||
liveFollowGenerationRef.current === userGenerationRef.current
|
||||
), []);
|
||||
|
||||
// ── snapshot persistence ────────────────────────────────────────────────
|
||||
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const flushSave = React.useCallback(() => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
const pending = pendingSaveRef.current;
|
||||
if (!pending) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
pendingSaveRef.current = null;
|
||||
return;
|
||||
}
|
||||
updateViewportAnchor(pending.sessionId, pending.anchor, {
|
||||
scrollTop: container.scrollTop,
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight,
|
||||
});
|
||||
pendingSaveRef.current = null;
|
||||
}, [updateViewportAnchor]);
|
||||
|
||||
const queueSave = React.useCallback(() => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (!sessionId) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const anchorRatio = scrollHeight > 0
|
||||
? (scrollTop + clientHeight / 2) / scrollHeight
|
||||
: 0;
|
||||
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
|
||||
|
||||
pendingSaveRef.current = { sessionId, anchor };
|
||||
if (saveTimerRef.current !== null) return;
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
flushSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}, [flushSave]);
|
||||
|
||||
const saveSnapshotNow = React.useCallback(() => {
|
||||
flushSave();
|
||||
}, [flushSave]);
|
||||
|
||||
// ── scroll commands ─────────────────────────────────────────────────────
|
||||
const goToBottomReassertTimersRef = React.useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const clearGoToBottomReasserts = React.useCallback(() => {
|
||||
for (const timer of goToBottomReassertTimersRef.current) clearTimeout(timer);
|
||||
goToBottomReassertTimersRef.current = [];
|
||||
}, []);
|
||||
|
||||
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
||||
isAtEndRef.current = true;
|
||||
setIsPinned(true);
|
||||
setUserOwnsScroll(false);
|
||||
modeRef.current = 'following-end';
|
||||
// Returning to the end is an explicit opt back IN to live follow.
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
clearAnchor();
|
||||
hideScrollButton();
|
||||
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
|
||||
// While a stream is growing the content, a single jump lands on the
|
||||
// end as of that moment and the list's own follow may not have
|
||||
// re-armed yet — re-assert a few times until the edge holds, then the
|
||||
// library follows onward. A new user gesture invalidates the window.
|
||||
clearGoToBottomReasserts();
|
||||
const generation = userGenerationRef.current;
|
||||
for (const delay of [150, 400, 800]) {
|
||||
goToBottomReassertTimersRef.current.push(setTimeout(() => {
|
||||
if (userGenerationRef.current !== generation) return;
|
||||
if (modeRef.current !== 'following-end') return;
|
||||
const state = listRef.current?.getState();
|
||||
if (state && resolveTimelineIsAtEnd(state) === true) return;
|
||||
void listRef.current?.scrollToEnd({ animated: false });
|
||||
}, delay));
|
||||
}
|
||||
}, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]);
|
||||
|
||||
// Sending arms the anchor. The message id is not known here (the optimistic
|
||||
// row is created by the store), so the next new user message id claims it.
|
||||
// Whether the send-time anchor positioning may animate. Sending from the
|
||||
// live edge parks the new message with a short smooth scroll; sending
|
||||
// from mid-history teleports — a long smooth scroll through the
|
||||
// virtualized timeline gets cancelled by rows mounting and measuring
|
||||
// along the way and dies partway there.
|
||||
const anchorPositionInstantRef = React.useRef(false);
|
||||
|
||||
const scrollToBottomOnSend = React.useCallback(() => {
|
||||
anchorPositionInstantRef.current = !isAtEndRef.current;
|
||||
isAtEndRef.current = true;
|
||||
setUserOwnsScroll(false);
|
||||
modeRef.current = 'anchoring-new-turn';
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
armedForNextUserMessageRef.current = true;
|
||||
// The optimistic row is not committed yet; the next NEW user message id
|
||||
// relative to this baseline claims the anchor, independent of whether
|
||||
// the commit lands before or after this call.
|
||||
armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current;
|
||||
pendingAnchorRef.current = null;
|
||||
positionedAnchorRef.current = null;
|
||||
settledAnchorRef.current = null;
|
||||
activeAnchorIndexRef.current = null;
|
||||
hideScrollButton();
|
||||
}, [hideScrollButton]);
|
||||
|
||||
// Claim the anchor as soon as the sent row exists in the timeline. The
|
||||
// comparison is against the baseline captured when the send armed the
|
||||
// anchor, so the claim works whether the optimistic row committed before
|
||||
// or after the arming call.
|
||||
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
||||
const armBaselineUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
||||
React.useEffect(() => {
|
||||
lastArmedUserMessageIdRef.current = lastUserMessageId;
|
||||
if (!armedForNextUserMessageRef.current) return;
|
||||
if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return;
|
||||
armedForNextUserMessageRef.current = false;
|
||||
pendingAnchorRef.current = lastUserMessageId;
|
||||
setAnchorMessageId(lastUserMessageId);
|
||||
}, [lastUserMessageId]);
|
||||
|
||||
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
|
||||
const sessionKey = currentSessionKeyRef.current;
|
||||
if (!sessionKey) return false;
|
||||
|
||||
// Entering a session always returns to the live edge. Late async growth
|
||||
// is handled by the list staying at the end, not by a timed hold.
|
||||
isAtEndRef.current = true;
|
||||
setUserOwnsScroll(false);
|
||||
modeRef.current = 'following-end';
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
clearAnchor();
|
||||
hideScrollButton();
|
||||
void listRef.current?.scrollToEnd({ animated: false });
|
||||
return false;
|
||||
}, [clearAnchor, hideScrollButton]);
|
||||
|
||||
// ── list callbacks ──────────────────────────────────────────────────────
|
||||
const registerList = React.useCallback((list: TimelineListHandle | null) => {
|
||||
listRef.current = list;
|
||||
const node = (list?.getScrollableNode() as HTMLDivElement | null) ?? null;
|
||||
scrollRef.current = node;
|
||||
setScrollNode(node);
|
||||
}, []);
|
||||
|
||||
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
|
||||
// While an automatic movement owns the viewport, leaving the end is our
|
||||
// own doing (the anchored turn parks mid-timeline, the glide trails its
|
||||
// target between corrections) — not a reason to offer the pill. Only a
|
||||
// real gesture (free-scrolling) shows it.
|
||||
if (!isAtEnd && isLiveFollowActive()) {
|
||||
hideScrollButton();
|
||||
return;
|
||||
}
|
||||
if (isAtEndRef.current === isAtEnd) return;
|
||||
isAtEndRef.current = isAtEnd;
|
||||
setIsPinned(isAtEnd);
|
||||
if (isAtEnd) {
|
||||
if (modeRef.current !== 'anchoring-new-turn') {
|
||||
modeRef.current = 'following-end';
|
||||
}
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
setUserOwnsScroll(false);
|
||||
hideScrollButton();
|
||||
} else {
|
||||
modeRef.current = 'free-scrolling';
|
||||
liveFollowGenerationRef.current = null;
|
||||
scheduleShowScrollButton();
|
||||
}
|
||||
queueSave();
|
||||
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
|
||||
|
||||
// Park the anchored row near the top once the list has measured it.
|
||||
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
|
||||
// The anchored end space can be remeasured long after the send (turn
|
||||
// completion, images decoding). Only the send-time anchoring mode may
|
||||
// position the viewport.
|
||||
if (modeRef.current !== 'anchoring-new-turn') return;
|
||||
if (pendingAnchorRef.current === messageId) {
|
||||
pendingAnchorRef.current = null;
|
||||
}
|
||||
activeAnchorIndexRef.current = anchorIndex;
|
||||
if (positionedAnchorRef.current === messageId) return;
|
||||
positionedAnchorRef.current = messageId;
|
||||
settledAnchorRef.current = null;
|
||||
|
||||
const positionAnchor = (remainingAttempts: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
if (positionedAnchorRef.current !== messageId) return;
|
||||
const list = listRef.current;
|
||||
if (!list) {
|
||||
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
|
||||
return;
|
||||
}
|
||||
const scrollNode = list.getScrollableNode();
|
||||
if (!scrollNode) {
|
||||
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
const finishPositioning = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(fallbackTimer);
|
||||
scrollNode.removeEventListener('scrollend', finishPositioning);
|
||||
if (positionedAnchorRef.current !== messageId) return;
|
||||
// Re-assert the resting offset without animation so the
|
||||
// smooth scroll's own momentum cannot drift past it.
|
||||
const scrollOffset = list.getState().scroll;
|
||||
void list.scrollToOffset({ offset: scrollOffset, animated: false });
|
||||
settledAnchorRef.current = messageId;
|
||||
};
|
||||
const fallbackTimer = setTimeout(finishPositioning, ANCHOR_SETTLE_FALLBACK_MS);
|
||||
scrollNode.addEventListener('scrollend', finishPositioning, { once: true });
|
||||
|
||||
void list.scrollToIndex({
|
||||
index: anchorIndex,
|
||||
animated: !anchorPositionInstantRef.current,
|
||||
viewPosition: 0,
|
||||
viewOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => positionAnchor(ANCHOR_POSITION_ATTEMPTS));
|
||||
}, []);
|
||||
|
||||
// The anchored row can still change height after it settles (an image
|
||||
// decoding, a code block highlighting). Hold the resting offset, but only
|
||||
// against sub-pixel drift and only while the user has not taken over.
|
||||
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
|
||||
if (settledAnchorRef.current !== messageId) return;
|
||||
if (!isLiveFollowActive()) return;
|
||||
const scrollOffset = listRef.current?.getState().scroll;
|
||||
if (scrollOffset === undefined) return;
|
||||
|
||||
if (pendingAnchorRestoreRef.current === null) {
|
||||
pendingAnchorRestoreRef.current = {
|
||||
messageId,
|
||||
offset: scrollOffset,
|
||||
userGeneration: userGenerationRef.current,
|
||||
};
|
||||
}
|
||||
if (anchorRestoreFrameRef.current !== null) return;
|
||||
|
||||
anchorRestoreFrameRef.current = requestAnimationFrame(() => {
|
||||
anchorRestoreFrameRef.current = null;
|
||||
const pending = pendingAnchorRestoreRef.current;
|
||||
pendingAnchorRestoreRef.current = null;
|
||||
if (
|
||||
!pending
|
||||
|| settledAnchorRef.current !== pending.messageId
|
||||
|| pending.userGeneration !== userGenerationRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const list = listRef.current;
|
||||
const currentOffset = list?.getState().scroll;
|
||||
if (
|
||||
typeof currentOffset === 'number'
|
||||
&& Math.abs(currentOffset - pending.offset) <= ANCHOR_RESTORE_TOLERANCE_PX
|
||||
) {
|
||||
void list?.scrollToOffset({ offset: pending.offset, animated: false });
|
||||
}
|
||||
});
|
||||
}, [isLiveFollowActive]);
|
||||
|
||||
// Whether the real rows (ignoring any reserved anchored end space) are tall
|
||||
// enough to scroll. Without this, entering a short session would scroll into
|
||||
// the reserved space and strand the content above the viewport.
|
||||
const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => {
|
||||
const state = list.getState();
|
||||
if (state.data.length === 0) return false;
|
||||
|
||||
const lastIndex = state.data.length - 1;
|
||||
const lastTop = state.positionAtIndex(lastIndex);
|
||||
const lastHeight = state.sizeAtIndex(lastIndex);
|
||||
if (
|
||||
typeof lastTop !== 'number'
|
||||
|| typeof lastHeight !== 'number'
|
||||
|| !Number.isFinite(lastTop)
|
||||
|| !Number.isFinite(lastHeight)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const realContentBottom = lastTop + Math.max(1, lastHeight);
|
||||
const visibleScrollLength = Math.max(
|
||||
0,
|
||||
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
|
||||
);
|
||||
return realContentBottom > visibleScrollLength;
|
||||
}, []);
|
||||
|
||||
// One deterministic correction per data change, two frames out so the list
|
||||
// has measured the new rows. Nothing runs while the user owns the scroll.
|
||||
const dataChangeFramesRef = React.useRef<{ first: number | null; second: number | null }>({
|
||||
first: null,
|
||||
second: null,
|
||||
});
|
||||
// User preference: with auto-follow off, streaming growth never moves the
|
||||
// viewport — the anchored user message still parks at the top on send, but
|
||||
// no glide or end-follow correction runs afterwards.
|
||||
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
|
||||
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
|
||||
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
|
||||
|
||||
// While the list width is resizing, every pinning write fights the
|
||||
// per-frame row re-measure and the pinned viewport shakes. Corrections
|
||||
// stand down for the whole resize and the visible content is held by the
|
||||
// list's size compensation instead. Deliberately NO snap back to the end
|
||||
// afterwards: a slow drag settles repeatedly, and each snap reads as the
|
||||
// very jump this suspension removes — geometry changed, staying where the
|
||||
// reader is beats re-asserting the edge.
|
||||
const widthResizingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
|
||||
let lastWidth: number | null = null;
|
||||
let quietTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const observer = new ResizeObserver((observerEntries) => {
|
||||
const width = observerEntries[observerEntries.length - 1]?.contentRect.width;
|
||||
if (typeof width !== 'number') return;
|
||||
if (lastWidth === null) {
|
||||
lastWidth = width;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(width - lastWidth) < 1) return;
|
||||
lastWidth = width;
|
||||
widthResizingRef.current = true;
|
||||
if (quietTimer !== null) clearTimeout(quietTimer);
|
||||
quietTimer = setTimeout(() => {
|
||||
quietTimer = null;
|
||||
widthResizingRef.current = false;
|
||||
}, 350);
|
||||
});
|
||||
observer.observe(scrollNode);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (quietTimer !== null) clearTimeout(quietTimer);
|
||||
};
|
||||
}, [scrollNode]);
|
||||
|
||||
const onTimelineDataChange = React.useCallback(() => {
|
||||
if (widthResizingRef.current) return;
|
||||
if (!streamingAutoFollowEnabledRef.current) return;
|
||||
if (!isLiveFollowActive()) return;
|
||||
|
||||
// Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content
|
||||
// growth on its own — including a tail row growing in place — and
|
||||
// releases when the user scrolls away. Following the end therefore
|
||||
// needs no correction here; this handler only serves the
|
||||
// anchored-turn glide below.
|
||||
if (modeRef.current === 'following-end') return;
|
||||
|
||||
const frames = dataChangeFramesRef.current;
|
||||
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
||||
if (frames.second !== null) cancelAnimationFrame(frames.second);
|
||||
|
||||
frames.first = requestAnimationFrame(() => {
|
||||
frames.first = null;
|
||||
frames.second = requestAnimationFrame(() => {
|
||||
frames.second = null;
|
||||
if (!isLiveFollowActive()) return;
|
||||
// An anchor that exists but has not come to rest yet owns the
|
||||
// viewport; correcting now would fight its animation.
|
||||
if (pendingAnchorRef.current !== null) return;
|
||||
if (
|
||||
positionedAnchorRef.current !== null
|
||||
&& settledAnchorRef.current !== positionedAnchorRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
|
||||
if (modeRef.current === 'anchoring-new-turn') {
|
||||
const anchorIndex = activeAnchorIndexRef.current;
|
||||
if (anchorIndex === null) return;
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state: list.getState(),
|
||||
anchorIndex,
|
||||
composerOverlayHeight: composerOverlayHeightRef.current,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
// The turn still fits: leave the viewport exactly where the
|
||||
// user is reading.
|
||||
if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) return;
|
||||
// Animated: successive corrections restart the smooth scroll
|
||||
// from the current position, so streaming reads as one
|
||||
// continuous glide instead of a per-line hop. A real user
|
||||
// gesture interrupts the native smooth scroll on its own.
|
||||
void list.scrollToOffset({
|
||||
offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd,
|
||||
animated: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
}, [isLiveFollowActive]);
|
||||
|
||||
// The streaming tail grows inside one row without changing the entries
|
||||
// array, so data-change callbacks are silent for the entire stream. The
|
||||
// list's total content size is the authoritative growth signal; every
|
||||
// change re-runs the same guarded correction.
|
||||
const onTimelineDataChangeRef = React.useRef(onTimelineDataChange);
|
||||
onTimelineDataChangeRef.current = onTimelineDataChange;
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode) return;
|
||||
const listen = listRef.current?.getState().listen;
|
||||
if (!listen) return;
|
||||
const unsubscribe = listen('totalSize', () => {
|
||||
onTimelineDataChangeRef.current();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [scrollNode]);
|
||||
|
||||
// ── gesture opt-out ─────────────────────────────────────────────────────
|
||||
const onManualNavigationRef = React.useRef(onManualNavigation);
|
||||
onManualNavigationRef.current = onManualNavigation;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode) return;
|
||||
|
||||
// A gesture is meaningful when the viewport can move up AT ALL:
|
||||
// either the real rows overflow the viewport, or there is scrolled
|
||||
// history above (an anchored turn parks mid-conversation with
|
||||
// reserved space below — the real rows may not overflow yet, but
|
||||
// wheel-up is still a genuine opt-out; swallowing it left live-follow
|
||||
// armed, which suppressed the pill and kept corrections armed under a
|
||||
// viewport the user had taken).
|
||||
const canScrollUp = () => {
|
||||
const list = listRef.current;
|
||||
if (!list) return false;
|
||||
if (list.getState().scroll > 1) return true;
|
||||
return realContentOverflowsViewport(list);
|
||||
};
|
||||
const gesture = () => {
|
||||
onManualNavigationRef.current();
|
||||
};
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// Scrolling toward the end is not opting out of follow.
|
||||
if (event.deltaY < 0 && canScrollUp()) gesture();
|
||||
};
|
||||
// Touch mirrors wheel by finger direction, not by having already left
|
||||
// the end: while a stream keeps re-pinning the viewport, waiting for
|
||||
// an at-end transition means the drag never registers — the user
|
||||
// cannot scroll, the pill never appears, and live-follow stays armed
|
||||
// under a viewport they are fighting for.
|
||||
let touchLastY: number | null = null;
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
touchLastY = event.touches[0]?.clientY ?? null;
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
const y = event.touches[0]?.clientY ?? null;
|
||||
const lastY = touchLastY;
|
||||
touchLastY = y;
|
||||
if (y === null) return;
|
||||
// A downward finger drags the content up — the touch wheel-up.
|
||||
const draggedUp = lastY !== null && y > lastY;
|
||||
if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture();
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
touchLastY = null;
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
// The scrollbar track is the scroll node itself; a tap on a row
|
||||
// only breaks follow when the viewport already left the end.
|
||||
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) {
|
||||
gesture();
|
||||
}
|
||||
};
|
||||
const handleScroll = () => {
|
||||
queueSave();
|
||||
};
|
||||
|
||||
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
|
||||
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
||||
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
||||
scrollNode.addEventListener('keydown', handleKeyDown);
|
||||
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollNode.removeEventListener('wheel', handleWheel);
|
||||
scrollNode.removeEventListener('touchstart', handleTouchStart);
|
||||
scrollNode.removeEventListener('touchmove', handleTouchMove);
|
||||
scrollNode.removeEventListener('touchend', handleTouchEnd);
|
||||
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
|
||||
scrollNode.removeEventListener('pointerdown', handlePointerDown);
|
||||
scrollNode.removeEventListener('keydown', handleKeyDown);
|
||||
scrollNode.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [queueSave, realContentOverflowsViewport, scrollNode]);
|
||||
|
||||
// ── session lifecycle ───────────────────────────────────────────────────
|
||||
const lastSessionKeyRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
lastSessionKeyRef.current = currentSessionKey;
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
// Persist the outgoing session's position before the new one takes over.
|
||||
flushSave();
|
||||
isAtEndRef.current = true;
|
||||
setUserOwnsScroll(false);
|
||||
modeRef.current = 'following-end';
|
||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||
clearAnchor();
|
||||
hideScrollButton();
|
||||
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
|
||||
|
||||
// Suppress the overlay scrollbar thumb while automatic movement owns the
|
||||
// scroll position, so it does not jump on each correction.
|
||||
React.useEffect(() => {
|
||||
setIsFollowingProgrammatically(!showScrollButton && !userOwnsScroll);
|
||||
}, [showScrollButton, userOwnsScroll]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
cancelShowButtonTimer();
|
||||
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
|
||||
if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||
const frames = dataChangeFramesRef.current;
|
||||
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
||||
if (frames.second !== null) cancelAnimationFrame(frames.second);
|
||||
}, [cancelShowButtonTimer]);
|
||||
|
||||
// ── active-turn spy ─────────────────────────────────────────────────────
|
||||
// Reads turn positions straight from the DOM, so it is unaffected by which
|
||||
// list implementation owns the container. Rows mounting and unmounting
|
||||
// during virtualized scrolling are tracked through the mutation observer.
|
||||
React.useEffect(() => {
|
||||
if (!onActiveTurnChange) return;
|
||||
const container = scrollNode;
|
||||
if (!container) return;
|
||||
|
||||
let lastActiveTurnId: string | null = null;
|
||||
const spy = createScrollSpy({
|
||||
onActive: (turnId) => {
|
||||
if (turnId === lastActiveTurnId) return;
|
||||
lastActiveTurnId = turnId;
|
||||
onActiveTurnChange(turnId);
|
||||
},
|
||||
});
|
||||
spy.setContainer(container);
|
||||
|
||||
const elementByTurnId = new Map<string, HTMLElement>();
|
||||
const registerTurnNode = (node: HTMLElement) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) return false;
|
||||
elementByTurnId.set(turnId, node);
|
||||
spy.register(node, turnId);
|
||||
return true;
|
||||
};
|
||||
const unregisterTurnNode = (node: HTMLElement) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) return false;
|
||||
if (elementByTurnId.get(turnId) !== node) return false;
|
||||
elementByTurnId.delete(turnId);
|
||||
spy.unregister(turnId);
|
||||
return true;
|
||||
};
|
||||
const collectTurnNodes = (node: Node): HTMLElement[] => {
|
||||
if (!(node instanceof HTMLElement)) return [];
|
||||
const collected: HTMLElement[] = [];
|
||||
if (node.matches('[data-turn-id]')) collected.push(node);
|
||||
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
|
||||
return collected;
|
||||
};
|
||||
|
||||
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
|
||||
spy.markDirty();
|
||||
|
||||
const mutationObserver = new MutationObserver((records) => {
|
||||
let changed = false;
|
||||
records.forEach((record) => {
|
||||
record.removedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (unregisterTurnNode(turnNode)) changed = true;
|
||||
});
|
||||
});
|
||||
record.addedNodes.forEach((node) => {
|
||||
collectTurnNodes(node).forEach((turnNode) => {
|
||||
if (registerTurnNode(turnNode)) changed = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
if (changed) spy.markDirty();
|
||||
});
|
||||
mutationObserver.observe(container, { subtree: true, childList: true });
|
||||
|
||||
const onScroll = () => spy.onScroll();
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
mutationObserver.disconnect();
|
||||
spy.destroy();
|
||||
};
|
||||
}, [onActiveTurnChange, scrollNode]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
scrollNode,
|
||||
isPinned,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onManualNavigation,
|
||||
onTimelineDataChange,
|
||||
showScrollButton,
|
||||
userOwnsScroll,
|
||||
isFollowingProgrammatically,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
saveSnapshotNow,
|
||||
restoreSnapshot,
|
||||
};
|
||||
};
|
||||
+126
-58
@@ -256,81 +256,91 @@ div[data-chat-input-footer="true"] {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Scroll edge fades live on the viewport, never on the scrolling content. */
|
||||
[data-scroll-shadow-viewport]::before,
|
||||
[data-scroll-shadow-viewport]::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */
|
||||
[data-scroll-shadow="true"] {
|
||||
/* A concrete default, not var(--scroll-shadow-size, 48px): a custom
|
||||
property referencing itself is a cycle and computes to invalid, which
|
||||
silently killed every mask below for consumers that don't set the
|
||||
variable inline (the hook-based chat scroller). Inline styles from the
|
||||
ScrollShadow component still override this. */
|
||||
--scroll-shadow-size: 48px;
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-orientation="vertical"]::before,
|
||||
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
|
||||
inset-inline: 0;
|
||||
[data-scroll-shadow="true"][data-orientation="vertical"] {
|
||||
mask-mode: alpha;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-orientation="vertical"]::before {
|
||||
top: 0;
|
||||
height: var(--scroll-shadow-top-size, var(--scroll-shadow-size));
|
||||
background: linear-gradient(
|
||||
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-bottom-scroll="true"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--scroll-shadow-color, var(--surface-background)) 0,
|
||||
var(--scroll-shadow-color, var(--surface-background)) var(--scroll-shadow-top-clear-size, 0px),
|
||||
transparent 0%,
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
|
||||
bottom: 0;
|
||||
height: var(--scroll-shadow-size);
|
||||
background: linear-gradient(to top, var(--scroll-shadow-color, var(--surface-background)), transparent);
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before,
|
||||
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
|
||||
inset-block: 0;
|
||||
width: var(--scroll-shadow-size);
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before {
|
||||
left: 0;
|
||||
background: linear-gradient(to right, var(--scroll-shadow-color, var(--surface-background)), transparent);
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
|
||||
right: 0;
|
||||
background: linear-gradient(to left, var(--scroll-shadow-color, var(--surface-background)), transparent);
|
||||
}
|
||||
|
||||
[data-scroll-shadow-viewport][data-top-scroll="true"]::before,
|
||||
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::before,
|
||||
[data-scroll-shadow-viewport][data-bottom-scroll="true"]::after,
|
||||
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::after,
|
||||
[data-scroll-shadow-viewport][data-left-scroll="true"]::before,
|
||||
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::before,
|
||||
[data-scroll-shadow-viewport][data-right-scroll="true"]::after,
|
||||
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::after {
|
||||
opacity: 1;
|
||||
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-scroll="true"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Sticky-fade overlay: the crisp duplicate identity stays
|
||||
mounted and its visibility is driven synchronously by the viewport's
|
||||
top-scroll data attribute, so the overlay appears in the exact frame the
|
||||
edge fade covers the real header (no blink).
|
||||
mounted and its visibility is driven synchronously by the scroller's own
|
||||
top-scroll data attribute — the same signal that engages the mask — so the
|
||||
overlay appears in the exact frame the mask hides the real header (no blink).
|
||||
ScrollShadow sets data-top-scroll when nothing is below the viewport and
|
||||
data-top-bottom-scroll when content is both above and below. */
|
||||
.oc-sticky-fade-overlay {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.oc-sticky-fade-root:has(
|
||||
[data-scroll-shadow-viewport]:is([data-top-scroll="true"], [data-top-bottom-scroll="true"])
|
||||
) .oc-sticky-fade-overlay {
|
||||
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-scroll="true"]) .oc-sticky-fade-overlay,
|
||||
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-bottom-scroll="true"]) .oc-sticky-fade-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-scroll-shadow="true"][data-orientation="vertical"][data-bottom-scroll="true"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0%,
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 0%,
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@keyframes spin-once {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@@ -885,15 +895,15 @@ html:not(.dark) .chat-scroll {
|
||||
}
|
||||
|
||||
/* Hide the long active todo before it can collide with the changed-files summary. */
|
||||
@container status-row (max-width: 38rem) {
|
||||
.status-row__active-todo {
|
||||
@container composer-status-bar (max-width: 38rem) {
|
||||
.composer-status-bar__active-todo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the secondary changed-files label on narrow mobile layouts. */
|
||||
@container status-row (max-width: 30rem) {
|
||||
.status-row__changed-label {
|
||||
@container composer-status-bar (max-width: 30rem) {
|
||||
.composer-status-bar__changed-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1353,6 +1363,64 @@ html:not(.dark) .chat-scroll {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* First uncached open of a session shows a hydration skeleton; the real
|
||||
timeline replacing it fades in once instead of popping. Cached session
|
||||
switches never carry this class and stay instant. */
|
||||
@keyframes oc-chat-hydration-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.oc-chat-hydration-reveal {
|
||||
animation: oc-chat-hydration-reveal 180ms ease-out both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.oc-chat-hydration-reveal {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A block committed mid-stream enters with a fade and a gentle rise —
|
||||
compositor-only properties, deliberately nothing heavier: during a long
|
||||
stream the follow scroll masks the entrance anyway, so the effect only
|
||||
really shows on short replies, and those must not pay for a GPU filter.
|
||||
Blocks committed in the same tick cascade via --oc-md-enter-delay set
|
||||
inline by the renderer. */
|
||||
@keyframes oc-md-block-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.oc-md-block-enter {
|
||||
animation: oc-md-block-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: var(--oc-md-enter-delay, 0ms);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.oc-md-block-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* While streaming defers the per-line gutter markup, hold its horizontal
|
||||
footprint (2rem column + 0.75rem gap) so the finished pass only fills in
|
||||
the numbers instead of shifting every code line. */
|
||||
.markdown-content pre[data-md-gutter-reserved] > code {
|
||||
display: block;
|
||||
padding-left: 2.75rem;
|
||||
}
|
||||
|
||||
.markdown-content [data-md-code-line-number] {
|
||||
align-self: stretch;
|
||||
padding-right: 0.75rem;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { TerminalShell } from '@/lib/api/types';
|
||||
|
||||
type AppearanceSlice = {
|
||||
showReasoningTraces: boolean;
|
||||
streamingAutoFollowEnabled: boolean;
|
||||
workStatusPanelEnabled: boolean;
|
||||
workStatusHiddenSections: string[];
|
||||
sessionRecapEnabled: boolean;
|
||||
@@ -62,6 +63,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
|
||||
let previous: AppearanceSlice = {
|
||||
showReasoningTraces: useUIStore.getState().showReasoningTraces,
|
||||
streamingAutoFollowEnabled: useUIStore.getState().streamingAutoFollowEnabled,
|
||||
workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled,
|
||||
workStatusHiddenSections: useUIStore.getState().workStatusHiddenSections,
|
||||
sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled,
|
||||
@@ -104,6 +106,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
useUIStore.subscribe((state) => {
|
||||
const current: AppearanceSlice = {
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
|
||||
workStatusPanelEnabled: state.workStatusPanelEnabled,
|
||||
workStatusHiddenSections: state.workStatusHiddenSections,
|
||||
sessionRecapEnabled: state.sessionRecapEnabled,
|
||||
@@ -156,6 +159,9 @@ export const startAppearanceAutoSave = (): void => {
|
||||
if (current.showReasoningTraces !== previous.showReasoningTraces) {
|
||||
diff.showReasoningTraces = current.showReasoningTraces;
|
||||
}
|
||||
if (current.streamingAutoFollowEnabled !== previous.streamingAutoFollowEnabled) {
|
||||
diff.streamingAutoFollowEnabled = current.streamingAutoFollowEnabled;
|
||||
}
|
||||
if (current.sessionRecapEnabled !== previous.sessionRecapEnabled) {
|
||||
diff.sessionRecapEnabled = current.sessionRecapEnabled;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ export type DesktopSettings = {
|
||||
defaultVariant?: string;
|
||||
defaultAgent?: string;
|
||||
smallModelUseDefault?: boolean;
|
||||
streamingAutoFollowEnabled?: boolean;
|
||||
sessionRecapEnabled?: boolean;
|
||||
sessionSuggestionEnabled?: boolean;
|
||||
sessionGoalEnabled?: boolean;
|
||||
|
||||
@@ -1856,6 +1856,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Werkzeuge standardmäßig geöffnet anzeigen:',
|
||||
'settings.openchamber.visual.section.sessionAssistance': 'Sitzungshilfe',
|
||||
'settings.openchamber.visual.section.reasoning': 'Reasoning',
|
||||
'settings.openchamber.visual.section.streaming': 'Streaming',
|
||||
'settings.openchamber.visual.field.streamingAutoFollow': 'Neuen Inhalten beim Streaming folgen',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Neuen Inhalten automatisch folgen, während eine Antwort gestreamt wird',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Während eine Antwort eintrifft, folgt die Ansicht laufend dem neuesten Inhalt. Deaktivieren, um die Ansicht ruhig zu halten und manuell zu scrollen.',
|
||||
'settings.openchamber.visual.section.messageAppearance': 'Nachrichten-Erscheinungsbild',
|
||||
'settings.openchamber.visual.section.toolsAndFiles': 'Werkzeuge & Dateien',
|
||||
'settings.openchamber.visual.section.composer': 'Komponist',
|
||||
|
||||
@@ -1929,6 +1929,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Show tools opened by default',
|
||||
'settings.openchamber.visual.section.sessionAssistance': 'Session Assistance',
|
||||
'settings.openchamber.visual.section.reasoning': 'Reasoning',
|
||||
'settings.openchamber.visual.section.streaming': 'Streaming',
|
||||
'settings.openchamber.visual.field.streamingAutoFollow': 'Follow new content while streaming',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatically follow new content while a response streams',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'While a reply streams in, the view keeps gliding to the newest content. Turn this off to keep the view still and scroll manually.',
|
||||
'settings.openchamber.visual.section.messageAppearance': 'Message Appearance',
|
||||
'settings.openchamber.visual.section.toolsAndFiles': 'Tools & Files',
|
||||
'settings.openchamber.visual.section.composer': 'Composer',
|
||||
|
||||
@@ -1906,6 +1906,10 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar herramientas abiertas por defecto",
|
||||
"settings.openchamber.visual.section.sessionAssistance": "Asistencia de sesión",
|
||||
"settings.openchamber.visual.section.reasoning": "Razonamiento",
|
||||
"settings.openchamber.visual.section.streaming": "Streaming",
|
||||
"settings.openchamber.visual.field.streamingAutoFollow": "Seguir el contenido nuevo durante el streaming",
|
||||
"settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automáticamente el contenido nuevo mientras se transmite una respuesta",
|
||||
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Mientras llega una respuesta, la vista se desplaza hacia el contenido más reciente. Desactívalo para mantener la vista quieta y desplazarte manualmente.",
|
||||
"settings.openchamber.visual.section.messageAppearance": "Apariencia de los mensajes",
|
||||
"settings.openchamber.visual.section.toolsAndFiles": "Herramientas y archivos",
|
||||
"settings.openchamber.visual.section.composer": "Compositor",
|
||||
|
||||
@@ -1820,6 +1820,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Afficher les outils ouverts par défaut',
|
||||
'settings.openchamber.visual.section.sessionAssistance': 'Assistance de session',
|
||||
'settings.openchamber.visual.section.reasoning': 'Raisonnement',
|
||||
'settings.openchamber.visual.section.streaming': 'Streaming',
|
||||
'settings.openchamber.visual.field.streamingAutoFollow': 'Suivre le nouveau contenu pendant le streaming',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Suivre automatiquement le nouveau contenu pendant la diffusion d’une réponse',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Pendant qu’une réponse arrive, la vue glisse vers le contenu le plus récent. Désactivez pour garder la vue immobile et défiler manuellement.',
|
||||
'settings.openchamber.visual.section.messageAppearance': 'Apparence des messages',
|
||||
'settings.openchamber.visual.section.toolsAndFiles': 'Outils et fichiers',
|
||||
'settings.openchamber.visual.section.composer': 'Zone de saisie',
|
||||
|
||||
@@ -1939,6 +1939,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'デフォルトで開くツールを表示',
|
||||
'settings.openchamber.visual.section.sessionAssistance': 'セッション支援',
|
||||
'settings.openchamber.visual.section.reasoning': '推論',
|
||||
'settings.openchamber.visual.section.streaming': 'ストリーミング',
|
||||
'settings.openchamber.visual.field.streamingAutoFollow': '応答のストリーミング中に新しい内容を追従',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowAria': '応答のストリーミング中に新しい内容へ自動スクロールする',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowInfo': '応答の受信中、ビューは常に最新の内容へスクロールします。オフにするとビューは動かず、手動でスクロールできます。',
|
||||
'settings.openchamber.visual.section.messageAppearance': 'メッセージの外観',
|
||||
'settings.openchamber.visual.section.toolsAndFiles': 'ツールとファイル',
|
||||
'settings.openchamber.visual.section.composer': '入力欄',
|
||||
|
||||
@@ -1906,6 +1906,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.section.showToolsOpenedByDefault': '도구를 기본으로 펼쳐 표시',
|
||||
'settings.openchamber.visual.section.sessionAssistance': '세션 지원',
|
||||
'settings.openchamber.visual.section.reasoning': '추론',
|
||||
'settings.openchamber.visual.section.streaming': '스트리밍',
|
||||
'settings.openchamber.visual.field.streamingAutoFollow': '스트리밍 중 새 내용 따라가기',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowAria': '응답 스트리밍 중 새 내용으로 자동 스크롤',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowInfo': '응답이 스트리밍되는 동안 화면이 최신 내용으로 계속 이동합니다. 끄면 화면이 고정되어 직접 스크롤할 수 있습니다.',
|
||||
'settings.openchamber.visual.section.messageAppearance': '메시지 모양',
|
||||
'settings.openchamber.visual.section.toolsAndFiles': '도구 및 파일',
|
||||
'settings.openchamber.visual.section.composer': '입력창',
|
||||
|
||||
@@ -1209,6 +1209,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Pokaż narzędzia domyślnie otwarte',
|
||||
'settings.openchamber.visual.section.sessionAssistance': 'Wsparcie sesji',
|
||||
'settings.openchamber.visual.section.reasoning': 'Rozumowanie',
|
||||
'settings.openchamber.visual.section.streaming': 'Streaming',
|
||||
'settings.openchamber.visual.field.streamingAutoFollow': 'Podążaj za nową treścią podczas streamingu',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatycznie podążaj za nową treścią podczas streamowania odpowiedzi',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Podczas napływania odpowiedzi widok płynnie podąża za najnowszą treścią. Wyłącz, aby widok pozostał nieruchomy i przewijać ręcznie.',
|
||||
'settings.openchamber.visual.section.messageAppearance': 'Wygląd wiadomości',
|
||||
'settings.openchamber.visual.section.toolsAndFiles': 'Narzędzia i pliki',
|
||||
'settings.openchamber.visual.section.composer': 'Pole wiadomości',
|
||||
|
||||
@@ -1906,6 +1906,10 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar ferramentas abertas por padrão",
|
||||
"settings.openchamber.visual.section.sessionAssistance": "Assistência da sessão",
|
||||
"settings.openchamber.visual.section.reasoning": "Raciocínio",
|
||||
"settings.openchamber.visual.section.streaming": "Streaming",
|
||||
"settings.openchamber.visual.field.streamingAutoFollow": "Seguir o novo conteúdo durante o streaming",
|
||||
"settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automaticamente o novo conteúdo enquanto uma resposta é transmitida",
|
||||
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Enquanto uma resposta chega, a visualização acompanha o conteúdo mais recente. Desative para manter a visualização parada e rolar manualmente.",
|
||||
"settings.openchamber.visual.section.messageAppearance": "Aparência das mensagens",
|
||||
"settings.openchamber.visual.section.toolsAndFiles": "Ferramentas e arquivos",
|
||||
"settings.openchamber.visual.section.composer": "Campo de mensagem",
|
||||
|
||||
@@ -1906,6 +1906,10 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Показувати інструменти відкритими за замовчуванням",
|
||||
"settings.openchamber.visual.section.sessionAssistance": "Допомога із сесією",
|
||||
"settings.openchamber.visual.section.reasoning": "Міркування",
|
||||
"settings.openchamber.visual.section.streaming": "Стримінг",
|
||||
"settings.openchamber.visual.field.streamingAutoFollow": "Слідкувати за новим вмістом під час стримінгу",
|
||||
"settings.openchamber.visual.field.streamingAutoFollowAria": "Автоматично слідкувати за новим вмістом під час стримінгу відповіді",
|
||||
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Поки відповідь надходить, вигляд плавно рухається до найновішого вмісту. Вимкніть, щоб вигляд залишався нерухомим і гортати вручну.",
|
||||
"settings.openchamber.visual.section.messageAppearance": "Вигляд повідомлень",
|
||||
"settings.openchamber.visual.section.toolsAndFiles": "Інструменти та файли",
|
||||
"settings.openchamber.visual.section.composer": "Поле вводу",
|
||||
|
||||
@@ -1906,6 +1906,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.section.showToolsOpenedByDefault': '默认展开以下工具',
|
||||
'settings.openchamber.visual.section.sessionAssistance': '会话辅助',
|
||||
'settings.openchamber.visual.section.reasoning': '推理',
|
||||
'settings.openchamber.visual.section.streaming': '流式输出',
|
||||
'settings.openchamber.visual.field.streamingAutoFollow': '流式输出时跟随新内容',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowAria': '在回复流式输出时自动跟随新内容',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回复流式输出时,视图会持续滚动到最新内容。关闭后视图保持不动,可手动滚动。',
|
||||
'settings.openchamber.visual.section.messageAppearance': '消息外观',
|
||||
'settings.openchamber.visual.section.toolsAndFiles': '工具和文件',
|
||||
'settings.openchamber.visual.section.composer': '输入框',
|
||||
|
||||
@@ -1813,6 +1813,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.section.showToolsOpenedByDefault': '預設展開以下工具',
|
||||
'settings.openchamber.visual.section.sessionAssistance': '工作階段輔助',
|
||||
'settings.openchamber.visual.section.reasoning': '推理',
|
||||
'settings.openchamber.visual.section.streaming': '串流',
|
||||
'settings.openchamber.visual.field.streamingAutoFollow': '串流時跟隨新內容',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowAria': '回覆串流時自動跟隨新內容',
|
||||
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回覆串流時,畫面會持續捲動到最新內容。關閉後畫面保持不動,可手動捲動。',
|
||||
'settings.openchamber.visual.section.messageAppearance': '訊息外觀',
|
||||
'settings.openchamber.visual.section.toolsAndFiles': '工具與檔案',
|
||||
'settings.openchamber.visual.section.composer': '輸入框',
|
||||
|
||||
@@ -531,6 +531,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
|
||||
darkThemeId: DEFAULT_DARK_THEME_ID,
|
||||
openInAppId: DEFAULT_OPEN_IN_APP_ID,
|
||||
showReasoningTraces: defaults.showReasoningTraces,
|
||||
streamingAutoFollowEnabled: defaults.streamingAutoFollowEnabled,
|
||||
workStatusPanelEnabled: defaults.workStatusPanelEnabled,
|
||||
workStatusHiddenSections: defaults.workStatusHiddenSections,
|
||||
sessionRecapEnabled: defaults.sessionRecapEnabled,
|
||||
@@ -637,6 +638,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
|
||||
store.setShowReasoningTraces(settings.showReasoningTraces);
|
||||
}
|
||||
if (typeof settings.streamingAutoFollowEnabled === 'boolean' && settings.streamingAutoFollowEnabled !== store.streamingAutoFollowEnabled) {
|
||||
store.setStreamingAutoFollowEnabled(settings.streamingAutoFollowEnabled);
|
||||
}
|
||||
if (typeof settings.sessionRecapEnabled === 'boolean' && settings.sessionRecapEnabled !== store.sessionRecapEnabled) {
|
||||
store.setSessionRecapEnabled(settings.sessionRecapEnabled);
|
||||
}
|
||||
@@ -1158,6 +1162,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
||||
}
|
||||
if (typeof candidate.streamingAutoFollowEnabled === 'boolean') {
|
||||
result.streamingAutoFollowEnabled = candidate.streamingAutoFollowEnabled;
|
||||
}
|
||||
if (typeof candidate.sessionRecapEnabled === 'boolean') {
|
||||
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
|
||||
}
|
||||
|
||||
@@ -240,6 +240,19 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
titleKey: 'settings.openchamber.visual.section.reasoning',
|
||||
keywords: ['thinking', 'traces'],
|
||||
},
|
||||
{
|
||||
id: 'chat.streaming',
|
||||
page: 'chat',
|
||||
titleKey: 'settings.openchamber.visual.section.streaming',
|
||||
keywords: ['stream', 'scroll'],
|
||||
},
|
||||
{
|
||||
id: 'chat.streaming-auto-follow',
|
||||
page: 'chat',
|
||||
titleKey: 'settings.openchamber.visual.field.streamingAutoFollow',
|
||||
descriptionKey: 'settings.openchamber.visual.field.streamingAutoFollowInfo',
|
||||
keywords: ['autoscroll', 'auto-scroll', 'follow', 'stick to bottom', 'streaming'],
|
||||
},
|
||||
{
|
||||
id: 'chat.sticky-user-header',
|
||||
page: 'chat',
|
||||
|
||||
@@ -674,6 +674,7 @@ interface UIStore {
|
||||
eventStreamStatus: EventStreamStatus;
|
||||
eventStreamHint: string | null;
|
||||
showReasoningTraces: boolean;
|
||||
streamingAutoFollowEnabled: boolean;
|
||||
sessionRecapEnabled: boolean;
|
||||
sessionSuggestionEnabled: boolean;
|
||||
sessionGoalEnabled: boolean;
|
||||
@@ -859,6 +860,7 @@ interface UIStore {
|
||||
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
|
||||
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
|
||||
setShowReasoningTraces: (value: boolean) => void;
|
||||
setStreamingAutoFollowEnabled: (value: boolean) => void;
|
||||
setSessionRecapEnabled: (value: boolean) => void;
|
||||
setSessionSuggestionEnabled: (value: boolean) => void;
|
||||
setSessionGoalEnabled: (value: boolean) => void;
|
||||
@@ -1027,6 +1029,7 @@ export const useUIStore = create<UIStore>()(
|
||||
eventStreamStatus: 'idle',
|
||||
eventStreamHint: null,
|
||||
showReasoningTraces: true,
|
||||
streamingAutoFollowEnabled: true,
|
||||
sessionRecapEnabled: true,
|
||||
sessionSuggestionEnabled: true,
|
||||
sessionGoalEnabled: true,
|
||||
@@ -1759,6 +1762,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ showReasoningTraces: value });
|
||||
},
|
||||
|
||||
setStreamingAutoFollowEnabled: (value) => {
|
||||
set({ streamingAutoFollowEnabled: value });
|
||||
},
|
||||
|
||||
setSessionRecapEnabled: (value) => {
|
||||
set({ sessionRecapEnabled: value });
|
||||
},
|
||||
@@ -2639,6 +2646,7 @@ export const useUIStore = create<UIStore>()(
|
||||
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
|
||||
// Note: isSettingsDialogOpen intentionally NOT persisted
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
|
||||
sessionRecapEnabled: state.sessionRecapEnabled,
|
||||
sessionSuggestionEnabled: state.sessionSuggestionEnabled,
|
||||
sessionGoalEnabled: state.sessionGoalEnabled,
|
||||
|
||||
@@ -473,7 +473,7 @@
|
||||
choreography driven by useNativeMobileChrome:
|
||||
|
||||
show: shell keeps its full height for the whole 0.25s; the composer (and, when
|
||||
pinned, the chat scroll container — see useChatAutoFollow) slides up via
|
||||
pinned, the chat scroll container — see useChatTimelineScroll) slides up via
|
||||
inline transforms in sync with the keyboard; at the end the shell snaps to
|
||||
its final height (--oc-kb-layout, one reflow) and the shift is removed
|
||||
in the same frame — visually identical, so the swap is invisible.
|
||||
|
||||
@@ -2702,6 +2702,48 @@ export function useSessionParts(messageID: string, directory?: string) {
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_PARTS_BY_MESSAGE: Record<string, Part[]> = {}
|
||||
|
||||
/**
|
||||
* Get parts for several messages at once, keyed by message id. The snapshot
|
||||
* keeps its identity until one of the requested part arrays changes, so a
|
||||
* streaming turn can overlay every one of its step messages — not only the
|
||||
* currently streaming one — without tearing between them when the stream
|
||||
* moves to the next message.
|
||||
*/
|
||||
export function useSessionPartsForMessages(messageIDs: readonly string[], directory?: string): Record<string, Part[]> {
|
||||
const store = useDirectoryStore(directory)
|
||||
const cacheRef = React.useRef<{ ids: readonly string[]; parts: Record<string, Part[]> } | null>(null)
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (messageIDs.length === 0) return EMPTY_PARTS_BY_MESSAGE
|
||||
const state = store.getState()
|
||||
const cached = cacheRef.current
|
||||
if (
|
||||
cached
|
||||
&& cached.ids === messageIDs
|
||||
&& messageIDs.every((id) => (state.part[id] ?? EMPTY_PARTS) === (cached.parts[id] ?? EMPTY_PARTS))
|
||||
) {
|
||||
return cached.parts
|
||||
}
|
||||
const parts: Record<string, Part[]> = {}
|
||||
for (const id of messageIDs) parts[id] = state.part[id] ?? EMPTY_PARTS
|
||||
cacheRef.current = { ids: messageIDs, parts }
|
||||
return parts
|
||||
}, [messageIDs, store])
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (messageIDs.length === 0) return () => undefined
|
||||
return store.subscribe((state, previous) => {
|
||||
for (const id of messageIDs) {
|
||||
if (state.part[id] !== previous.part[id]) {
|
||||
notify()
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [messageIDs, store])
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/** Get status for a specific session */
|
||||
export function useSessionStatus(sessionID: string, directory?: string) {
|
||||
const store = useDirectoryStore(directory)
|
||||
|
||||
Reference in New Issue
Block a user