feat(chat): status row and scroll pill share one anchored slot

The streaming status and the scroll-to-bottom pill now hand off to each
other in one place above the composer: same anchor, same input-aligned
column, both in the popover glass (ring and soft shadow stay pill-only
as the interactivity cue, with the shadow on a wrapper so the glass
backdrop-filter cannot drop it after hide/show cycles). The pill is a
single full-surface button, moves to the left edge, and while the
session streams it carries a compact one-line status label instead of
the status row's animation machinery, which did not survive a 32px
chip.

Behavioral fixes along the way: a gesture shows the pill immediately
(no debounce) and hides the status in the same frame; gestures register
whenever the viewport can actually move up — an anchored short turn
kept live-follow armed and suppressed the pill entirely; the status
row sheds its in-list morph offsets (mb-6, message column) and its
inline-size container gets the glass on an inner row, since a query
container cannot shrink-wrap; its working text uses full
muted-foreground to match the pill.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 12:35:12 +03:00
parent 5685e9a6a3
commit 6a944db746
5 changed files with 142 additions and 81 deletions
@@ -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}
/>
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
{/* 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. */}
<div
className={cn(
'pointer-events-none absolute inset-x-0 bottom-0 z-10 transition-opacity duration-150',
statusOverlayHidden && 'opacity-0',
)}
>
<div
ref={onStatusOverlayNode}
className={cn('pb-2 [&:not(:has(*))]:hidden', statusOverlayHidden ? 'pointer-events-none' : 'pointer-events-auto')}
>
<StatusRowContainer />
</div>
</div>
{showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail
turnIds={promptTurnIds}
@@ -451,9 +429,7 @@ const ChatViewport = React.memo(({
</div>
);
}, (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<ChatContainerProps> = ({
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<ChatContainerProps> = ({
isPinned,
isFollowingProgrammatically,
showScrollButton,
userOwnsScroll,
} = useChatTimelineScroll({
currentSessionId,
currentSessionKey,
@@ -1366,8 +1345,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return (
<ChatViewport
onStatusOverlayNode={onStatusOverlayNode}
statusOverlayHidden={timelineController.showScrollToBottom}
currentSessionId={currentSessionId ?? ''}
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
isDesktopExpandedInput={isDesktopExpandedInput}
@@ -1425,11 +1402,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
)}
>
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
working={sessionIsWorking}
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 />