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). */}
+
{/* 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. */}
+
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,