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:
@@ -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 />
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user