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
+27 -9
View File
@@ -99,6 +99,8 @@ export interface UseChatTimelineScrollResult {
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;
@@ -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,