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