diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 90e90874..c3db9e76 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -147,8 +147,6 @@ type HydratingToolSkeletonRow = { }; type ChatViewportProps = { - onStatusOverlayNode: (node: HTMLDivElement | null) => void; - statusOverlayHidden: boolean; currentSessionId: string; currentSessionKey: string; isDesktopExpandedInput: boolean; @@ -192,8 +190,6 @@ type ChatViewportProps = { }; const ChatViewport = React.memo(({ - onStatusOverlayNode, - statusOverlayHidden, currentSessionId, currentSessionKey, isDesktopExpandedInput, @@ -418,24 +414,6 @@ const ChatViewport = React.memo(({ scrollContainerProps={scrollContainerProps} /> - {/* Static above the composer: inside the list it walked down - with every streamed line while a turn was anchored. Solid - background — text streams beneath it; the measured height - feeds composerOverlayHeight so the live line never hides - under it. */} -
-
- -
-
{showPromptNavigator && promptTurnIds.length >= 2 ? ( ); }, (prev, next) => { - return prev.onStatusOverlayNode === next.onStatusOverlayNode - && prev.statusOverlayHidden === next.statusOverlayHidden - && prev.currentSessionId === next.currentSessionId + return prev.currentSessionId === next.currentSessionId && prev.currentSessionKey === next.currentSessionKey && prev.isDesktopExpandedInput === next.isDesktopExpandedInput && prev.isMobile === next.isMobile @@ -949,7 +925,9 @@ export const ChatContainer: React.FC = ({ return; } const update = () => { - const height = node.getBoundingClientRect().height; + // +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); @@ -989,6 +967,7 @@ export const ChatContainer: React.FC = ({ isPinned, isFollowingProgrammatically, showScrollButton, + userOwnsScroll, } = useChatTimelineScroll({ currentSessionId, currentSessionKey, @@ -1366,8 +1345,6 @@ export const ChatContainer: React.FC = ({ return ( = ({ )} > {!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && ( - + <> + + {/* 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. */} +
+
+ {/* The glass chip itself is rendered inside + StatusRow (its root is a size container + that cannot shrink-wrap). */} +
+ +
+
+
+ )} {promptReadOnly ? ( diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 83b4b52b..479e61eb 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -306,24 +306,23 @@ export const StatusRow: React.FC = ({ return (
{/* 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. */} -
- {/* Left: Abort status | Working placeholder | leftAccessory */} -
+ {/* 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. */} +
+ {/* Left: Abort status | Working placeholder | leftAccessory. Sized by + its content: the row now lives in a shrink-to-fit glass chip, and + a flex-1 (basis 0) here collapsed the chip to zero width. */} +
{showAssistantStatus && showAbortStatus ? (
diff --git a/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx b/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx index 64f0d4e6..9622c5f5 100644 --- a/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx +++ b/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx @@ -1,13 +1,45 @@ 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 ( + + {label} + + + ); +}; interface ScrollToBottomButtonProps { visible: boolean; - /** The session is still streaming: the pill carries the activity signal + /** 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; @@ -18,26 +50,36 @@ const ScrollToBottomButton: React.FC = ({ visible, wo return (
- + {/* The same column that centres the composer, so the pill's left + edge lines up exactly with the input frame. */} +
+ {/* 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. */} +
+ +
+
); }; diff --git a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx index 9260cc51..4308e72b 100644 --- a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx +++ b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx @@ -229,12 +229,11 @@ export function WorkingPlaceholder({ return (
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; @@ -221,8 +223,12 @@ export const useChatTimelineScroll = ({ 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 — offer the way back now. - if (!isAtEndRef.current) scheduleShowScrollButton(); + // case no further at-end transition will fire. This is an explicit + // gesture — show the pill immediately, no debounce. + if (!isAtEndRef.current) { + cancelShowButtonTimer(); + setShowScrollButton(true); + } armedForNextUserMessageRef.current = false; pendingAnchorRef.current = null; positionedAnchorRef.current = null; @@ -233,7 +239,7 @@ export const useChatTimelineScroll = ({ cancelAnimationFrame(anchorRestoreFrameRef.current); anchorRestoreFrameRef.current = null; } - }, [scheduleShowScrollButton]); + }, [cancelShowButtonTimer]); const isLiveFollowActive = React.useCallback(() => ( liveFollowGenerationRef.current === userGenerationRef.current @@ -592,27 +598,38 @@ export const useChatTimelineScroll = ({ React.useEffect(() => { if (!scrollNode) return; - const contentScrollsUp = () => { + // 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; - return list ? realContentOverflowsViewport(list) : false; + 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 && contentScrollsUp()) gesture(); + if (event.deltaY < 0 && canScrollUp()) gesture(); }; const handleTouchMove = () => { - if (!isAtEndRef.current && contentScrollsUp()) gesture(); + // Touch is continuous: the first move may still read as at-end, + // but the next one lands after the viewport left it. + if (!isAtEndRef.current && canScrollUp()) gesture(); }; 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) && contentScrollsUp()) gesture(); + if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture(); }; const handleKeyDown = (event: KeyboardEvent) => { - if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && contentScrollsUp()) { + if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) { gesture(); } }; @@ -766,6 +783,7 @@ export const useChatTimelineScroll = ({ onManualNavigation, onTimelineDataChange, showScrollButton, + userOwnsScroll, isFollowingProgrammatically, goToBottom, scrollToBottomOnSend,