feat: implement scroll threshold state management

- Introduced AIRootStateContext to manage the scroll threshold state across components.
- Updated AIChatRoot, ChatHeader, ChatInterface, and WelcomeScreen to utilize the new context for scroll behavior.
- Added SCROLLED_BEYOND_THRESHOLD constant to define the scroll limit for triggering state changes.

These changes enhance the chat interface by allowing components to react to scroll position, improving user experience and interaction dynamics.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-09-08 21:07:30 +08:00
parent b05f49fe84
commit 682771cd4e
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
6 changed files with 72 additions and 4 deletions

View File

@ -1,3 +1,4 @@
import { atom } from "jotai"
import type { FC, PropsWithChildren } from "react"
import { useEffect, useMemo, useRef } from "react"
@ -5,7 +6,11 @@ import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import type { AIPanelRefs } from "../../store/AIChatContext"
import { AIChatStoreContext, AIPanelRefsContext } from "../../store/AIChatContext"
import {
AIChatStoreContext,
AIPanelRefsContext,
AIRootStateContext,
} from "../../store/AIChatContext"
import { ChatSliceActions } from "../../store/chat-core/chat-actions"
import { useChatActions, useCurrentChatId } from "../../store/hooks"
import { createAIChatStore } from "../../store/store"
@ -58,7 +63,16 @@ export const AIChatRoot: FC<AIChatRootProps> = ({
const Element = (
<AIChatStoreContext value={useAiContextStore}>
<AIChatRootInner chatId={externalChatId}>{children}</AIChatRootInner>
<AIRootStateContext
value={useMemo(
() => ({
isScrolledBeyondThreshold: atom(false),
}),
[],
)}
>
<AIChatRootInner chatId={externalChatId}>{children}</AIChatRootInner>
</AIRootStateContext>
</AIChatStoreContext>
)

View File

@ -1,11 +1,13 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { cn } from "@follow/utils"
import { useAtomValue } from "jotai"
import type { ReactNode } from "react"
import { useCallback } from "react"
import { AIChatPanelStyle, setAIPanelVisibility, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { useBlockActions, useChatActions, useCurrentTitle } from "~/modules/ai-chat/store/hooks"
import { useAIRootState } from "../../store/AIChatContext"
import { ChatMoreDropdown } from "./ChatMoreDropdown"
import { EditableTitle } from "./EditableTitle"
import { TaskReportDropdown } from "./TaskReportDropdown"
@ -43,8 +45,17 @@ const ChatHeaderLayout = ({
const isFloating = useAIChatPanelStyle() === AIChatPanelStyle.Floating
const { isScrolledBeyondThreshold } = useAIRootState()
const isScrolledBeyondThresholdValue = useAtomValue(isScrolledBeyondThreshold)
return (
<div className={cn("absolute inset-x-0 top-0 z-[1]", !isFloating && "bg-background border-b")}>
<div
className={cn(
"absolute inset-x-0 top-0 z-[1] duration-200",
!isFloating && "bg-background data-[scrolled-beyond-threshold=true]:border-b",
)}
data-scrolled-beyond-threshold={isScrolledBeyondThresholdValue}
>
<div className="h-entry-header">
{isFloating && (
<div

View File

@ -4,11 +4,12 @@ import { tracker } from "@follow/tracker"
import { clsx, cn, nextFrame } from "@follow/utils"
import type { BizUIMessage } from "@folo-services/ai-tools"
import { ErrorBoundary } from "@sentry/react"
import { useSetAtom } from "jotai"
import type { EditorState, LexicalEditor } from "lexical"
import { AnimatePresence } from "motion/react"
import { nanoid } from "nanoid"
import type { FC, Ref } from "react"
import { Suspense, useEffect, useRef, useState } from "react"
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { useAISettingKey } from "~/atoms/settings/ai"
@ -31,6 +32,8 @@ import {
useMessages,
} from "~/modules/ai-chat/store/hooks"
import { SCROLLED_BEYOND_THRESHOLD } from "../../constants"
import { useAIRootState } from "../../store/AIChatContext"
import type { AIChatContextBlock } from "../../store/types"
import { convertLexicalToMarkdown } from "../../utils/lexical-markdown"
import { GlobalFileDropZone } from "../file/GlobalFileDropZone"
@ -225,6 +228,15 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
const shouldShowScrollToBottom = hasMessages && !isAtBottom && !isLoadingHistory
const { isScrolledBeyondThreshold } = useAIRootState()
const setIsScrolledBeyondThreshold = useSetAtom(isScrolledBeyondThreshold)
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget
setIsScrolledBeyondThreshold(scrollTop > SCROLLED_BEYOND_THRESHOLD)
},
[setIsScrolledBeyondThreshold],
)
return (
<GlobalFileDropZone className="@container flex size-full flex-col">
<div className="flex min-h-0 flex-1 flex-col" ref={scrollContainerParentRef}>
@ -234,6 +246,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
) : (
<>
<ScrollArea
onScroll={handleScroll}
flex
scrollbarClassName="mb-40 mt-12"
ref={setScrollAreaRef}

View File

@ -1,14 +1,18 @@
import { Folo } from "@follow/components/icons/folo.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { clsx } from "@follow/utils"
import { useSetAtom } from "jotai"
import type { EditorState, LexicalEditor } from "lexical"
import { AnimatePresence, m } from "motion/react"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { useAISettingValue } from "~/atoms/settings/ai"
import { AISpline } from "~/modules/ai-chat/components/3d-models/AISpline"
import { SCROLLED_BEYOND_THRESHOLD } from "../../constants"
import { useMainEntryId } from "../../hooks/useMainEntryId"
import { useAIRootState } from "../../store/AIChatContext"
import { DefaultWelcomeContent, EntrySummaryCard } from "../welcome"
interface WelcomeScreenProps {
@ -24,12 +28,22 @@ export const WelcomeScreen = ({ onSend, centerInputOnEmpty }: WelcomeScreenProps
const hasEntryContext = !!mainEntryId
const enabledShortcuts = aiSettings.shortcuts?.filter((shortcut) => shortcut.enabled) || []
const { isScrolledBeyondThreshold } = useAIRootState()
const setIsScrolledBeyondThreshold = useSetAtom(isScrolledBeyondThreshold)
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget
setIsScrolledBeyondThreshold(scrollTop > SCROLLED_BEYOND_THRESHOLD)
},
[setIsScrolledBeyondThreshold],
)
return (
<ScrollArea
rootClassName="flex min-h-0 flex-1"
viewportClassName="px-6 pt-24 flex min-h-0 grow"
scrollbarClassName="mb-40 mt-12"
flex
onScroll={handleScroll}
>
<div className="mx-auto flex w-full flex-1 flex-col justify-center space-y-8 pb-52">
{/* Header Section - Always Present */}

View File

@ -0,0 +1 @@
export const SCROLLED_BEYOND_THRESHOLD = 100

View File

@ -1,3 +1,4 @@
import type { PrimitiveAtom } from "jotai"
import { createContext, use } from "react"
import type { StoreApi } from "zustand"
import type { UseBoundStoreWithEqualityFn } from "zustand/traditional"
@ -22,3 +23,17 @@ export const useAIChatStore = () => {
}
return store
}
export type AIRootStateContext = {
isScrolledBeyondThreshold: PrimitiveAtom<boolean>
}
export const AIRootStateContext = createContext<AIRootStateContext>(null!)
export const useAIRootState = () => {
const context = use(AIRootStateContext)
if (!context) {
throw new Error("useAIRootState must be used within a AIRootStateContext")
}
return context
}