diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx index 7e8a5fee9..b6e22ed55 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx @@ -7,13 +7,14 @@ import { cva } from "class-variance-authority" import { noop } from "es-toolkit" import type { EditorState, LexicalEditor } from "lexical" import { $getRoot } from "lexical" -import { memo, use, useCallback, useRef, useState } from "react" +import type { Ref } from "react" +import { memo, use, useCallback, useImperativeHandle, useRef, useState } from "react" import { AIChatContextBar } from "~/modules/ai-chat/components/layouts/AIChatContextBar" import { FileUploadPlugin, MentionPlugin } from "../../editor" import { AIPanelRefsContext } from "../../store/AIChatContext" -import { useChatActions, useChatStatus } from "../../store/hooks" +import { useChatActions, useChatScene, useChatStatus } from "../../store/hooks" import { AIChatSendButton } from "./AIChatSendButton" import { AIModelIndicator } from "./AIModelIndicator" @@ -38,9 +39,10 @@ const chatInputVariants = cva( interface ChatInputProps extends VariantProps { onSend: (message: EditorState | string, editor: LexicalEditor | null) => void + ref?: Ref } -export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => { +export const ChatInput = memo(({ onSend, variant, ref: forwardedRef }: ChatInputProps) => { const status = useChatStatus() const chatActions = useChatActions() @@ -48,12 +50,20 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => { chatActions.stop() }, [chatActions]) - const editorRef = useRef(null) + const editorRef = useRef(null) + + useImperativeHandle( + forwardedRef, + () => editorRef.current, + // eslint-disable-next-line react-hooks/exhaustive-deps + [editorRef.current], + ) const aiPanelRefs = use(AIPanelRefsContext) if (editorRef.current) { aiPanelRefs.inputRef.current = editorRef.current } + const [isEmpty, setIsEmpty] = useState(true) const [currentEditor, setCurrentEditor] = useState(null) @@ -91,6 +101,8 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => { }) }, []) + const scene = useChatScene() + return (
{/* Input Area */} @@ -98,12 +110,12 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => { @@ -117,22 +129,24 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
- {/* Context Bar - Always shown, positioned below the input area */} -
-
-
- onSend(prompt, null)} + {/* Context Bar - only shown in non-onboarding scene, positioned below the input area */} + {scene !== "onboarding" && ( +
+
+
+ onSend(prompt, null)} + /> +
+
-
-
+ )}
) }) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx index b71d5a871..02bdd2142 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx @@ -412,7 +412,7 @@ export const ChatInterface = (props: ChatInterfaceProps) => ( ) -const Messages: FC<{ contentRef?: RefObject }> = ({ contentRef }) => { +export const Messages: FC<{ contentRef?: RefObject }> = ({ contentRef }) => { const messages = useMessages() const fallbackRef = useRef(null) const messageContainerWidth = useElementWidth(contentRef ?? fallbackRef) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx index c8d6488bc..db3cc5d2c 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx @@ -5,7 +5,7 @@ import { AnimatePresence, m } from "motion/react" import * as React from "react" import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai-chat/atoms/session" -import { useChatActions, useChatStatus } from "~/modules/ai-chat/store/hooks" +import { useChatActions, useChatScene, useChatStatus } from "~/modules/ai-chat/store/hooks" import type { AIChatContextBlock, BizUIMessage } from "~/modules/ai-chat/store/types" import { convertLexicalToMarkdown } from "../../utils/lexical-markdown" @@ -91,11 +91,13 @@ export const UserChatMessage: React.FC = React.memo(({ mes chatActions.regenerate({ messageId }) }, [chatActions, messageId]) + const scene = useChatScene() + return (
{/* Render data-block parts separately, outside the chat bubble */} - {dataBlockParts.length > 0 && ( + {dataBlockParts.length > 0 && scene !== "onboarding" && dataBlockParts.length > 0 && (
{dataBlockParts.map((part) => { diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts index 0d0a87b62..04fc40716 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts @@ -1,5 +1,6 @@ import { autoBindThis } from "@follow/utils/bind-this" import type { ChatRequestOptions, ChatStatus } from "ai" +import { merge } from "es-toolkit/compat" import { nanoid } from "nanoid" import type { StateCreator } from "zustand" @@ -158,7 +159,13 @@ export class ChatSliceActions { : (message as Parameters[0]) // Use the AI SDK's sendMessage method - const response = await this.chatInstance.sendMessage(messageObj, options) + const finalOptions = merge( + { + body: { scene: this.get().scene }, + }, + options, + ) + const response = await this.chatInstance.sendMessage(messageObj, finalOptions) return response } catch (error) { this.setError(error as Error) @@ -276,4 +283,8 @@ export class ChatSliceActions { throw error } } + + setScene = (scene: ChatSlice["scene"]) => { + this.set((state) => ({ ...state, scene })) + } } diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/types.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/types.ts index 3be582f45..2373a5582 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/types.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/types.ts @@ -20,4 +20,7 @@ export interface ChatSlice { // Actions chatActions: ChatSliceActions + + // Scene + scene: "general" | "onboarding" | "timeline-summary" } diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/hooks.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/hooks.ts index e48419ac8..ea94e86dd 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/hooks.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/hooks.ts @@ -80,3 +80,11 @@ export const useChatError = () => { const store = useAIChatStore() return store((state) => state.error) } + +/** + * Hook to get the chat scene + */ +export const useChatScene = () => { + const store = useAIChatStore() + return store((state) => state.scene) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/chat.slice.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/chat.slice.ts index 54d0ca56a..aa352c766 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/chat.slice.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/chat.slice.ts @@ -77,5 +77,6 @@ export const createChatSlice: (options: { currentTitle: undefined, chatInstance, chatActions, + scene: "general", } } diff --git a/apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedCard.tsx b/apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedCard.tsx index 741756910..a81bacd2d 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedCard.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedCard.tsx @@ -166,7 +166,7 @@ export const DiscoverFeedCard: FC = memo( }, ) -const SearchResultContent: FC<{ +export const SearchResultContent: FC<{ entry: NonUndefined[number] }> = memo(({ entry }) => { const safeUrl = useFeedSafeUrl(entry.id) diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx new file mode 100644 index 000000000..822cefd5d --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx @@ -0,0 +1,526 @@ +import { Logo } from "@follow/components/icons/logo.jsx" +import { AIShortcutButton } from "@follow/components/ui/ai-shortcut-button/index.js" +import { Button } from "@follow/components/ui/button/index.js" +import type { LexicalRichEditorRef } from "@follow/components/ui/lexical-rich-editor/index.js" +import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js" +import { tracker } from "@follow/tracker" +import { nextFrame } from "@follow/utils" +import { cn } from "@follow/utils/utils" +import { AnimatePresence } from "framer-motion" +import { useSetAtom } from "jotai" +import type { EditorState, LexicalEditor } from "lexical" +import { $getRoot, $getSelection, $isRangeSelection } from "lexical" +import { nanoid } from "nanoid" +import type { RefObject } from "react" +import { Fragment, useEffect, useLayoutEffect, useRef, useState } from "react" +import { useEventCallback } from "usehooks-ts" + +import { useI18n } from "~/hooks/common" +import { ChatInput } from "~/modules/ai-chat/components/layouts/ChatInput" +import { Messages } from "~/modules/ai-chat/components/layouts/ChatInterface" +import { useAttachScrollBeyond } from "~/modules/ai-chat/hooks/useAttachScrollBeyond" +import { useAutoScroll } from "~/modules/ai-chat/hooks/useAutoScroll" +import { + useBlockActions, + useChatActions, + useChatError, + useChatStatus, + useCurrentChatId, + useHasMessages, + useMessages, +} from "~/modules/ai-chat/store/hooks" +import type { AIChatContextBlock, BizUIMessage } from "~/modules/ai-chat/store/types" +import { convertLexicalToMarkdown } from "~/modules/ai-chat/utils/lexical-markdown" + +import { CollapsibleError } from "../ai-chat/components/layouts/CollapsibleError" +import { AIChatWaitingIndicator } from "../ai-chat/components/message/AIChatMessage" +import { stepAtom } from "./store" + +const SUGGESTION_KEYS = [ + "new_user_guide.ai_chat.suggestions.fashion_designer", + "new_user_guide.ai_chat.suggestions.nano_engineering_researcher", + "new_user_guide.ai_chat.suggestions.drug_delivery_student", + "new_user_guide.ai_chat.suggestions.investor_market_news", + "new_user_guide.ai_chat.suggestions.nasa_fan", + "new_user_guide.ai_chat.suggestions.climate_newsletter_writer", + "new_user_guide.ai_chat.suggestions.plant_based_cooking", + "new_user_guide.ai_chat.suggestions.cybersecurity_tracker", + "new_user_guide.ai_chat.suggestions.japan_trip_planner", + "new_user_guide.ai_chat.suggestions.podcast_summary_seeker", + "new_user_guide.ai_chat.suggestions.personal_finance_builder", + "new_user_guide.ai_chat.suggestions.robotics_coach", + "new_user_guide.ai_chat.suggestions.saas_marketing_manager", + "new_user_guide.ai_chat.suggestions.ai_regulation_learner", +] as const + +const SUGGESTION_SAMPLE_SIZE = 5 + +type SuggestionKey = (typeof SUGGESTION_KEYS)[number] + +function pickSuggestionKeys(previous?: readonly SuggestionKey[]): SuggestionKey[] { + const shuffle = (input: readonly SuggestionKey[]) => { + const pool = [...input] as SuggestionKey[] + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[pool[i], pool[j]] = [pool[j]!, pool[i]!] + } + return pool + } + + if (!previous || previous.length === 0) { + return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE) + } + + const previousSet = new Set(previous) + const available = SUGGESTION_KEYS.filter((key) => !previousSet.has(key)) + + if (available.length >= SUGGESTION_SAMPLE_SIZE) { + return shuffle(available).slice(0, SUGGESTION_SAMPLE_SIZE) + } + + // When there aren't enough unique suggestions left, attempt to find a fully new batch. + const maxAttempts = 10 + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const candidate = shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE) + if (!candidate.some((key) => previousSet.has(key))) { + return candidate + } + } + + return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE) +} + +export function AIChatPane() { + return ( +
+ +
+ ) +} + +function AIChatPaneImpl() { + const t = useI18n() + + const setStep = useSetAtom(stepAtom) + + const hasMessages = useHasMessages() + const chatInputRef = useRef(null) + + const appendSuggestionToInput = (suggestion: string) => { + const ref = chatInputRef.current + const editor = ref?.getEditor() + + if (!editor) { + return + } + + editor.focus() + editor.update(() => { + const root = $getRoot() + const currentText = root.getTextContent() + const needsLeadingSpace = currentText.length > 0 && !currentText.endsWith(" ") + const textToInsert = needsLeadingSpace ? ` ${suggestion}` : suggestion + + root.selectEnd() + let selection = $getSelection() + + if (!$isRangeSelection(selection)) { + root.selectEnd() + selection = $getSelection() + } + + if ($isRangeSelection(selection)) { + selection.insertText(textToInsert) + } + }) + } + + return ( +
+
+ + + +
+ + + {!hasMessages && } + + +
+ +
+
+ ) +} + +interface WelcomeProps { + onSuggestionClick: (suggestion: string) => void +} + +function Welcome({ onSuggestionClick }: WelcomeProps) { + const t = useI18n() + const [suggestionKeys, setSuggestionKeys] = useState(() => pickSuggestionKeys()) + + const onClickSuggestion = useEventCallback((suggestion: string) => { + onSuggestionClick(suggestion) + }) + + const rerollSuggestions = useEventCallback(() => { + setSuggestionKeys((prev) => pickSuggestionKeys(prev)) + }) + + return ( +
+
+
+

+ {(t.app("new_user_guide.ai_chat.intro") as string).split("\n").map((line) => ( + + {line} +
+
+ ))} +

+
+
+ +
+
+

+ {t.app("new_user_guide.ai_chat.you_can_say")} +

+ +
+
+ {suggestionKeys.map((suggestionKey, index) => { + const suggestionText = t.app(suggestionKey) as string + return ( + onClickSuggestion(suggestionText)} + animationDelay={index * 0.05} + className={cn( + "font-normal text-black shadow-[0_12px_30px_rgba(15,15,15,0.35)] hover:text-black", + gradientByIndex(index), + )} + > + {suggestionText} + + ) + })} +
+
+ + +
+ ) +} + +// if the chat response has `tool-onboardingGetTrendingFeedsTool`, set the step to pre-finish +function FinishListener() { + const chatMessages = useMessages() + const setStep = useSetAtom(stepAtom) + useEffect(() => { + const hasCalledConfirmTool = chatMessages.some((msg) => + // @ts-expect-error TODO: fix this after version published + msg.parts.some((p) => p.type === "tool-onboardingGetTrendingFeeds"), + ) + if (hasCalledConfirmTool) { + setStep("pre-finish") + } + }, [chatMessages, setStep]) + + return null +} + +const SCROLL_BOTTOM_THRESHOLD = 100 + +interface AIChatInterfaceProps { + inputRef?: RefObject +} + +function AIChatInterface({ inputRef }: AIChatInterfaceProps) { + const hasMessages = useHasMessages() + const status = useChatStatus() + const chatActions = useChatActions() + const error = useChatError() + const t = useI18n() + + useEffect(() => { + if (error) { + console.error("AIChat Error:", error) + } + }, [error]) + + // on init, set the scene to onboarding + useEffect(() => { + chatActions.setScene("onboarding") + + return () => { + // reset the scene to general + chatActions.setScene("general") + } + }, [chatActions]) + + const currentChatId = useCurrentChatId() + + const [scrollAreaRef, setScrollAreaRef] = useState(null) + const [isAtBottom, setIsAtBottom] = useState(true) + const [messageContainerMinHeight, setMessageContainerMinHeight] = useState() + const previousMinHeightRef = useRef(0) + const messagesContentRef = useRef(null) + + useEffect(() => { + setIsAtBottom(true) + setMessageContainerMinHeight(undefined) + previousMinHeightRef.current = 0 + }, [currentChatId]) + + const { resetScrollState } = useAutoScroll(scrollAreaRef, status === "streaming") + + const { handleScroll } = useAttachScrollBeyond() + + 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(0) + const scrollContainerParentRef = useRef(null) + const handleScrollPositioning = useEventCallback(() => { + const $scrollContainerParent = scrollContainerParentRef.current + if (!scrollAreaRef || !$scrollContainerParent) return + + const parentClientHeight = $scrollContainerParent.clientHeight + // Use actual content height captured before send (messages container height), not inflated by minHeight + const currentScrollHeight = scrollHeightBeforeSendingRef.current + + // Calculate new minimum height based on actual content height + // Use previousMinHeightRef which tracks the real content height, not reserved space + const baseHeight = Math.max(previousMinHeightRef.current, currentScrollHeight) + const newMinHeight = baseHeight + parentClientHeight - 250 + + setMessageContainerMinHeight(newMinHeight) + + // Scroll to the end immediately to position user message at top + nextFrame(() => { + scrollAreaRef.scrollTo({ + top: scrollAreaRef.scrollHeight, + behavior: "instant", + }) + }) + }) + + const handleSendMessage = useEventCallback( + (message: string | EditorState, editor: LexicalEditor | null) => { + resetScrollState() + + const blocks = [] as AIChatContextBlock[] + + for (const block of blockActions.getBlocks()) { + if (block.type === "fileAttachment" && block.attachment.serverUrl) { + blocks.push({ + ...block, + attachment: { + id: block.attachment.id, + name: block.attachment.name, + type: block.attachment.type, + size: block.attachment.size, + serverUrl: block.attachment.serverUrl, + }, + }) + } else { + blocks.push(block) + } + } + + const parts: BizUIMessage["parts"] = [ + { + type: "data-block", + data: blocks, + }, + ] + + if (typeof message === "string") { + parts.push({ + type: "text", + text: message, + }) + } else if (editor) { + parts.push({ + type: "data-rich-text", + data: { + state: JSON.stringify(message.toJSON()), + text: convertLexicalToMarkdown(editor), + }, + }) + } + + // Capture actual content height (messages container), not including reserved minHeight + scrollHeightBeforeSendingRef.current = messagesContentRef.current?.scrollHeight ?? 0 + chatActions.sendMessage({ + parts, + role: "user", + id: nanoid(), + }) + tracker.aiChatMessageSent() + + nextFrame(() => { + // Calculate and adjust scroll positioning immediately + handleScrollPositioning() + }) + }, + ) + + const [bottomPanelHeight, setBottomPanelHeight] = useState(0) + const bottomPanelRef = useRef(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") { + resetScrollState() + } + + // When AI response is complete, update the reference height but keep the container height unchanged + // This avoids CLS while ensuring next calculation is based on actual content + if (status === "ready" && scrollAreaRef && messagesContentRef.current) { + // Update the reference to actual content height for next calculation (use messages container) + previousMinHeightRef.current = messagesContentRef.current.scrollHeight + // Keep the current minHeight unchanged to avoid CLS + } + }, [status, resetScrollState, messageContainerMinHeight, scrollAreaRef]) + + const shouldShowScrollToBottom = hasMessages && !isAtBottom + + const messages = useMessages() + const setStep = useSetAtom(stepAtom) + + return ( +
+ +
+ } /> + + {/* if the last message is from ai, show "Next Step" button */} + {messages.length > 0 && messages.at(-1)?.role === "assistant" && status === "ready" && ( +
+ +
+ )} + + {(status === "submitted" || status === "streaming") && } +
+
+ + {shouldShowScrollToBottom && ( +
+ +
+ )} + +
+ {error && } + +
+
+ ) +} + +const GRADIENT_CLASSES = [ + "bg-gradient-to-r from-[#ff8a00] to-[#ff5f2d]", + "bg-gradient-to-r from-[#0ccb8b] to-[#00a36c]", + "bg-gradient-to-r from-[#ffd700] to-[#ffa800]", + "bg-gradient-to-r from-[#9b5df5] to-[#7848ff]", + "bg-gradient-to-r from-[#ff5dc5] to-[#7a5cff]", +] + +function gradientByIndex(index: number) { + return GRADIENT_CLASSES[index % GRADIENT_CLASSES.length] +} diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx new file mode 100644 index 000000000..2276486e0 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx @@ -0,0 +1,25 @@ +import { Button } from "@follow/components/ui/button/index.js" +import { useSetAtom } from "jotai" + +import { useI18n } from "~/hooks/common" + +import { DiscoverImport } from "../discover/DiscoverImport" +import { stepAtom } from "./store" + +export function DiscoverImportStep() { + const t = useI18n() + const setStep = useSetAtom(stepAtom) + return ( +
+ + +
+ + + +
+
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx new file mode 100644 index 000000000..47a448a1d --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx @@ -0,0 +1,207 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@follow/components/ui/card/index.jsx" +import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js" +import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js" +import { cn } from "@follow/utils/utils" +import type { PrimitiveAtom } from "jotai" +import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai" +import { AnimatePresence, m } from "motion/react" +import { useEffect, useMemo, useRef } from "react" + +import { useI18n } from "~/hooks/common" + +import { useMessages } from "../ai-chat/store/hooks" +import { SearchResultContent } from "../discover/DiscoverFeedCard" +import { FeedIcon } from "../feed/feed-icon" +import type { FeedSelection } from "./store" +import { feedSelectionAtomsAtom, selectedFeedSelectionAtomsAtom } from "./store" + +type FeedToSelect = Omit + +export function FeedsSelectionList() { + const chatMessages = useMessages() + + const hasFeedsSelection = chatMessages.some((msg) => + // @ts-expect-error TODO: fix this after version published + msg.parts.some((p) => p.type === "tool-onboardingGetTrendingFeeds" && p.output), + ) + + return ( +
+ + {hasFeedsSelection ? : } + +
+ ) +} + +function FeedSelectionOperationScreen() { + const chatMessages = useMessages() + + const feedsToSelect: FeedToSelect[] = useMemo( + () => + // find the last message that has the tool + chatMessages + .findLast((m) => + // @ts-expect-error TODO: fix this after version published + m.parts?.some((p) => p.type === "tool-onboardingGetTrendingFeeds"), + ) + // @ts-expect-error TODO: fix this after version published + ?.parts?.findLast((p) => p.type === "tool-onboardingGetTrendingFeeds")?.output ?? [], + [chatMessages], + ) + + const store = useStore() + const atomList = useAtomValue(feedSelectionAtomsAtom) + const dispatch = useSetAtom(feedSelectionAtomsAtom) + + const lastKeyRef = useRef(null) + + const outputKey = useMemo(() => { + const ids = Array.from(new Set(feedsToSelect.map((f) => String(f.id)))) + ids.sort() + return ids.join("|") + }, [feedsToSelect]) + + const existingIds = useMemo( + () => new Set(atomList.map((a) => String(store.get(a).id))), + [atomList, store], + ) + + useEffect(() => { + if (lastKeyRef.current === outputKey) return + lastKeyRef.current = outputKey + + const seen = new Set(existingIds) + + for (const feed of feedsToSelect) { + const id = String(feed.id) + if (seen.has(id)) continue + seen.add(id) + + dispatch({ + type: "insert", + value: { ...feed, selected: true }, + }) + } + }, [dispatch, feedsToSelect, existingIds, outputKey]) + + const selectedAtoms = useAtomValue(selectedFeedSelectionAtomsAtom) + const items = useMemo( + () => selectedAtoms.map((atom) => ({ atom, id: store.get(atom).id })), + [selectedAtoms, store], + ) + + return ( + +
+ + {items.map(({ atom, id }) => ( + + + + ))} + +
+
+ ) +} + +function FeedSelectionItem({ feedAtom }: { feedAtom: PrimitiveAtom }) { + const [feed, setFeed] = useAtom(feedAtom) + + const onRemove = () => { + setFeed((prev) => ({ + ...prev, + selected: false, + })) + } + + return ( +
+ {/* remove button */} + + + + + Remove + + + + +
+ +
+

{feed.title}

+

{feed.url}

+
+
+
+ + + + {feed.description} + + +
+ {feed.entries?.map((entry) => ( + + ))} +
+
+
+
+ ) +} + +function FeedSelectionFirstScreen() { + const t = useI18n() + + return ( +