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 />
+13 -14
View File
@@ -306,24 +306,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return (
<div
// This row must land exactly where the assistant turn footer (mt-2
// inside the message) appears when the turn completes. Measured against
// the live DOM: the gap ABOVE already matches (message pb-2 = footer
// mt-2 = 8px), but the chat is bottom-anchored and the finished message
// carries ~12px more structure BELOW its footer than this row has — so
// 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")}
// The row renders inside the composer-anchored overlay, which owns the
// distance to the input and the horizontal column (the same ones the
// scroll-to-bottom pill uses). The in-list morph offsets (mb-6 and the
// message column) belonged to the old footer placement and pushed the
// row up and right relative to the pill.
style={STATUS_ROW_CONTAINER_STYLE}
>
{/* 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. */}
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: Abort status | Working placeholder | leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{/* 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. */}
<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 ? (
<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">
@@ -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 (
<span className="min-w-0 truncate pr-3 text-sm text-muted-foreground">
{label}
<span className="animate-pulse"> </span>
</span>
);
};
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<ScrollToBottomButtonProps> = ({ visible, wo
return (
<div
className={cn(
// Left-aligned to match the status row it stands in for.
'absolute bottom-full left-2 mb-2 transition-all duration-150',
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none',
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
visible ? 'opacity-100' : 'opacity-0',
)}
>
<Button
variant="ghost"
size="sm"
onClick={onClick}
className="oc-glass-popover oc-glass-floating relative size-8 rounded-full [corner-shape:round] p-0 shadow-none"
aria-label={t('chat.scrollToBottom.aria')}
>
<Icon name="arrow-down" className="h-4 w-4" />
{working ? (
<span
aria-hidden
className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary animate-pulse"
/>
) : null}
</Button>
{/* The same column that centres the composer, so the pill's left
edge lines up exactly with the input frame. */}
<div className="chat-input-column">
{/* 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. */}
<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)]">
<button
type="button"
onClick={onClick}
aria-label={t('chat.scrollToBottom.aria')}
className={cn(
// Glass material with a hairline real border — much
// lighter than the oc-glass-floating stack.
'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>
);
};
@@ -229,12 +229,11 @@ export function WorkingPlaceholder({
return (
<div
// Styled to mirror the turn footer's model row (text-sm,
// muted-foreground/60, no left inset): when the turn completes this row
// disappears and the footer appears in the same visual spot, so the two
// must read as the same line swapping its text.
// Full muted-foreground, matching the scroll-to-bottom pill's status
// text: the row and the pill hand off to each other in the same spot
// and must read as one element changing chrome.
className={
'flex h-full items-center text-muted-foreground/60'
'flex h-full items-center text-muted-foreground'
}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
+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,