fix(ai): detect resume tip after consume chat

- Introduced ChatBottomPanel component to manage chat input, shortcuts, and rate limit notices.
- Refactored ChatInterface to utilize ChatBottomPanel, enhancing the structure and readability of the chat layout.
- Created ChatMessageContainer to handle message display and scrolling behavior, improving user experience.
- Added ScrollToBottomButton for easier navigation in long chat histories.
- Consolidated message rendering logic into a new Messages component for better maintainability.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-11-07 16:59:24 +08:00
parent 2332ae2bb4
commit 520ea60f2a
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
8 changed files with 425 additions and 298 deletions

View File

@ -0,0 +1,160 @@
import { cn } from "@follow/utils"
import type { EditorState } from "lexical"
import { $createParagraphNode, $getRoot, createEditor } from "lexical"
import { m } from "motion/react"
import { useCallback, useLayoutEffect, useRef } from "react"
import { useI18n } from "~/hooks/common/useI18n"
import { ChatInput } from "~/modules/ai-chat/components/layouts/ChatInput"
import { ChatShortcutsRow } from "~/modules/ai-chat/components/layouts/ChatShortcutsRow"
import { RateLimitNotice } from "~/modules/ai-chat/components/layouts/RateLimitNotice"
import type { ShortcutData } from "../../editor"
import { LexicalAIEditorNodes, ShortcutNode } from "../../editor"
interface ChatBottomPanelProps {
hasMessages: boolean
centerInputOnEmpty?: boolean
shouldShowInterruptionNotice: boolean
rateLimitMessage: string | null
isRateLimited: boolean
onRetryLastMessage: () => void
onSendMessage: (message: string | EditorState) => void
initialDraftState?: EditorState
onDraftChange: (state: EditorState) => void
onHeightChange: (height: number) => void
}
export const ChatBottomPanel = ({
hasMessages,
centerInputOnEmpty,
shouldShowInterruptionNotice,
rateLimitMessage,
isRateLimited,
onRetryLastMessage,
onSendMessage,
initialDraftState,
onDraftChange,
onHeightChange,
}: ChatBottomPanelProps) => {
const panelRef = useRef<HTMLDivElement | null>(null)
const t = useI18n()
useLayoutEffect(() => {
const element = panelRef.current
if (!element) return
const updateHeight = () => {
onHeightChange(element.offsetHeight)
}
updateHeight()
const resizeObserver = new ResizeObserver(() => {
updateHeight()
})
resizeObserver.observe(element)
return () => {
resizeObserver.disconnect()
onHeightChange(0)
}
}, [onHeightChange])
const handleShortcutSelect = useCallback(
(shortcutData: ShortcutData) => {
const tempEditor = createEditor({
nodes: LexicalAIEditorNodes,
})
tempEditor.update(
() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
const shortcutNode = new ShortcutNode(shortcutData)
paragraph.append(shortcutNode)
root.append(paragraph)
},
{
discrete: true,
},
)
const editorState = tempEditor.getEditorState()
onSendMessage(editorState)
},
[onSendMessage],
)
return (
<div
ref={panelRef}
data-testid="chat-input-container"
className={cn(
"absolute z-10 mx-auto duration-500 ease-in-out",
"inset-x-0 bottom-0 max-w-4xl px-4 pb-4",
centerInputOnEmpty &&
!hasMessages &&
"bottom-1/2 translate-y-[calc(100%+1rem)] duration-200",
)}
>
{shouldShowInterruptionNotice && (
<m.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="mb-3 flex w-full items-start gap-2 rounded-lg border border-border bg-material-ultra-thick px-3 py-2 text-xs text-text-secondary backdrop-blur-background"
>
<i className="i-mingcute-information-fill size-4 flex-shrink-0 text-text" />
<div className="flex flex-1 items-center justify-between gap-1">
<span>{t.ai("session.interrupted.message")}</span>
{!rateLimitMessage && (
<button
type="button"
onClick={onRetryLastMessage}
className="cursor-button self-start text-xs text-accent duration-200 hover:opacity-80"
>
{t.ai("session.interrupted.retry")}
</button>
)}
</div>
</m.div>
)}
<RateLimitNotice message={rateLimitMessage} />
{!isRateLimited && <ChatShortcutsRow onSelect={handleShortcutSelect} />}
<ChatInput
onSend={onSendMessage}
variant={!hasMessages ? "minimal" : "default"}
initialDraftState={initialDraftState}
onEditorStateChange={onDraftChange}
submitDisabled={isRateLimited}
/>
{(!centerInputOnEmpty || hasMessages) && (
<div className="absolute inset-x-0 bottom-0 isolate">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-44 backdrop-blur-xl backdrop-brightness-110 dark:backdrop-brightness-75"
style={{
maskImage: "linear-gradient(to top, black 0%, rgba(0, 0, 0, 0.6) 25%, transparent)",
WebkitMaskImage:
"linear-gradient(to top, black 0%, rgba(0, 0, 0, 0.6) 25%, transparent)",
}}
/>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-60 bg-gradient-to-b from-background/20 to-background/0"
style={{
maskImage: "linear-gradient(to top, black 20%, transparent 70%)",
WebkitMaskImage: "linear-gradient(to top, black 20%, transparent 70%)",
backdropFilter: "blur(50px) saturate(130%)",
WebkitBackdropFilter: "blur(50px) saturate(130%)",
}}
/>
</div>
)}
</div>
)
}

View File

@ -3,33 +3,21 @@ import {
convertLexicalToMarkdown,
getEditorStateJSONString,
} from "@follow/components/ui/lexical-rich-editor/utils.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { useElementWidth } from "@follow/hooks"
import { getCategoryFeedIds } from "@follow/store/subscription/getter"
import { usePrefetchSummary } from "@follow/store/summary/hooks"
import { tracker } from "@follow/tracker"
import { clsx, cn, detectIsEditableElement, nextFrame } from "@follow/utils"
import { detectIsEditableElement, nextFrame } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import type { EditorState } from "lexical"
import { $createParagraphNode, $getRoot, createEditor } from "lexical"
import { AnimatePresence, m } from "motion/react"
import { createEditor } from "lexical"
import { nanoid } from "nanoid"
import type { FC, RefObject } from "react"
import * as React from "react"
import { Suspense, use, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { use, useEffect, useMemo, useRef, useState } from "react"
import { useEventCallback, useEventListener } from "usehooks-ts"
import { useAISettingKey } from "~/atoms/settings/ai"
import { useActionLanguage } from "~/atoms/settings/general"
import { ROUTE_FEED_IN_FOLDER } from "~/constants"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { useI18n } from "~/hooks/common/useI18n"
import {
AIChatMessage,
AIChatWaitingIndicator,
} from "~/modules/ai-chat/components/message/AIChatMessage"
import { ErrorMessage } from "~/modules/ai-chat/components/message/ErrorMessage"
import { UserChatMessage } from "~/modules/ai-chat/components/message/UserChatMessage"
import { useAutoScroll } from "~/modules/ai-chat/hooks/useAutoScroll"
import { useAutoTimelineSummaryShortcut } from "~/modules/ai-chat/hooks/useAutoTimelineSummaryShortcut"
import { useLoadMessages } from "~/modules/ai-chat/hooks/useLoadMessages"
@ -44,7 +32,7 @@ import {
useMessages,
} from "~/modules/ai-chat/store/hooks"
import { LexicalAIEditorNodes, ShortcutNode } from "../../editor"
import { LexicalAIEditorNodes } from "../../editor"
import { useAIConfiguration } from "../../hooks/useAIConfiguration"
import { useAttachScrollBeyond } from "../../hooks/useAttachScrollBeyond"
import { AIPanelRefsContext } from "../../store/AIChatContext"
@ -52,12 +40,8 @@ import type { AIChatContextBlock, BizUIMessage, SendingUIMessage } from "../../s
import { computeIsRateLimited, computeRateLimitMessage } from "../../utils/rate-limit"
import { GlobalFileDropZone } from "../file/GlobalFileDropZone"
import { AIErrorFallback } from "./AIErrorFallback"
import { ChatInput } from "./ChatInput"
import { ChatShortcutsRow } from "./ChatShortcutsRow"
import { RateLimitNotice } from "./RateLimitNotice"
import { WelcomeScreen } from "./WelcomeScreen"
const SCROLL_BOTTOM_THRESHOLD = 100
import { ChatBottomPanel } from "./ChatBottomPanel"
import { ChatMessageContainer } from "./ChatMessageContainer"
const draftMessages = new Map<string, EditorState>()
const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
@ -66,7 +50,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
const chatActions = useChatActions()
const error = useChatError()
const messages = useMessages()
const t = useI18n()
useAutoTimelineSummaryShortcut()
@ -74,8 +57,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
const aiPanelRefs = use(AIPanelRefsContext)
// Store draft messages for each chatId in memory during browser session
useEventListener("keydown", (e) => {
if (isFocusWithIn) {
const currentActiveElement = document.activeElement
@ -109,13 +90,11 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
})
const [scrollAreaRef, setScrollAreaRef] = useState<HTMLDivElement | null>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [messageContainerMinHeight, setMessageContainerMinHeight] = useState<number | undefined>()
const previousMinHeightRef = useRef<number>(0)
const messagesContentRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
setIsAtBottom(true)
setMessageContainerMinHeight(undefined)
previousMinHeightRef.current = 0
}, [currentChatId])
@ -131,7 +110,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
top: scrollHeight,
})
}
setIsAtBottom(true)
})
},
})
@ -147,6 +125,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
}
const lastMessage = messages.at(-1)!
const shouldShow =
lastMessage.role === "user" &&
status !== "streaming" &&
@ -164,27 +143,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
autoScrollWhenStreaming && status === "streaming",
)
useEffect(() => {
const scrollElement = scrollAreaRef
if (!scrollElement) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollElement
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const atBottom = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD
setIsAtBottom(atBottom)
}
scrollElement.addEventListener("scroll", handleScroll, { passive: true })
handleScroll()
return () => {
scrollElement.removeEventListener("scroll", handleScroll)
}
}, [scrollAreaRef])
const blockActions = useBlockActions()
const scrollHeightBeforeSendingRef = useRef<number>(0)
@ -332,26 +290,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
const initialDraft = currentChatId ? draftMessages.get(currentChatId) : undefined
const [bottomPanelHeight, setBottomPanelHeight] = useState<number>(0)
const bottomPanelRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (!bottomPanelRef.current) {
return
}
setBottomPanelHeight(bottomPanelRef.current.offsetHeight)
const resizeObserver = new ResizeObserver(() => {
if (!bottomPanelRef.current) {
return
}
setBottomPanelHeight(bottomPanelRef.current.offsetHeight)
})
resizeObserver.observe(bottomPanelRef.current)
return () => {
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
if (status === "submitted") {
@ -367,10 +305,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
}
}, [status, resetScrollState, messageContainerMinHeight, scrollAreaRef])
const shouldShowScrollToBottom = hasMessages && !isAtBottom && !isLoadingHistory
const shouldShowLoadingOverlay =
Boolean(currentChatId) && !hasMessages && (isLoadingHistory || isSyncingRemote)
const { handleScroll } = useAttachScrollBeyond()
const { data: configuration } = useAIConfiguration()
@ -389,172 +323,34 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
<div className="flex size-full flex-col @container">
<GlobalFileDropZone className="flex size-full flex-col @container">
<div className="flex min-h-0 flex-1 flex-col" ref={scrollContainerParentRef}>
<AnimatePresence>
{!hasMessages && !shouldShowLoadingOverlay ? (
<WelcomeScreen centerInputOnEmpty={centerInputOnEmpty} />
) : (
<>
{shouldShowLoadingOverlay ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex -translate-y-24 flex-col items-center space-y-2">
<i className="i-mgc-loading-3-cute-re size-8 animate-spin text-text" />
{isSyncingRemote && (
<p className="text-sm text-text-secondary">
Syncing messages from server...
</p>
)}
</div>
</div>
) : null}
<ScrollArea
onScroll={handleScroll}
flex
scrollbarClassName="mt-12"
scrollbarProps={{
style: {
marginBottom: Math.max(160, bottomPanelHeight),
},
}}
ref={setScrollAreaRef}
rootClassName="flex-1"
viewportProps={{
style: {
paddingBottom: Math.max(128, bottomPanelHeight),
},
}}
viewportClassName={"pt-12"}
>
<div
className="mx-auto w-full max-w-4xl px-6 py-8"
style={{
minHeight: messageContainerMinHeight
? `${messageContainerMinHeight}px`
: undefined,
}}
>
<Messages contentRef={messagesContentRef as RefObject<HTMLDivElement>} />
{(status === "submitted" || status === "streaming") && (
<AIChatWaitingIndicator />
)}
</div>
</ScrollArea>
</>
)}
</AnimatePresence>
</div>
{shouldShowScrollToBottom && (
<div
className={clsx("absolute right-1/2 z-40 translate-x-1/2", "bottom-48 -translate-y-2")}
>
<button
type="button"
onClick={() => resetScrollState()}
className={cn(
"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2",
"border-border",
"hover:border-border/60 active:scale-[0.98]",
)}
>
<i className="i-mingcute-arrow-down-line text-text/90" />
</button>
</div>
)}
<div
ref={bottomPanelRef}
data-testid="chat-input-container"
className={cn(
"absolute z-10 mx-auto duration-500 ease-in-out",
"inset-x-0 bottom-0 max-w-4xl px-4 pb-4",
centerInputOnEmpty &&
!hasMessages &&
"bottom-1/2 translate-y-[calc(100%+1rem)] duration-200",
)}
>
{shouldShowInterruptionNotice && (
<m.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="mb-3 flex w-full items-start gap-2 rounded-lg border border-border bg-material-ultra-thick px-3 py-2 text-xs text-text-secondary backdrop-blur-background"
>
<i className="i-mingcute-information-fill size-4 flex-shrink-0 text-text" />
<div className="flex flex-1 items-center justify-between gap-1">
<span>{t.ai("session.interrupted.message")}</span>
{!rateLimitMessage && (
<button
type="button"
onClick={handleRetryLastMessage}
className="cursor-button self-start text-xs text-accent duration-200 hover:opacity-80"
>
{t.ai("session.interrupted.retry")}
</button>
)}
</div>
</m.div>
)}
<RateLimitNotice message={rateLimitMessage} />
{!isRateLimited && (
<ChatShortcutsRow
onSelect={(shortcutData) => {
const tempEditor = createEditor({
nodes: LexicalAIEditorNodes,
})
tempEditor.update(
() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
const shortcutNode = new ShortcutNode(shortcutData)
paragraph.append(shortcutNode)
root.append(paragraph)
},
{
discrete: true,
},
)
const editorState = tempEditor.getEditorState()
handleSendMessage(editorState)
}}
/>
)}
<ChatInput
onSend={handleSendMessage}
variant={!hasMessages ? "minimal" : "default"}
initialDraftState={initialDraft}
onEditorStateChange={handleDraftChange}
submitDisabled={isRateLimited}
<ChatMessageContainer
currentChatId={currentChatId}
hasMessages={hasMessages}
isLoadingHistory={isLoadingHistory}
isSyncingRemote={isSyncingRemote}
bottomPanelHeight={bottomPanelHeight}
messageContainerMinHeight={messageContainerMinHeight}
messagesContentRef={messagesContentRef}
onScroll={handleScroll}
setScrollAreaRef={setScrollAreaRef}
status={status}
centerInputOnEmpty={centerInputOnEmpty}
onScrollToBottom={resetScrollState}
/>
{(!centerInputOnEmpty || hasMessages) && (
<div className="absolute inset-x-0 bottom-0 isolate">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-44 backdrop-blur-xl backdrop-brightness-110 dark:backdrop-brightness-75"
style={{
maskImage:
"linear-gradient(to top, black 0%, rgba(0, 0, 0, 0.6) 25%, transparent)",
WebkitMaskImage:
"linear-gradient(to top, black 0%, rgba(0, 0, 0, 0.6) 25%, transparent)",
}}
/>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-60 bg-gradient-to-b from-background/20 to-background/0"
style={{
maskImage: "linear-gradient(to top, black 20%, transparent 70%)",
WebkitMaskImage: "linear-gradient(to top, black 20%, transparent 70%)",
backdropFilter: "blur(50px) saturate(130%)",
WebkitBackdropFilter: "blur(50px) saturate(130%)",
}}
/>
</div>
)}
</div>
<ChatBottomPanel
hasMessages={hasMessages}
centerInputOnEmpty={centerInputOnEmpty}
shouldShowInterruptionNotice={shouldShowInterruptionNotice}
rateLimitMessage={rateLimitMessage}
isRateLimited={isRateLimited}
onRetryLastMessage={handleRetryLastMessage}
onSendMessage={handleSendMessage}
initialDraftState={initialDraft}
onDraftChange={handleDraftChange}
onHeightChange={setBottomPanelHeight}
/>
</GlobalFileDropZone>
</div>
)
@ -568,38 +364,3 @@ export const ChatInterface = (props: ChatInterfaceProps) => (
<ChatInterfaceContent {...props} />
</ErrorBoundary>
)
export const Messages: FC<{ contentRef?: RefObject<HTMLDivElement> }> = ({ contentRef }) => {
const messages = useMessages()
const error = useChatError()
const fallbackRef = useRef<HTMLDivElement>(null)
const messageContainerWidth = useElementWidth(contentRef ?? fallbackRef)
return (
<div
ref={contentRef}
className="relative flex min-w-0 flex-1 flex-col"
style={
{
"--ai-chat-message-container-width": `${messageContainerWidth}px`,
} as React.CSSProperties
}
>
{!!messageContainerWidth &&
messages.map((message, index) => {
const isLastMessage = index === messages.length - 1
return (
<Suspense key={message.id}>
{message.role === "user" ? (
<UserChatMessage message={message} />
) : (
<AIChatMessage message={message} isLastMessage={isLastMessage} />
)}
</Suspense>
)
})}
{/* Render error as the last message in the list */}
{!!messageContainerWidth && error && <ErrorMessage error={error} />}
</div>
)
}

View File

@ -0,0 +1,124 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { AnimatePresence } from "motion/react"
import type { RefObject, UIEventHandler } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { WelcomeScreen } from "~/modules/ai-chat/components/layouts/WelcomeScreen"
import { AIChatWaitingIndicator } from "~/modules/ai-chat/components/message/AIChatMessage"
import type { ChatStatus } from "~/modules/ai-chat/store/slices"
import { Messages } from "./Messages"
import { ScrollToBottomButton } from "./ScrollToBottomButton"
const SCROLL_BOTTOM_THRESHOLD = 100
interface ChatMessageContainerProps {
currentChatId: string | null
hasMessages: boolean
isLoadingHistory: boolean
isSyncingRemote: boolean
bottomPanelHeight: number
messageContainerMinHeight?: number
messagesContentRef: RefObject<HTMLDivElement | null>
onScroll: UIEventHandler<HTMLDivElement>
setScrollAreaRef: (instance: HTMLDivElement | null) => void
status: ChatStatus
centerInputOnEmpty?: boolean
onScrollToBottom: () => void
}
export const ChatMessageContainer = ({
currentChatId,
hasMessages,
isLoadingHistory,
isSyncingRemote,
bottomPanelHeight,
messageContainerMinHeight,
messagesContentRef,
onScroll,
setScrollAreaRef,
status,
centerInputOnEmpty,
onScrollToBottom,
}: ChatMessageContainerProps) => {
const [isAtBottom, setIsAtBottom] = useState(true)
useEffect(() => {
setIsAtBottom(true)
}, [currentChatId])
const shouldShowLoadingOverlay = useMemo(() => {
return Boolean(currentChatId) && !hasMessages && (isLoadingHistory || isSyncingRemote)
}, [currentChatId, hasMessages, isLoadingHistory, isSyncingRemote])
const shouldShowScrollToBottom = useMemo(() => {
return hasMessages && !isAtBottom && !isLoadingHistory
}, [hasMessages, isAtBottom, isLoadingHistory])
const handleScrollEvent = useCallback<UIEventHandler<HTMLDivElement>>(
(event) => {
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const atBottom = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD
if (atBottom !== isAtBottom) {
setIsAtBottom(atBottom)
}
onScroll(event)
},
[isAtBottom, onScroll],
)
return (
<>
<AnimatePresence>
{!hasMessages && !shouldShowLoadingOverlay ? (
<WelcomeScreen centerInputOnEmpty={centerInputOnEmpty} />
) : (
<>
{shouldShowLoadingOverlay ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex -translate-y-24 flex-col items-center space-y-2">
<i className="i-mgc-loading-3-cute-re size-8 animate-spin text-text" />
{isSyncingRemote && (
<p className="text-sm text-text-secondary">Syncing messages from server...</p>
)}
</div>
</div>
) : null}
<ScrollArea
onScroll={handleScrollEvent}
flex
scrollbarClassName="mt-12"
scrollbarProps={{
style: {
marginBottom: Math.max(160, bottomPanelHeight),
},
}}
ref={setScrollAreaRef}
rootClassName="flex-1"
viewportProps={{
style: {
paddingBottom: Math.max(128, bottomPanelHeight),
},
}}
viewportClassName="pt-12"
>
<div
className="mx-auto w-full max-w-4xl px-6 py-8"
style={{
minHeight: messageContainerMinHeight
? `${messageContainerMinHeight}px`
: undefined,
}}
>
<Messages contentRef={messagesContentRef} />
{(status === "submitted" || status === "streaming") && <AIChatWaitingIndicator />}
</div>
</ScrollArea>
</>
)}
</AnimatePresence>
{shouldShowScrollToBottom && <ScrollToBottomButton onClick={onScrollToBottom} />}
</>
)
}

View File

@ -0,0 +1,44 @@
import { useElementWidth } from "@follow/hooks"
import type { CSSProperties, FC, RefObject } from "react"
import { Suspense, useRef } from "react"
import { AIChatMessage } from "~/modules/ai-chat/components/message/AIChatMessage"
import { ErrorMessage } from "~/modules/ai-chat/components/message/ErrorMessage"
import { UserChatMessage } from "~/modules/ai-chat/components/message/UserChatMessage"
import { useChatError, useMessages } from "~/modules/ai-chat/store/hooks"
interface MessagesProps {
contentRef?: RefObject<HTMLDivElement | null>
}
export const Messages: FC<MessagesProps> = ({ contentRef }) => {
const messages = useMessages()
const error = useChatError()
const fallbackRef = useRef<HTMLDivElement>(null)
const effectiveRef = contentRef ?? fallbackRef
const messageContainerWidth = useElementWidth(effectiveRef)
const style = messageContainerWidth
? ({ "--ai-chat-message-container-width": `${messageContainerWidth}px` } as CSSProperties)
: undefined
return (
<div ref={effectiveRef} className="relative flex min-w-0 flex-1 flex-col" style={style}>
{!!messageContainerWidth &&
messages.map((message, index) => {
const isLastMessage = index === messages.length - 1
return (
<Suspense key={message.id}>
{message.role === "user" ? (
<UserChatMessage message={message} />
) : (
<AIChatMessage message={message} isLastMessage={isLastMessage} />
)}
</Suspense>
)
})}
{!!messageContainerWidth && error && <ErrorMessage error={error} />}
</div>
)
}

View File

@ -0,0 +1,23 @@
import { clsx, cn } from "@follow/utils"
interface ScrollToBottomButtonProps {
onClick: () => void
}
export const ScrollToBottomButton = ({ onClick }: ScrollToBottomButtonProps) => {
return (
<div className={clsx("absolute right-1/2 z-40 translate-x-1/2", "bottom-48 -translate-y-2")}>
<button
type="button"
onClick={onClick}
className={cn(
"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2",
"border-border",
"hover:border-border/60 active:scale-[0.98]",
)}
>
<i className="i-mingcute-arrow-down-line text-text/90" />
</button>
</div>
)
}

View File

@ -2,6 +2,7 @@ import { convertLexicalToMarkdown } from "@follow/components/ui/lexical-rich-edi
import { FeedViewType } from "@follow/constants"
import { DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID } from "@follow/shared/settings/defaults"
import { getCategoryFeedIds } from "@follow/store/subscription/getter"
import { asyncableNextFrame } from "@follow/utils"
import type { LexicalEditor } from "lexical"
import { $createParagraphNode, $getRoot, createEditor } from "lexical"
import { nanoid } from "nanoid"
@ -259,33 +260,40 @@ export const useAutoTimelineSummaryShortcut = () => {
})
await chatActions.switchToChat(timelineSummaryChatId)
blockActions.clearBlocks({ keepSpecialTypes: true })
const tempEditor = createEditor({
nodes: LexicalAIEditorNodes,
await asyncableNextFrame(async () => {
// wait switch to chat completed
if (chatActions.get().chatId !== timelineSummaryChatId) {
return
}
blockActions.clearBlocks({ keepSpecialTypes: true })
const tempEditor = createEditor({
nodes: LexicalAIEditorNodes,
})
tempEditor.update(
() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
const shortcutNode = new ShortcutNode({ id, name, prompt })
paragraph.append(shortcutNode)
root.append(paragraph)
},
{
discrete: true,
},
)
const message = buildSummaryMessage(tempEditor, contextBlocks, nanoid())
await chatActions.sendMessage(message, {
body: { scene: "general" },
})
automationStateRef.current.failed = false
})
tempEditor.update(
() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
const shortcutNode = new ShortcutNode({ id, name, prompt })
paragraph.append(shortcutNode)
root.append(paragraph)
},
{
discrete: true,
},
)
const message = buildSummaryMessage(tempEditor, contextBlocks, nanoid())
await chatActions.sendMessage(message, {
body: { scene: "general" },
})
automationStateRef.current.failed = false
} catch (error) {
automationStateRef.current.failed = true
console.error("[AI Chat] Failed to auto-run timeline summary shortcut:", error)

View File

@ -334,7 +334,7 @@ export class ChatSliceActions {
syncStatus: chatSession ? chatSession.syncStatus : "local",
}))
newChatInstance.resumeStream()
await newChatInstance.resumeStream()
// Update the reference
this.chatInstance = newChatInstance
} catch (error) {

View File

@ -15,6 +15,13 @@ export const nextFrame = (fn: (...args: any[]) => any) => {
if (timer2) cancelAnimationFrame(timer2)
}
}
export const asyncableNextFrame = <T>(fn: (...args: any[]) => Promise<T>, timeout = 0) => {
return new Promise((resolve) => {
setTimeout(() => {
fn().then(resolve)
}, timeout)
})
}
export const getElementTop = (element: HTMLElement) => {
let actualTop = element.offsetTop