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 = { type ChatViewportProps = {
onStatusOverlayNode: (node: HTMLDivElement | null) => void;
statusOverlayHidden: boolean;
currentSessionId: string; currentSessionId: string;
currentSessionKey: string; currentSessionKey: string;
isDesktopExpandedInput: boolean; isDesktopExpandedInput: boolean;
@@ -192,8 +190,6 @@ type ChatViewportProps = {
}; };
const ChatViewport = React.memo(({ const ChatViewport = React.memo(({
onStatusOverlayNode,
statusOverlayHidden,
currentSessionId, currentSessionId,
currentSessionKey, currentSessionKey,
isDesktopExpandedInput, isDesktopExpandedInput,
@@ -418,24 +414,6 @@ const ChatViewport = React.memo(({
scrollContainerProps={scrollContainerProps} scrollContainerProps={scrollContainerProps}
/> />
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} /> <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 ? ( {showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail <PromptNavigatorRail
turnIds={promptTurnIds} turnIds={promptTurnIds}
@@ -451,9 +429,7 @@ const ChatViewport = React.memo(({
</div> </div>
); );
}, (prev, next) => { }, (prev, next) => {
return prev.onStatusOverlayNode === next.onStatusOverlayNode return prev.currentSessionId === next.currentSessionId
&& prev.statusOverlayHidden === next.statusOverlayHidden
&& prev.currentSessionId === next.currentSessionId
&& prev.currentSessionKey === next.currentSessionKey && prev.currentSessionKey === next.currentSessionKey
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput && prev.isDesktopExpandedInput === next.isDesktopExpandedInput
&& prev.isMobile === next.isMobile && prev.isMobile === next.isMobile
@@ -949,7 +925,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return; return;
} }
const update = () => { 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)); setStatusOverlayHeight((prev) => (Math.abs(prev - height) < 1 ? prev : height));
}; };
const observer = new ResizeObserver(update); const observer = new ResizeObserver(update);
@@ -989,6 +967,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
isPinned, isPinned,
isFollowingProgrammatically, isFollowingProgrammatically,
showScrollButton, showScrollButton,
userOwnsScroll,
} = useChatTimelineScroll({ } = useChatTimelineScroll({
currentSessionId, currentSessionId,
currentSessionKey, currentSessionKey,
@@ -1366,8 +1345,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return ( return (
<ChatViewport <ChatViewport
onStatusOverlayNode={onStatusOverlayNode}
statusOverlayHidden={timelineController.showScrollToBottom}
currentSessionId={currentSessionId ?? ''} currentSessionId={currentSessionId ?? ''}
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''} currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
isDesktopExpandedInput={isDesktopExpandedInput} isDesktopExpandedInput={isDesktopExpandedInput}
@@ -1425,11 +1402,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
)} )}
> >
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && ( {!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton <>
visible={timelineController.showScrollToBottom} <ScrollToBottomButton
working={sessionIsWorking} visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest} 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 ? ( {promptReadOnly ? (
<ReadOnlyPromptBanner /> <ReadOnlyPromptBanner />
+13 -14
View File
@@ -306,24 +306,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return ( return (
<div <div
// This row must land exactly where the assistant turn footer (mt-2 // The row renders inside the composer-anchored overlay, which owns the
// inside the message) appears when the turn completes. Measured against // distance to the input and the horizontal column (the same ones the
// the live DOM: the gap ABOVE already matches (message pb-2 = footer // scroll-to-bottom pill uses). The in-list morph offsets (mb-6 and the
// mt-2 = 8px), but the chat is bottom-anchored and the finished message // message column) belonged to the old footer placement and pushed the
// carries ~12px more structure BELOW its footer than this row has — so // row up and right relative to the pill.
// 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")}
style={STATUS_ROW_CONTAINER_STYLE} style={STATUS_ROW_CONTAINER_STYLE}
> >
{/* h-8 matches the turn footer's real row height: its h-8 action {/* 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. */} 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")}> {/* The glass chip lives here, not on the container: the root above is
{/* Left: Abort status | Working placeholder | leftAccessory */} an inline-size query container, whose width ignores its children —
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}> a shrink-to-fit wrapper around it always collapsed to zero. */}
<div className={cn("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", hasLeftAccessory && "px-0.5")}>
{/* 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. */}
<div className={cn("flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAssistantStatus && showAbortStatus ? ( {showAssistantStatus && showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5"> <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"> <span className="flex items-center gap-1.5 typography-ui-label">
@@ -1,13 +1,45 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; 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 { interface ScrollToBottomButtonProps {
visible: boolean; 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. */ while the floating status row is hidden away from the live edge. */
working?: boolean; working?: boolean;
onClick: () => void; onClick: () => void;
@@ -18,26 +50,36 @@ const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, wo
return ( return (
<div <div
className={cn( className={cn(
// Left-aligned to match the status row it stands in for. 'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
'absolute bottom-full left-2 mb-2 transition-all duration-150', visible ? 'opacity-100' : 'opacity-0',
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none',
)} )}
> >
<Button {/* The same column that centres the composer, so the pill's left
variant="ghost" edge lines up exactly with the input frame. */}
size="sm" <div className="chat-input-column">
onClick={onClick} {/* The soft shadow lives on this wrapper, away from the glass
className="oc-glass-popover oc-glass-floating relative size-8 rounded-full [corner-shape:round] p-0 shadow-none" button's backdrop-filter: sharing one element made the
aria-label={t('chat.scrollToBottom.aria')} 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)]">
<Icon name="arrow-down" className="h-4 w-4" /> <button
{working ? ( type="button"
<span onClick={onClick}
aria-hidden aria-label={t('chat.scrollToBottom.aria')}
className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary animate-pulse" className={cn(
/> // Glass material with a hairline real border — much
) : null} // lighter than the oc-glass-floating stack.
</Button> '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> </div>
); );
}; };
@@ -229,12 +229,11 @@ export function WorkingPlaceholder({
return ( return (
<div <div
// Styled to mirror the turn footer's model row (text-sm, // Full muted-foreground, matching the scroll-to-bottom pill's status
// muted-foreground/60, no left inset): when the turn completes this row // text: the row and the pill hand off to each other in the same spot
// disappears and the footer appears in the same visual spot, so the two // and must read as one element changing chrome.
// must read as the same line swapping its text.
className={ className={
'flex h-full items-center text-muted-foreground/60' 'flex h-full items-center text-muted-foreground'
} }
role="status" role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'} aria-live={displayedPermission ? 'assertive' : 'polite'}
+27 -9
View File
@@ -99,6 +99,8 @@ export interface UseChatTimelineScrollResult {
onManualNavigation: () => void; onManualNavigation: () => void;
onTimelineDataChange: () => void; onTimelineDataChange: () => void;
showScrollButton: boolean; showScrollButton: boolean;
/** A real gesture took the scroll; flips back on any explicit opt-in. */
userOwnsScroll: boolean;
isFollowingProgrammatically: boolean; isFollowingProgrammatically: boolean;
goToBottom: (mode?: 'instant' | 'smooth') => void; goToBottom: (mode?: 'instant' | 'smooth') => void;
scrollToBottomOnSend: () => void; scrollToBottomOnSend: () => void;
@@ -221,8 +223,12 @@ export const useChatTimelineScroll = ({
liveFollowGenerationRef.current = null; liveFollowGenerationRef.current = null;
setUserOwnsScroll(true); setUserOwnsScroll(true);
// The end may already have been left by our own movement, in which // 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. // case no further at-end transition will fire. This is an explicit
if (!isAtEndRef.current) scheduleShowScrollButton(); // gesture — show the pill immediately, no debounce.
if (!isAtEndRef.current) {
cancelShowButtonTimer();
setShowScrollButton(true);
}
armedForNextUserMessageRef.current = false; armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null; pendingAnchorRef.current = null;
positionedAnchorRef.current = null; positionedAnchorRef.current = null;
@@ -233,7 +239,7 @@ export const useChatTimelineScroll = ({
cancelAnimationFrame(anchorRestoreFrameRef.current); cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null; anchorRestoreFrameRef.current = null;
} }
}, [scheduleShowScrollButton]); }, [cancelShowButtonTimer]);
const isLiveFollowActive = React.useCallback(() => ( const isLiveFollowActive = React.useCallback(() => (
liveFollowGenerationRef.current === userGenerationRef.current liveFollowGenerationRef.current === userGenerationRef.current
@@ -592,27 +598,38 @@ export const useChatTimelineScroll = ({
React.useEffect(() => { React.useEffect(() => {
if (!scrollNode) return; 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; 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 = () => { const gesture = () => {
onManualNavigationRef.current(); onManualNavigationRef.current();
}; };
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
// Scrolling toward the end is not opting out of follow. // Scrolling toward the end is not opting out of follow.
if (event.deltaY < 0 && contentScrollsUp()) gesture(); if (event.deltaY < 0 && canScrollUp()) gesture();
}; };
const handleTouchMove = () => { 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) => { const handlePointerDown = (event: PointerEvent) => {
// The scrollbar track is the scroll node itself; a tap on a row // The scrollbar track is the scroll node itself; a tap on a row
// only breaks follow when the viewport already left the end. // 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) => { 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(); gesture();
} }
}; };
@@ -766,6 +783,7 @@ export const useChatTimelineScroll = ({
onManualNavigation, onManualNavigation,
onTimelineDataChange, onTimelineDataChange,
showScrollButton, showScrollButton,
userOwnsScroll,
isFollowingProgrammatically, isFollowingProgrammatically,
goToBottom, goToBottom,
scrollToBottomOnSend, scrollToBottomOnSend,