+
+
+
+
{
role: "user",
id: nanoid(),
})
+ tracker.aiChatMessageSent()
},
)
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/components/message/AIRichTextMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/components/message/AIRichTextMessage.tsx
index f4cef9c96..3227f698b 100644
--- a/apps/desktop/layer/renderer/src/modules/ai/chat/components/message/AIRichTextMessage.tsx
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/components/message/AIRichTextMessage.tsx
@@ -9,6 +9,8 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import type { SerializedEditorState } from "lexical"
import * as React from "react"
+import { MentionNode } from "../../editor/plugins/mention/MentionNode"
+
function onError(error: Error) {
console.error("Lexical Read-Only Editor Error:", error)
}
@@ -29,7 +31,7 @@ export const AIRichTextMessage: React.FC = React.memo(
onError,
editable: false, // Read-only mode
editorState: JSON.stringify(data.state),
- nodes: LexicalRichEditorNodes,
+ nodes: [...LexicalRichEditorNodes, MentionNode],
}
return (
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/index.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/index.ts
new file mode 100644
index 000000000..8ea7bb21d
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/index.ts
@@ -0,0 +1 @@
+export * from "./plugins"
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/index.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/index.tsx
new file mode 100644
index 000000000..880d97c42
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/index.tsx
@@ -0,0 +1 @@
+export * from "./mention"
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionNode.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionNode.tsx
new file mode 100644
index 000000000..0c83d92d6
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionNode.tsx
@@ -0,0 +1,171 @@
+import type {
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ LexicalEditor,
+ LexicalNode,
+ NodeKey,
+ SerializedLexicalNode,
+ Spread,
+} from "lexical"
+import { $applyNodeReplacement, DecoratorNode } from "lexical"
+import * as React from "react"
+
+import type { MentionData } from "./types"
+
+export type SerializedMentionNode = Spread<
+ {
+ mentionData: MentionData
+ },
+ SerializedLexicalNode
+>
+
+const MentionComponent = React.lazy(() =>
+ import("./components/MentionComponent").then((module) => ({
+ default: module.MentionComponent,
+ })),
+)
+
+export class MentionNode extends DecoratorNode {
+ __mentionData: MentionData
+
+ static override getType(): string {
+ return "mention"
+ }
+
+ static override clone(node: MentionNode): MentionNode {
+ return new MentionNode(node.__mentionData, node.__key)
+ }
+
+ constructor(mentionData: MentionData, key?: NodeKey) {
+ super(key)
+ this.__mentionData = mentionData
+ }
+
+ override createDOM(config: EditorConfig): HTMLElement {
+ const dom = document.createElement("span")
+ dom.className = config.theme.mention || "mention-node"
+ dom.dataset.lexicalMention = "true"
+ dom.dataset.mentionType = this.__mentionData.type
+ dom.dataset.mentionId = this.__mentionData.id
+ return dom
+ }
+
+ override updateDOM(): false {
+ return false
+ }
+
+ static override importDOM(): DOMConversionMap | null {
+ return {
+ span: (domNode: HTMLElement) => {
+ if (!Object.hasOwn(domNode.dataset, "lexicalMention")) {
+ return null
+ }
+ return {
+ conversion: convertMentionElement,
+ priority: 1,
+ }
+ },
+ }
+ }
+
+ static override importJSON(serializedNode: SerializedMentionNode): MentionNode {
+ const { mentionData } = serializedNode
+ const node = $createMentionNode(mentionData)
+ return node
+ }
+
+ override exportDOM(): DOMExportOutput {
+ const element = document.createElement("span")
+ element.dataset.lexicalMention = "true"
+ element.dataset.mentionType = this.__mentionData.type
+ element.dataset.mentionId = this.__mentionData.id
+ element.textContent = `@${this.__mentionData.name}`
+ element.className = "mention-node"
+ return { element }
+ }
+
+ override exportJSON(): SerializedMentionNode {
+ return {
+ mentionData: this.__mentionData,
+ type: "mention",
+ version: 1,
+ }
+ }
+
+ /**
+ * For export markdown conversion
+ */
+ override getTextContent(): string {
+ return `[[ref:${this.__mentionData.type}:${this.__mentionData.value}]]`
+ }
+
+ override decorate(_editor: LexicalEditor): React.JSX.Element {
+ return (
+ @{this.__mentionData.name}}>
+
+
+ )
+ }
+
+ override isInline(): boolean {
+ return true
+ }
+
+ override isKeyboardSelectable(): boolean {
+ return false
+ }
+
+ canInsertTextBefore(): boolean {
+ return false
+ }
+
+ canInsertTextAfter(): boolean {
+ return true
+ }
+
+ canBeEmpty(): boolean {
+ return false
+ }
+
+ isSegmented(): boolean {
+ return true
+ }
+
+ extractWithChild(): boolean {
+ return false
+ }
+}
+
+function convertMentionElement(domNode: HTMLElement): DOMConversionOutput {
+ const mentionType = domNode.dataset.mentionType as MentionData["type"] | null
+ const { mentionId } = domNode.dataset
+ const textContent = domNode.textContent || ""
+
+ if (!mentionType || !mentionId) {
+ return { node: null }
+ }
+
+ // Extract name from text content (remove @ prefix)
+ const name = textContent.startsWith("@") ? textContent.slice(1) : textContent
+
+ const mentionData: MentionData = {
+ id: mentionId,
+ name,
+ type: mentionType,
+ value: null,
+ }
+
+ const node = $createMentionNode(mentionData)
+ return { node }
+}
+
+export function $createMentionNode(mentionData: MentionData): MentionNode {
+ const mentionNode = new MentionNode(mentionData)
+ return $applyNodeReplacement(mentionNode)
+}
+
+export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode {
+ return node instanceof MentionNode
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionPlugin.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionPlugin.tsx
new file mode 100644
index 000000000..a2b14a3e6
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionPlugin.tsx
@@ -0,0 +1,133 @@
+import * as React from "react"
+import { Suspense, useMemo } from "react"
+
+import { MentionDropdown } from "./components/MentionDropdown"
+import { DEFAULT_MAX_SUGGESTIONS } from "./constants"
+import { useMentionIntegration } from "./hooks/useMentionIntegration"
+import { useMentionKeyboard } from "./hooks/useMentionKeyboard"
+import { useMentionSearch } from "./hooks/useMentionSearch"
+import { useMentionSelection } from "./hooks/useMentionSelection"
+import { useMentionTrigger } from "./hooks/useMentionTrigger"
+import { MentionNode } from "./MentionNode"
+import { defaultTriggerFn } from "./utils/triggerDetection"
+
+export function MentionPlugin() {
+ // Get integrated search and context block handling
+ const { searchMentions, handleMentionInsert } = useMentionIntegration()
+
+ const { maxSuggestions, triggerFn } = {
+ maxSuggestions: DEFAULT_MAX_SUGGESTIONS,
+ triggerFn: defaultTriggerFn,
+ }
+ // Hook for detecting mention triggers
+ const { mentionMatch, isActive, clearMentionMatch } = useMentionTrigger({
+ triggerFn,
+ })
+
+ // Hook for searching mentions
+ const {
+ suggestions,
+ selectedIndex,
+ isLoading,
+ searchMentions: performSearch,
+ clearSuggestions,
+ setSelectedIndex,
+ hasResults,
+ } = useMentionSearch({
+ onSearch: searchMentions,
+ maxSuggestions,
+ })
+
+ // Hook for handling mention selection
+ const { selectMention } = useMentionSelection({
+ mentionMatch,
+ onMentionInsert: handleMentionInsert,
+ onSelectionComplete: () => {
+ clearMentionMatch()
+ clearSuggestions()
+ },
+ })
+
+ // Hook for keyboard navigation
+ const handleArrowKey = React.useCallback(
+ (isUp: boolean) => {
+ if (!hasResults) return
+
+ const newIndex = isUp
+ ? selectedIndex <= 0
+ ? suggestions.length - 1
+ : selectedIndex - 1
+ : selectedIndex >= suggestions.length - 1
+ ? 0
+ : selectedIndex + 1
+
+ setSelectedIndex(newIndex)
+ },
+ [hasResults, suggestions.length, selectedIndex, setSelectedIndex],
+ )
+
+ const handleEnterKey = React.useCallback(() => {
+ if (hasResults && selectedIndex >= 0 && selectedIndex < suggestions.length) {
+ const mention = suggestions[selectedIndex]
+ if (mention) {
+ selectMention(mention)
+ }
+ }
+ }, [hasResults, selectedIndex, suggestions, selectMention])
+
+ const handleEscapeKey = React.useCallback(() => {
+ clearMentionMatch()
+ clearSuggestions()
+ }, [clearMentionMatch, clearSuggestions])
+
+ useMentionKeyboard({
+ isActive,
+ suggestions,
+ selectedIndex,
+ onArrowKey: handleArrowKey,
+ onEnterKey: handleEnterKey,
+ onEscapeKey: handleEscapeKey,
+ })
+
+ // Search when mention match changes
+ React.useEffect(() => {
+ if (mentionMatch) {
+ performSearch(mentionMatch.matchingString)
+ } else {
+ clearSuggestions()
+ }
+ }, [mentionMatch, performSearch, clearSuggestions])
+
+ // Calculate dropdown props
+ const dropdownProps = useMemo(() => {
+ if (!isActive || !hasResults) return null
+
+ return {
+ isVisible: true,
+ suggestions,
+ selectedIndex,
+ isLoading,
+ onSelect: selectMention,
+ onClose: handleEscapeKey,
+ query: mentionMatch?.matchingString || "",
+ }
+ }, [
+ isActive,
+ hasResults,
+ suggestions,
+ selectedIndex,
+ isLoading,
+ selectMention,
+ handleEscapeKey,
+ mentionMatch,
+ ])
+
+ return dropdownProps ? (
+
+
+
+ ) : null
+}
+
+MentionPlugin.id = "mention"
+MentionPlugin.nodes = [MentionNode]
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionComponent.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionComponent.tsx
new file mode 100644
index 000000000..296cf4d09
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionComponent.tsx
@@ -0,0 +1,88 @@
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipPortal,
+ TooltipRoot,
+ TooltipTrigger,
+} from "@follow/components/ui/tooltip/index.js"
+import { cn } from "@follow/utils"
+import * as React from "react"
+
+import type { MentionData } from "../types"
+import { MentionTypeIcon } from "./shared/MentionTypeIcon"
+
+interface MentionComponentProps {
+ mentionData: MentionData
+ className?: string
+}
+
+const MentionTooltipContent = ({ mentionData }: { mentionData: MentionData }) => (
+
+
+
+
+
+
@{mentionData.name}
+
+ {mentionData.type}
+
+
+
+)
+
+const getMentionStyles = (type: MentionData["type"]) => {
+ const baseStyles = tw`
+ inline-flex items-center gap-1 px-2 py-0.5 rounded-md
+ font-medium text-sm cursor-pointer select-none
+ `
+
+ switch (type) {
+ case "entry": {
+ return cn(
+ baseStyles,
+ "bg-blue/10 text-blue border-blue/20",
+ "hover:bg-blue/20 hover:border-blue/30",
+ )
+ }
+ case "feed": {
+ return cn(
+ baseStyles,
+ "bg-orange/10 text-orange border-orange/20",
+ "hover:bg-orange/20 hover:border-orange/30",
+ )
+ }
+ }
+}
+export const MentionComponent: React.FC = ({ mentionData, className }) => {
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault()
+ // Handle mention click - could navigate to user profile, topic page, etc.
+ // TODO: Implement navigation logic for mentions
+ }
+
+ return (
+
+
+
+
+
+ @{mentionData.name}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+MentionComponent.displayName = "MentionComponent"
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionDropdown.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionDropdown.tsx
new file mode 100644
index 000000000..77f486337
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionDropdown.tsx
@@ -0,0 +1,266 @@
+import {
+ autoUpdate,
+ flip,
+ offset,
+ shift,
+ useDismiss,
+ useFloating,
+ useInteractions,
+ useRole,
+} from "@floating-ui/react"
+import { RootPortal } from "@follow/components/ui/portal/index.js"
+import { cn, thenable } from "@follow/utils"
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
+import * as React from "react"
+import { useCallback, useEffect, useRef, useState } from "react"
+
+import type { MentionData } from "../types"
+import { calculateDropdownPosition } from "../utils/positioning"
+import { MentionTypeIcon } from "./shared/MentionTypeIcon"
+
+interface MentionDropdownProps {
+ isVisible: boolean
+ suggestions: MentionData[]
+ selectedIndex: number
+ isLoading: boolean
+ onSelect: (mention: MentionData) => void
+ onClose: () => void
+ query: string
+ anchor?: HTMLElement | null
+}
+
+const MentionSuggestionItem = React.memo(
+ ({
+ mention,
+ isSelected,
+ onClick,
+ query,
+ }: {
+ mention: MentionData
+ isSelected: boolean
+ onClick: (mention: MentionData) => void
+ query: string
+ }) => {
+ const handleClick = useCallback(() => {
+ onClick(mention)
+ }, [mention, onClick])
+
+ // Highlight matching text
+ const highlightText = (text: string, query: string) => {
+ const cleanQuery = query.replace("@", "").toLowerCase()
+ if (!cleanQuery) return text
+
+ const parts = text.split(new RegExp(`(${cleanQuery})`, "gi"))
+ return parts.map((part, partIndex) => {
+ const isMatch = part.toLowerCase() === cleanQuery
+ // Create a more unique key that doesn't rely solely on index
+ const uniqueKey = `${mention.id}-${mention.type}-${part}-${partIndex}-${part.length}`
+ return (
+
+ {part}
+
+ )
+ })
+ }
+
+ return (
+
+ {/* Icon */}
+
+
+
+
+ {/* Content */}
+ {highlightText(mention.name, query)}
+
+ {/* Selection Indicator */}
+ {isSelected && (
+
+
+
+ )}
+
+ )
+ },
+)
+
+MentionSuggestionItem.displayName = "MentionSuggestionItem"
+
+export const MentionDropdown: React.FC = ({
+ isVisible,
+ suggestions,
+ selectedIndex,
+ isLoading,
+ onSelect,
+ onClose,
+ query,
+}) => {
+ if (!isVisible) throw thenable
+
+ const [editor] = useLexicalComposerContext()
+ const dropdownRef = useRef(null)
+ const [referenceWidth, setReferenceWidth] = useState(320)
+
+ // Create virtual reference element based on cursor position
+ const virtualReference = useRef({
+ getBoundingClientRect: () => {
+ const position = calculateDropdownPosition(editor)
+ const editorElement = editor.getRootElement()
+
+ if (!position || !editorElement) {
+ // Fallback to editor element
+ return (
+ editorElement?.getBoundingClientRect() || {
+ top: 0,
+ left: 0,
+ bottom: 0,
+ right: 0,
+ width: 0,
+ height: 0,
+ x: 0,
+ y: 0,
+ }
+ )
+ }
+
+ const editorRect = editorElement.getBoundingClientRect()
+
+ return {
+ top: editorRect.top + position.top,
+ left: editorRect.left + position.left,
+ bottom: editorRect.top + position.top,
+ right: editorRect.left + position.left,
+ width: 0,
+ height: 0,
+ x: editorRect.left + position.left,
+ y: editorRect.top + position.top,
+ }
+ },
+ })
+
+ const { refs, floatingStyles, context } = useFloating({
+ open: isVisible,
+ onOpenChange: (open) => {
+ if (!open) onClose()
+ },
+ middleware: [
+ offset(8),
+ flip({ fallbackPlacements: ["bottom-start", "top-start", "bottom-end", "top-end"] }),
+ shift({ padding: 8 }),
+ ],
+ whileElementsMounted: autoUpdate,
+ placement: "bottom-start",
+ })
+
+ const dismiss = useDismiss(context, {
+ enabled: isVisible,
+ })
+
+ const role = useRole(context, {
+ role: "listbox",
+ })
+
+ const { getFloatingProps } = useInteractions([dismiss, role])
+
+ // Handle scroll to keep selected item in view
+ useEffect(() => {
+ if (isVisible && dropdownRef.current && selectedIndex >= 0) {
+ const listContainer = dropdownRef.current.querySelector('[role="listbox"]')
+ if (listContainer) {
+ const selectedElement = listContainer.children[selectedIndex] as HTMLElement
+ if (selectedElement) {
+ selectedElement.scrollIntoView({
+ block: "nearest",
+ behavior: "smooth",
+ })
+ }
+ }
+ }
+ }, [selectedIndex, isVisible])
+
+ // Set virtual reference element based on cursor position and calculate width
+ useEffect(() => {
+ if (isVisible) {
+ refs.setReference(virtualReference.current)
+
+ const editorElement = editor.getRootElement()
+ if (editorElement) {
+ const rect = editorElement.getBoundingClientRect()
+ setReferenceWidth(rect.width || 320)
+ }
+ }
+ }, [editor, refs, isVisible, query]) // Add query as dependency to update position when typing
+
+ return (
+
+ {isVisible && (
+
+
+ {isLoading ? (
+
+
+ Searching...
+
+ ) : suggestions.length === 0 ? (
+
+
No matches found
+ {query && (
+
+ Try a different search term
+
+ )}
+
+ ) : (
+
+ {suggestions.map((mention, index) => (
+
+ ))}
+
+ )}
+
+
+ )}
+
+ )
+}
+
+MentionDropdown.displayName = "MentionDropdown"
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/shared/MentionTypeIcon.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/shared/MentionTypeIcon.tsx
new file mode 100644
index 000000000..c1e91dfdc
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/shared/MentionTypeIcon.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import type { MentionType } from "../../types"
+
+interface MentionTypeIconProps {
+ type: MentionType
+ className?: string
+}
+
+export const MentionTypeIcon: React.FC = ({ type, className = "size-3" }) => {
+ switch (type) {
+ case "entry": {
+ return
+ }
+ case "feed": {
+ return
+ }
+ default: {
+ return
+ }
+ }
+}
+
+MentionTypeIcon.displayName = "MentionTypeIcon"
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/constants.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/constants.ts
new file mode 100644
index 000000000..546c37f03
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/constants.ts
@@ -0,0 +1,16 @@
+import { createCommand } from "lexical"
+
+import type { MentionData } from "./types"
+
+// Commands
+export const MENTION_COMMAND = createCommand("MENTION_COMMAND")
+export const MENTION_TYPEAHEAD_COMMAND = createCommand("MENTION_TYPEAHEAD_COMMAND")
+
+// Default configuration
+
+export const DEFAULT_MAX_SUGGESTIONS = 10
+
+// Trigger patterns
+export const MENTION_TRIGGER_PATTERN = /(?:^|\s)(@[\w-]*)$/
+export const FEED_MENTION_PATTERN = /(?:^|\s)(@#[\w-]*)$/
+export const ENTRY_MENTION_PATTERN = /(?:^|\s)(@\+[\w-]*)$/
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionBlockSync.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionBlockSync.ts
new file mode 100644
index 000000000..2b7c70f0a
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionBlockSync.ts
@@ -0,0 +1,249 @@
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
+import { $getNodeByKey } from "lexical"
+import { useCallback, useEffect, useRef } from "react"
+
+import { useAIChatStore } from "~/modules/ai/chat/__internal__/AIChatContext"
+import { useChatBlockActions } from "~/modules/ai/chat/__internal__/hooks"
+import type { AIChatContextBlock } from "~/modules/ai/chat/__internal__/types"
+
+import { $isMentionNode, MentionNode } from "../MentionNode"
+import type { MentionData } from "../types"
+
+interface MentionBlockReference {
+ mentionNodeKey: string
+ blockId: string
+ resourceId: string // `${type}:${value}`
+ mentionData: MentionData
+}
+
+const getResourceId = (type: string, value: string) => `${type}:${value}`
+
+const getBlockType = (mentionType: string): AIChatContextBlock["type"] => {
+ return mentionType === "feed" ? "referFeed" : "referEntry"
+}
+
+/**
+ * Hook that manages bidirectional synchronization between mention nodes and context blocks
+ * - When a mention is added, corresponding block is created
+ * - When a block is removed, corresponding mentions are removed
+ * - When a mention is removed, corresponding block is removed (if no other mentions reference it)
+ */
+export const useMentionBlockSync = () => {
+ const [editor] = useLexicalComposerContext()
+ const blockActions = useChatBlockActions()
+ const blocks = useAIChatStore()((state) => state.blocks)
+
+ // Reference tracking maps
+ const mentionToBlockRef = useRef(new Map()) // mentionNodeKey -> reference
+ const resourceToMentionsRef = useRef(new Map>()) // resourceId -> Set
+ const blockToResourceRef = useRef(new Map()) // blockId -> resourceId
+
+ // Add mention-block reference
+ const addMentionReference = useCallback(
+ (mentionData: MentionData, mentionNodeKey: string, blockId: string) => {
+ const resourceId = getResourceId(mentionData.type, mentionData.value as string)
+
+ const reference: MentionBlockReference = {
+ mentionNodeKey,
+ blockId,
+ resourceId,
+ mentionData,
+ }
+
+ // Update tracking maps
+ mentionToBlockRef.current.set(mentionNodeKey, reference)
+
+ if (!resourceToMentionsRef.current.has(resourceId)) {
+ resourceToMentionsRef.current.set(resourceId, new Set())
+ }
+ resourceToMentionsRef.current.get(resourceId)!.add(mentionNodeKey)
+
+ blockToResourceRef.current.set(blockId, resourceId)
+ },
+ [],
+ )
+
+ // Remove mention reference
+ const removeMentionReference = useCallback((mentionNodeKey: string) => {
+ const reference = mentionToBlockRef.current.get(mentionNodeKey)
+ if (!reference) return null
+
+ const { resourceId, blockId } = reference
+
+ // Clean up tracking maps
+ mentionToBlockRef.current.delete(mentionNodeKey)
+
+ const mentionSet = resourceToMentionsRef.current.get(resourceId)
+ if (mentionSet) {
+ mentionSet.delete(mentionNodeKey)
+ if (mentionSet.size === 0) {
+ resourceToMentionsRef.current.delete(resourceId)
+ }
+ }
+
+ blockToResourceRef.current.delete(blockId)
+
+ return reference
+ }, [])
+
+ // Handle mention insertion - create block and track reference
+ const handleMentionInsert = useCallback(
+ (mentionData: MentionData, mentionNodeKey: string) => {
+ const resourceId = getResourceId(mentionData.type, mentionData.value as string)
+
+ // Check if block already exists for this resource
+ const existingBlock = blocks.find(
+ (block) => blockToResourceRef.current.get(block.id) === resourceId,
+ )
+
+ let blockId: string
+ if (existingBlock) {
+ // Use existing block
+ blockId = existingBlock.id
+ } else {
+ // Create new block
+ const blockType = getBlockType(mentionData.type)
+
+ // Generate block ID (mimicking the block slice logic)
+ const newBlock: Omit = {
+ type: blockType,
+ value: mentionData.value as string,
+ }
+
+ blockActions.addBlock(newBlock)
+
+ // Use current blocks state directly from store instead of stale closure
+ const currentBlocks = blockActions.getBlocks()
+ const addedBlock = currentBlocks.find(
+ (block) => block.type === blockType && block.value === mentionData.value,
+ )
+
+ if (addedBlock) {
+ blockId = addedBlock.id
+ } else {
+ // Fallback to a predictable ID pattern if we can't find the block immediately
+ blockId = `${blockType}-${mentionData.value}-${mentionNodeKey}`
+ }
+ }
+
+ // Track the reference
+ addMentionReference(mentionData, mentionNodeKey, blockId)
+ },
+ [blocks, blockActions, addMentionReference],
+ )
+
+ // Handle mention removal - remove block if no other mentions reference it
+ const handleMentionRemove = useCallback(
+ (mentionNodeKey: string) => {
+ const reference = removeMentionReference(mentionNodeKey)
+ if (!reference) return
+
+ const { resourceId, blockId } = reference
+
+ // Check if any other mentions still reference this resource
+ const remainingMentions = resourceToMentionsRef.current.get(resourceId)
+ if (!remainingMentions || remainingMentions.size === 0) {
+ // No more mentions reference this resource, remove the block
+
+ blockActions.removeBlock(blockId)
+ }
+ },
+ [blockActions, removeMentionReference],
+ )
+
+ // Handle block removal - remove corresponding mentions
+ const handleBlockRemove = useCallback(
+ (blockId: string) => {
+ const resourceId = blockToResourceRef.current.get(blockId)
+ if (!resourceId) return
+
+ const mentionKeys = resourceToMentionsRef.current.get(resourceId)
+ if (!mentionKeys) return
+
+ // Remove all mention nodes for this resource
+ editor.update(() => {
+ Array.from(mentionKeys).forEach((mentionKey) => {
+ const node = $getNodeByKey(mentionKey)
+ if (node && $isMentionNode(node)) {
+ node.remove()
+ }
+ })
+ })
+
+ // Clean up references
+ Array.from(mentionKeys).forEach((mentionKey) => {
+ removeMentionReference(mentionKey)
+ })
+ },
+ [editor, removeMentionReference],
+ )
+
+ // Monitor block changes
+ useEffect(() => {
+ const currentBlockIds = new Set(blocks.map((block) => block.id))
+ const trackedBlockIds = new Set(blockToResourceRef.current.keys())
+
+ // Find removed blocks
+ for (const trackedBlockId of trackedBlockIds) {
+ if (!currentBlockIds.has(trackedBlockId)) {
+ handleBlockRemove(trackedBlockId)
+ }
+ }
+ }, [blocks, handleBlockRemove])
+
+ // Monitor mention node changes using mutation observer
+ useEffect(() => {
+ const removedMentionKeys = new Set()
+
+ const unregisterMutationListener = editor.registerMutationListener(
+ MentionNode,
+ (mutatedNodes) => {
+ for (const [nodeKey, mutation] of mutatedNodes) {
+ // Only track destroyed mutations for mentions we're actually tracking
+ if (mutation === "destroyed" && mentionToBlockRef.current.has(nodeKey)) {
+ removedMentionKeys.add(nodeKey)
+ }
+ }
+
+ // Process removed mentions in next tick to avoid state conflicts
+ if (removedMentionKeys.size > 0) {
+ const timeoutId = setTimeout(() => {
+ Array.from(removedMentionKeys).forEach((mentionKey) => {
+ handleMentionRemove(mentionKey)
+ })
+ removedMentionKeys.clear()
+ }, 100) // Increase delay to avoid race conditions
+
+ // Store timeout ID for potential cleanup
+ return () => clearTimeout(timeoutId)
+ }
+ },
+ )
+
+ return unregisterMutationListener
+ }, [editor, handleMentionRemove])
+
+ // Cleanup on unmount
+ useEffect(() => {
+ const mentionToBlock = mentionToBlockRef.current
+ const resourceToMentions = resourceToMentionsRef.current
+ const blockToResource = blockToResourceRef.current
+
+ return () => {
+ mentionToBlock.clear()
+ resourceToMentions.clear()
+ blockToResource.clear()
+ }
+ }, [])
+
+ return {
+ handleMentionInsert,
+ handleMentionRemove,
+ // Debug helpers
+ getMentionReferences: () => ({
+ mentionToBlock: new Map(mentionToBlockRef.current),
+ resourceToMentions: new Map(resourceToMentionsRef.current),
+ blockToResource: new Map(blockToResourceRef.current),
+ }),
+ }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionIntegration.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionIntegration.ts
new file mode 100644
index 000000000..27f1e477d
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionIntegration.ts
@@ -0,0 +1,29 @@
+import { useCallback } from "react"
+
+import type { MentionData } from "../types"
+import { useMentionBlockSync } from "./useMentionBlockSync"
+import { useMentionSearchService } from "./useMentionSearchService"
+
+/**
+ * Hook that integrates mention search with context block management
+ * Provides search functionality and handles bidirectional synchronization
+ */
+export const useMentionIntegration = () => {
+ const { searchMentions } = useMentionSearchService()
+ const { handleMentionInsert: syncMentionInsert } = useMentionBlockSync()
+
+ // Handle mention insertion with node key tracking
+ const handleMentionInsert = useCallback(
+ (mention: MentionData, nodeKey?: string) => {
+ if (nodeKey) {
+ syncMentionInsert(mention, nodeKey)
+ }
+ },
+ [syncMentionInsert],
+ )
+
+ return {
+ searchMentions,
+ handleMentionInsert,
+ }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionKeyboard.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionKeyboard.ts
new file mode 100644
index 000000000..e0efc9307
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionKeyboard.ts
@@ -0,0 +1,161 @@
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
+import {
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_UP_COMMAND,
+ KEY_ENTER_COMMAND,
+ KEY_ESCAPE_COMMAND,
+ KEY_TAB_COMMAND,
+} from "lexical"
+import { useCallback, useEffect } from "react"
+
+import type { MentionData } from "../types"
+
+interface UseMentionKeyboardOptions {
+ isActive: boolean
+ suggestions: MentionData[]
+ selectedIndex: number
+ onArrowKey: (isUp: boolean) => void
+ onEnterKey: () => void
+ onEscapeKey: () => void
+}
+
+export const useMentionKeyboard = ({
+ isActive,
+ suggestions,
+ selectedIndex,
+ onArrowKey,
+ onEnterKey,
+ onEscapeKey,
+}: UseMentionKeyboardOptions) => {
+ const [editor] = useLexicalComposerContext()
+
+ // Handle keyboard navigation
+ const handleArrowKey = useCallback(
+ (isUp: boolean) => {
+ if (!isActive || suggestions.length === 0) return false
+ onArrowKey(isUp)
+ return true
+ },
+ [isActive, suggestions.length, onArrowKey],
+ )
+
+ // Handle enter key
+ const handleEnterKey = useCallback(() => {
+ if (
+ !isActive ||
+ suggestions.length === 0 ||
+ selectedIndex < 0 ||
+ selectedIndex >= suggestions.length
+ ) {
+ return false
+ }
+ onEnterKey()
+ return true
+ }, [isActive, suggestions.length, selectedIndex, onEnterKey])
+
+ // Handle escape key
+ const handleEscapeKey = useCallback(() => {
+ if (!isActive) return false
+ onEscapeKey()
+ return true
+ }, [isActive, onEscapeKey])
+
+ // Register keyboard commands
+ useEffect(() => {
+ const removeCommands = [
+ // Arrow key navigation
+ editor.registerCommand(
+ KEY_ARROW_UP_COMMAND,
+ (event) => {
+ if (isActive && suggestions.length > 0) {
+ event.preventDefault()
+ return handleArrowKey(true)
+ }
+ return false
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+
+ editor.registerCommand(
+ KEY_ARROW_DOWN_COMMAND,
+ (event) => {
+ if (isActive && suggestions.length > 0) {
+ event.preventDefault()
+ return handleArrowKey(false)
+ }
+ return false
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+
+ // Enter key
+ editor.registerCommand(
+ KEY_ENTER_COMMAND,
+ (event) => {
+ if (
+ isActive &&
+ suggestions.length > 0 &&
+ selectedIndex >= 0 &&
+ selectedIndex < suggestions.length
+ ) {
+ event?.preventDefault()
+ return handleEnterKey()
+ }
+ return false
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+
+ // Tab key (same as enter)
+ editor.registerCommand(
+ KEY_TAB_COMMAND,
+ (event) => {
+ if (
+ isActive &&
+ suggestions.length > 0 &&
+ selectedIndex >= 0 &&
+ selectedIndex < suggestions.length
+ ) {
+ event.preventDefault()
+ return handleEnterKey()
+ }
+ return false
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+
+ // Escape key
+ editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ (event) => {
+ if (isActive) {
+ event.preventDefault()
+ return handleEscapeKey()
+ }
+ return false
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ ]
+
+ return () => {
+ removeCommands.forEach((remove) => remove())
+ }
+ }, [
+ editor,
+ isActive,
+ suggestions.length,
+ selectedIndex,
+ handleArrowKey,
+ handleEnterKey,
+ handleEscapeKey,
+ ])
+
+ return {
+ handleArrowKey,
+ handleEnterKey,
+ handleEscapeKey,
+ }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearch.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearch.ts
new file mode 100644
index 000000000..40cc95451
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearch.ts
@@ -0,0 +1,115 @@
+import { useCallback, useRef, useState, useTransition } from "react"
+
+import { DEFAULT_MAX_SUGGESTIONS } from "../constants"
+import type { MentionData, MentionSearchState, MentionType } from "../types"
+import { getMentionType, shouldTriggerMention } from "../utils/triggerDetection"
+
+interface UseMentionSearchOptions {
+ onSearch?: (query: string, type: MentionType) => Promise | MentionData[]
+ maxSuggestions?: number
+}
+
+// Default search function
+const defaultSearchFn = async (): Promise => []
+
+export const useMentionSearch = ({
+ onSearch = defaultSearchFn,
+ maxSuggestions = DEFAULT_MAX_SUGGESTIONS,
+}: UseMentionSearchOptions = {}) => {
+ const [searchState, setSearchState] = useState({
+ suggestions: [],
+ selectedIndex: -1,
+ isLoading: false,
+ })
+
+ const [isPending, startTransition] = useTransition()
+ const onSearchRef = useRef(onSearch)
+ const maxSuggestionsRef = useRef(maxSuggestions)
+ const abortControllerRef = useRef(null)
+
+ // Update refs when props change to avoid stale closures
+ onSearchRef.current = onSearch
+ maxSuggestionsRef.current = maxSuggestions
+
+ const searchMentions = useCallback(
+ async (query: string) => {
+ // Cancel any pending search
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort()
+ }
+
+ if (!shouldTriggerMention(query)) {
+ setSearchState((prev) => ({
+ ...prev,
+ suggestions: [],
+ selectedIndex: -1,
+ isLoading: false,
+ }))
+ return
+ }
+
+ // Create new abort controller for this search
+ const abortController = new AbortController()
+ abortControllerRef.current = abortController
+
+ // Use transition to defer search as low-priority update
+ startTransition(() => {
+ setSearchState((prev) => ({ ...prev, isLoading: true }))
+ })
+
+ try {
+ const mentionType = getMentionType(query)
+ const results = await onSearchRef.current(query, mentionType)
+
+ // Check if this search was aborted
+ if (abortController.signal.aborted) {
+ return
+ }
+
+ startTransition(() => {
+ setSearchState({
+ suggestions: results.slice(0, maxSuggestionsRef.current),
+ selectedIndex: results.length > 0 ? 0 : -1,
+ isLoading: false,
+ })
+ })
+ } catch (error) {
+ // Check if this search was aborted
+ if (abortController.signal.aborted) {
+ return
+ }
+
+ console.error("Error searching mentions:", error)
+ startTransition(() => {
+ setSearchState({
+ suggestions: [],
+ selectedIndex: -1,
+ isLoading: false,
+ })
+ })
+ }
+ },
+ [], // Empty deps array - we use refs to avoid dependency issues
+ )
+
+ const clearSuggestions = useCallback(() => {
+ setSearchState({
+ suggestions: [],
+ selectedIndex: -1,
+ isLoading: false,
+ })
+ }, [])
+
+ const setSelectedIndex = useCallback((index: number) => {
+ setSearchState((prev) => ({ ...prev, selectedIndex: index }))
+ }, [])
+
+ return {
+ ...searchState,
+ searchMentions,
+ clearSuggestions,
+ setSelectedIndex,
+ hasResults: searchState.suggestions.length > 0,
+ isLoading: searchState.isLoading || isPending,
+ }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearchService.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearchService.ts
new file mode 100644
index 000000000..bda95bcee
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearchService.ts
@@ -0,0 +1,34 @@
+import { useMemo } from "react"
+
+import { useFeedEntrySearchService } from "~/modules/ai/chat/services/feedEntrySearchService"
+
+import type { MentionData, MentionType } from "../types"
+
+/**
+ * Hook that provides search functionality for mentions
+ * Uses the shared feed/entry search service
+ */
+export const useMentionSearchService = () => {
+ const { search } = useFeedEntrySearchService({
+ maxRecentEntries: 50,
+ })
+
+ // Search function that converts search results to MentionData format
+ const searchMentions = useMemo(() => {
+ return async (query: string, type?: MentionType): Promise => {
+ const searchResults = search(query, type, 10)
+
+ // Convert to MentionData format
+ return searchResults.map(
+ (item): MentionData => ({
+ id: item.id,
+ name: item.title,
+ type: item.type as MentionType,
+ value: item.id,
+ }),
+ )
+ }
+ }, [search])
+
+ return { searchMentions }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSelection.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSelection.ts
new file mode 100644
index 000000000..bf72dae86
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSelection.ts
@@ -0,0 +1,65 @@
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
+import { COMMAND_PRIORITY_LOW } from "lexical"
+import { useCallback, useEffect } from "react"
+
+import { MENTION_COMMAND } from "../constants"
+import type { MentionData, MentionMatch } from "../types"
+import { insertMentionNode } from "../utils/textReplacement"
+
+interface UseMentionSelectionOptions {
+ mentionMatch: MentionMatch | null
+ onMentionInsert?: (mention: MentionData, nodeKey?: string) => void
+ onSelectionComplete?: () => void
+}
+
+export const useMentionSelection = ({
+ mentionMatch,
+ onMentionInsert,
+ onSelectionComplete,
+}: UseMentionSelectionOptions) => {
+ const [editor] = useLexicalComposerContext()
+
+ const selectMention = useCallback(
+ (mentionData: MentionData) => {
+ if (!mentionMatch) return false
+
+ let result = { success: false, nodeKey: undefined as string | undefined }
+ editor.update(() => {
+ result = insertMentionNode(mentionData, mentionMatch)
+
+ if (result.success && result.nodeKey) {
+ // Call onMentionInsert immediately within the same editor update
+ // to ensure the node tracking happens before any mutations
+ onMentionInsert?.(mentionData, result.nodeKey)
+ }
+ })
+
+ // Call onSelectionComplete after the editor update is complete
+ if (result.success) {
+ setTimeout(() => {
+ onSelectionComplete?.()
+ }, 0)
+ }
+
+ return result.success
+ },
+ [editor, mentionMatch, onMentionInsert, onSelectionComplete],
+ )
+
+ // Register mention command
+ useEffect(() => {
+ const removeMentionCommand = editor.registerCommand(
+ MENTION_COMMAND,
+ (mentionData: MentionData) => {
+ return selectMention(mentionData)
+ },
+ COMMAND_PRIORITY_LOW,
+ )
+
+ return removeMentionCommand
+ }, [editor, selectMention])
+
+ return {
+ selectMention,
+ }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionTrigger.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionTrigger.ts
new file mode 100644
index 000000000..7b35b8da1
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionTrigger.ts
@@ -0,0 +1,65 @@
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
+import type { LexicalEditor } from "lexical"
+import { $getSelection, $isRangeSelection, $isTextNode } from "lexical"
+import { useCallback, useEffect, useState } from "react"
+
+import type { MentionMatch } from "../types"
+import { defaultTriggerFn } from "../utils/triggerDetection"
+
+interface UseMentionTriggerOptions {
+ triggerFn?: (text: string, editor: LexicalEditor) => MentionMatch | null
+ onTrigger?: (match: MentionMatch | null) => void
+}
+
+export const useMentionTrigger = ({
+ triggerFn = defaultTriggerFn,
+ onTrigger,
+}: UseMentionTriggerOptions = {}) => {
+ const [editor] = useLexicalComposerContext()
+ const [mentionMatch, setMentionMatch] = useState(null)
+
+ const updateMentionMatch = useCallback(
+ (match: MentionMatch | null) => {
+ setMentionMatch(match)
+ onTrigger?.(match)
+ },
+ [onTrigger],
+ )
+
+ const clearMentionMatch = useCallback(() => {
+ updateMentionMatch(null)
+ }, [updateMentionMatch])
+
+ // Monitor text changes for mention triggers
+ useEffect(() => {
+ const removeUpdateListener = editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
+ updateMentionMatch(null)
+ return
+ }
+
+ const { anchor } = selection
+ const anchorNode = anchor.getNode()
+
+ if (!$isTextNode(anchorNode)) {
+ updateMentionMatch(null)
+ return
+ }
+
+ const textContent = anchorNode.getTextContent()
+ const match = triggerFn(textContent, editor)
+ updateMentionMatch(match)
+ })
+ })
+
+ return removeUpdateListener
+ }, [editor, triggerFn, updateMentionMatch])
+
+ return {
+ mentionMatch,
+ isActive: mentionMatch !== null,
+ clearMentionMatch,
+ }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/index.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/index.ts
new file mode 100644
index 000000000..698ba4a48
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/index.ts
@@ -0,0 +1,19 @@
+// Public API exports
+export { MentionComponent } from "./components/MentionComponent"
+export { MentionDropdown } from "./components/MentionDropdown"
+export { $createMentionNode, $isMentionNode, MentionNode } from "./MentionNode"
+export { MentionPlugin } from "./MentionPlugin"
+
+// Commands
+export { MENTION_COMMAND, MENTION_TYPEAHEAD_COMMAND } from "./constants"
+
+// Types
+export type {
+ MentionData,
+ MentionDropdownPosition,
+ MentionMatch,
+ MentionPluginProps,
+ MentionSearchState,
+ MentionTriggerState,
+ MentionType,
+} from "./types"
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/types.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/types.ts
new file mode 100644
index 000000000..582902b88
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/types.ts
@@ -0,0 +1,30 @@
+export type MentionType = "entry" | "feed"
+
+export interface MentionData {
+ id: string
+ name: string
+ type: MentionType
+ value: unknown
+}
+
+export interface MentionMatch {
+ leadOffset: number
+ matchingString: string
+ replaceableString: string
+}
+
+export interface MentionDropdownPosition {
+ top: number
+ left: number
+}
+
+export interface MentionSearchState {
+ suggestions: MentionData[]
+ selectedIndex: number
+ isLoading: boolean
+}
+
+export interface MentionTriggerState {
+ mentionMatch: MentionMatch | null
+ isActive: boolean
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/positioning.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/positioning.ts
new file mode 100644
index 000000000..96615def5
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/positioning.ts
@@ -0,0 +1,38 @@
+import type { LexicalEditor } from "lexical"
+
+import type { MentionDropdownPosition } from "../types"
+
+export const calculateDropdownPosition = (
+ editor: LexicalEditor,
+): MentionDropdownPosition | null => {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return null
+
+ const range = selection.getRangeAt(0)
+ const rect = range.getBoundingClientRect()
+ const editorElement = editor.getRootElement()
+
+ if (!editorElement) return null
+
+ const editorRect = editorElement.getBoundingClientRect()
+
+ return {
+ top: rect.bottom - editorRect.top + 8, // 8px offset below cursor
+ left: rect.left - editorRect.left,
+ }
+}
+
+export const scrollSelectedItemIntoView = (
+ containerRef: React.RefObject,
+ selectedIndex: number,
+): void => {
+ if (!containerRef.current || selectedIndex < 0) return
+
+ const selectedElement = containerRef.current.children[selectedIndex] as HTMLElement
+ if (selectedElement) {
+ selectedElement.scrollIntoView({
+ block: "nearest",
+ behavior: "smooth",
+ })
+ }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/textReplacement.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/textReplacement.ts
new file mode 100644
index 000000000..a94735bbc
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/textReplacement.ts
@@ -0,0 +1,54 @@
+import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode } from "lexical"
+
+import { $createMentionNode } from "../MentionNode"
+import type { MentionData, MentionMatch } from "../types"
+
+export const insertMentionNode = (
+ mentionData: MentionData,
+ mentionMatch: MentionMatch,
+): { success: boolean; nodeKey?: string } => {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return { success: false }
+
+ const { anchor } = selection
+ const anchorNode = anchor.getNode()
+
+ if (!$isTextNode(anchorNode)) return { success: false }
+
+ // Replace the mention text with the mention node
+ const textContent = anchorNode.getTextContent()
+ const { leadOffset, replaceableString } = mentionMatch
+
+ // Split the text node
+ const beforeText = textContent.slice(0, leadOffset)
+ const afterText = textContent.slice(leadOffset + replaceableString.length)
+
+ // Create new nodes
+ const beforeNode = beforeText ? $createTextNode(beforeText) : null
+ const mentionNode = $createMentionNode(mentionData)
+ const afterNode = afterText ? $createTextNode(afterText) : null
+
+ // Replace the current node
+ if (beforeNode) {
+ anchorNode.insertBefore(beforeNode)
+ }
+ anchorNode.insertBefore(mentionNode)
+ if (afterNode) {
+ anchorNode.insertBefore(afterNode)
+ }
+
+ // Remove the original node
+ anchorNode.remove()
+
+ // Position cursor after the mention
+ if (afterNode) {
+ afterNode.select(0, 0)
+ } else {
+ // Create a space after the mention if there's no following text
+ const spaceNode = $createTextNode(" ")
+ mentionNode.insertAfter(spaceNode)
+ spaceNode.select(1, 1)
+ }
+
+ return { success: true, nodeKey: mentionNode.getKey() }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/triggerDetection.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/triggerDetection.ts
new file mode 100644
index 000000000..1239c84a9
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/triggerDetection.ts
@@ -0,0 +1,55 @@
+import { $getSelection, $isRangeSelection, $isTextNode } from "lexical"
+
+import { MENTION_TRIGGER_PATTERN } from "../constants"
+import type { MentionMatch, MentionType } from "../types"
+
+export const defaultTriggerFn = (): MentionMatch | null => {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
+ return null
+ }
+
+ const { anchor } = selection
+ const { focus } = selection
+ const anchorNode = anchor.getNode()
+
+ // Only trigger on text nodes
+ if (!$isTextNode(anchorNode) || anchor.key !== focus.key || anchor.offset !== focus.offset) {
+ return null
+ }
+
+ const textContent = anchorNode.getTextContent()
+ const cursorOffset = anchor.offset
+
+ // Look for @ symbol followed by text
+ const mentionMatch = textContent.slice(0, cursorOffset).match(MENTION_TRIGGER_PATTERN)
+
+ if (!mentionMatch) {
+ return null
+ }
+
+ const matchingString = mentionMatch[1] || ""
+ const replaceableString = matchingString
+ const leadOffset = (mentionMatch.index ?? 0) + (mentionMatch[0]?.startsWith(" ") ? 1 : 0)
+
+ return {
+ leadOffset,
+ matchingString,
+ replaceableString,
+ }
+}
+
+export const getMentionType = (query: string): MentionType => {
+ // Simple heuristic - could be enhanced with more sophisticated detection
+ if (query.startsWith("@#")) return "feed"
+ if (query.startsWith("@+")) return "entry"
+ return "feed"
+}
+
+export const cleanQuery = (query: string): string => {
+ return query.replace(/^@[#+]?/, "").toLowerCase()
+}
+
+export const shouldTriggerMention = (query: string): boolean => {
+ return query.startsWith("@") && query.length > 0
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/services/contextBarSearchService.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/services/contextBarSearchService.ts
new file mode 100644
index 000000000..22b4b92b4
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/services/contextBarSearchService.ts
@@ -0,0 +1,69 @@
+import { useMemo } from "react"
+
+import type { SearchItem } from "./feedEntrySearchService"
+import { useFeedEntrySearchService } from "./feedEntrySearchService"
+
+/**
+ * Picker item interface for context bar compatibility
+ */
+export interface PickerItem {
+ id: string
+ title: string
+}
+
+/**
+ * Hook that provides search functionality for context bar
+ * Uses the shared feed/entry search service and converts results to PickerItem format
+ */
+export const useContextBarSearchService = () => {
+ const { search, feedItems, entryItems } = useFeedEntrySearchService({
+ maxRecentEntries: 50,
+ fuseOptions: {
+ keys: ["title", "id"],
+ threshold: 0.3,
+ },
+ })
+
+ // Convert search items to picker items
+ const convertToPickerItems = useMemo(() => {
+ return (items: SearchItem[]): PickerItem[] => {
+ return items.map((item) => ({
+ id: item.id,
+ title: item.title,
+ }))
+ }
+ }, [])
+
+ // Search function for feeds
+ const searchFeeds = useMemo(() => {
+ return (query: string): PickerItem[] => {
+ const results = search(query, "feed", 20)
+ return convertToPickerItems(results)
+ }
+ }, [search, convertToPickerItems])
+
+ // Search function for entries
+ const searchEntries = useMemo(() => {
+ return (query: string): PickerItem[] => {
+ const results = search(query, "entry", 20)
+ return convertToPickerItems(results)
+ }
+ }, [search, convertToPickerItems])
+
+ // Get all feeds as picker items
+ const allFeeds = useMemo(() => {
+ return convertToPickerItems(feedItems)
+ }, [feedItems, convertToPickerItems])
+
+ // Get all recent entries as picker items
+ const allRecentEntries = useMemo(() => {
+ return convertToPickerItems(entryItems)
+ }, [entryItems, convertToPickerItems])
+
+ return {
+ searchFeeds,
+ searchEntries,
+ allFeeds,
+ allRecentEntries,
+ }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/services/feedEntrySearchService.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/services/feedEntrySearchService.ts
new file mode 100644
index 000000000..2909dc4e2
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai/chat/services/feedEntrySearchService.ts
@@ -0,0 +1,119 @@
+import { useEntryIdsByView } from "@follow/store/entry/hooks"
+import { useEntryStore } from "@follow/store/entry/store"
+import { getFeedById } from "@follow/store/feed/getter"
+import { useAllFeedSubscription } from "@follow/store/subscription/hooks"
+import type { IFuseOptions } from "fuse.js"
+import Fuse from "fuse.js"
+import { useMemo } from "react"
+
+import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
+
+/**
+ * Generic search item interface
+ */
+export interface SearchItem {
+ id: string
+ title: string
+ type: "feed" | "entry"
+}
+
+/**
+ * Search service options
+ */
+export interface SearchServiceOptions {
+ /** Maximum number of recent entries to include */
+ maxRecentEntries?: number
+ /** Fuse.js search options */
+ fuseOptions?: IFuseOptions
+}
+
+const defaultFuseOptions: IFuseOptions = {
+ keys: ["title", "id"],
+ threshold: 0.3,
+ includeScore: true,
+}
+/**
+ * Hook that provides unified search functionality for feeds and entries
+ * Used by both context bar and mention plugin
+ */
+export const useFeedEntrySearchService = (options: SearchServiceOptions = {}) => {
+ const { maxRecentEntries = 50, fuseOptions = defaultFuseOptions } = options
+
+ // Get data sources
+ const allSubscriptions = useAllFeedSubscription()
+ const view = useRouteParamsSelector((route) => route.view)
+ const recentEntryIds = useEntryIdsByView(view, false)
+ const entryStore = useEntryStore((state) => state.data)
+
+ // Prepare feed items
+ const feedItems = useMemo(() => {
+ return allSubscriptions
+ .filter((subscription) => subscription.feedId)
+ .map((subscription) => {
+ const customTitle = subscription.title
+ if (!subscription.feedId) return null
+
+ const feed = getFeedById(subscription.feedId!)
+ return {
+ id: subscription.feedId!,
+ title: customTitle || feed?.title || `Feed ${subscription.feedId}`,
+ type: "feed" as const,
+ }
+ })
+ .filter(Boolean) as SearchItem[]
+ }, [allSubscriptions])
+
+ // Prepare entry items (recent entries, limited for performance)
+ const entryItems = useMemo(() => {
+ if (!recentEntryIds) return []
+
+ return recentEntryIds
+ .slice(0, maxRecentEntries)
+ .map((entryId) => {
+ const entry = entryStore[entryId]
+ return entry
+ ? {
+ id: entryId,
+ title: entry.title || "Untitled",
+ type: "entry" as const,
+ }
+ : null
+ })
+ .filter(Boolean) as SearchItem[]
+ }, [recentEntryIds, entryStore, maxRecentEntries])
+
+ // Combine all search items
+ const allItems = useMemo(() => {
+ return [...feedItems, ...entryItems]
+ }, [feedItems, entryItems])
+
+ // Create Fuse instance for fuzzy search
+ const fuse = useMemo(() => {
+ return new Fuse(allItems, fuseOptions)
+ }, [allItems, JSON.stringify(fuseOptions)])
+
+ // Search function
+ const search = useMemo(() => {
+ return (query: string, type?: "feed" | "entry", maxResults = 10): SearchItem[] => {
+ if (!query.trim()) {
+ // If no query, return recent items of the specified type
+ const filteredItems = allItems.filter((item) => !type || item.type === type)
+ return filteredItems.slice(0, maxResults)
+ }
+
+ // Perform fuzzy search
+ const fuseResults = fuse.search(query)
+ return fuseResults
+ .map((result) => result.item)
+ .filter((item) => !type || item.type === type)
+ .slice(0, maxResults)
+ }
+ }, [allItems, fuse])
+
+ return {
+ search,
+ feedItems,
+ entryItems,
+ allItems,
+ }
+}
diff --git a/icons/mgc/at_cute_re.svg b/icons/mgc/at_cute_re.svg
new file mode 100644
index 000000000..129ced2ee
--- /dev/null
+++ b/icons/mgc/at_cute_re.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/internal/components/package.json b/packages/internal/components/package.json
index 7259d6edf..70f4ff732 100644
--- a/packages/internal/components/package.json
+++ b/packages/internal/components/package.json
@@ -23,6 +23,9 @@
},
"dependencies": {
"@essentials/request-timeout": "1.3.0",
+ "@floating-ui/core": "*",
+ "@floating-ui/react": "*",
+ "@floating-ui/react-dom": "*",
"@follow/hooks": "workspace:*",
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
diff --git a/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx b/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx
index dd41caa91..e20ccb1bb 100644
--- a/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx
+++ b/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx
@@ -18,12 +18,12 @@ import { useImperativeHandle, useRef, useState } from "react"
import { LexicalRichEditorNodes } from "./nodes"
import { KeyboardPlugin } from "./plugins"
import { defaultLexicalTheme } from "./theme"
-import type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
+import type { BuiltInPlugins, LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
function onError(error: Error) {
console.error("Lexical Editor Error:", error)
}
-const defaultEnabledPlugins = {
+const defaultEnabledPlugins: BuiltInPlugins = {
history: true,
markdown: true,
list: true,
@@ -42,15 +42,22 @@ export const LexicalRichEditor = ({
theme = defaultLexicalTheme,
enabledPlugins = defaultEnabledPlugins,
initalEditorState,
+ plugins,
}: LexicalRichEditorProps & { ref?: React.RefObject }) => {
const editorRef = useRef(null)
const [isEmpty, setIsEmpty] = useState(true)
+ // Collect nodes from plugins
+ const pluginNodes = plugins?.flatMap((plugin) => plugin.nodes || []) || []
+
+ // Merge base nodes with custom nodes and plugin nodes
+ const allNodes = [...LexicalRichEditorNodes, ...pluginNodes]
+
const initialConfig: InitialConfigType = {
namespace,
theme,
onError,
- nodes: LexicalRichEditorNodes,
+ nodes: allNodes,
editorState: initalEditorState,
}
@@ -88,13 +95,13 @@ export const LexicalRichEditor = ({
contentEditable={
+
{placeholder}
}
@@ -109,6 +116,10 @@ export const LexicalRichEditor = ({
{enabledPlugins.list && }
{enabledPlugins.link && }
+ {plugins?.map((Plugin) => (
+
+ ))}
+
{autoFocus && enabledPlugins.autoFocus && }
diff --git a/packages/internal/components/src/ui/lexical-rich-editor/index.ts b/packages/internal/components/src/ui/lexical-rich-editor/index.ts
index 21c187430..8eec0d013 100644
--- a/packages/internal/components/src/ui/lexical-rich-editor/index.ts
+++ b/packages/internal/components/src/ui/lexical-rich-editor/index.ts
@@ -3,4 +3,4 @@ export { LexicalRichEditor } from "./LexicalRichEditor"
export { LexicalRichEditorNodes } from "./nodes"
export { KeyboardPlugin } from "./plugins"
export { defaultLexicalTheme } from "./theme"
-export type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
+export type * from "./types"
diff --git a/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts b/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts
index d84224e5c..7bf89baa3 100644
--- a/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts
+++ b/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts
@@ -5,6 +5,9 @@ import { MarkNode } from "@lexical/mark"
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import { ParagraphNode, TextNode } from "lexical"
+// Mention functionality moved to desktop app, import should be handled there
+// import { MentionNode } from "./plugins/mention"
+
export const LexicalRichEditorNodes = [
// Core nodes
ParagraphNode,
diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/KeyboardPlugin.tsx b/packages/internal/components/src/ui/lexical-rich-editor/plugins/KeyboardPlugin.tsx
deleted file mode 100644
index 58c07b4bc..000000000
--- a/packages/internal/components/src/ui/lexical-rich-editor/plugins/KeyboardPlugin.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
-import { useEffect } from "react"
-
-interface KeyboardPluginProps {
- onKeyDown?: (event: KeyboardEvent) => boolean
-}
-
-export function KeyboardPlugin({ onKeyDown }: KeyboardPluginProps) {
- const [editor] = useLexicalComposerContext()
-
- useEffect(() => {
- if (!onKeyDown) return
-
- const handleKeyDown = (event: KeyboardEvent) => {
- onKeyDown(event)
- }
-
- return editor.registerRootListener((rootElement, prevRootElement) => {
- if (prevRootElement !== null) {
- prevRootElement.removeEventListener("keydown", handleKeyDown)
- }
- if (rootElement !== null) {
- rootElement.addEventListener("keydown", handleKeyDown)
- }
- })
- }, [editor, onKeyDown])
-
- return null
-}
diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts b/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts
index 22d054444..ba383f8bd 100644
--- a/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts
+++ b/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts
@@ -1 +1 @@
-export { KeyboardPlugin } from "./KeyboardPlugin"
+export { KeyboardPlugin } from "./keyboard"
diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/keyboard/index.tsx b/packages/internal/components/src/ui/lexical-rich-editor/plugins/keyboard/index.tsx
new file mode 100644
index 000000000..d2ea1b64d
--- /dev/null
+++ b/packages/internal/components/src/ui/lexical-rich-editor/plugins/keyboard/index.tsx
@@ -0,0 +1,55 @@
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
+import { COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND } from "lexical"
+import { useEffect } from "react"
+
+interface KeyboardPluginProps {
+ onKeyDown?: (event: KeyboardEvent) => boolean
+}
+
+export function KeyboardPlugin({ onKeyDown }: KeyboardPluginProps) {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ if (!onKeyDown) return
+
+ // Register a low-priority command that will only execute if no higher-priority commands handle the event
+ const removeEnterCommand = editor.registerCommand(
+ KEY_ENTER_COMMAND,
+ (event) => {
+ // This will only be called if no higher-priority commands handled the Enter key
+ if (!event) return false
+ const handled = onKeyDown(event)
+ return handled
+ },
+ COMMAND_PRIORITY_LOW,
+ )
+
+ // For other keys, use DOM event listener
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // Skip Enter key as it's handled by the command system
+ if (event.key === "Enter") return
+
+ const handled = onKeyDown(event)
+ if (handled) {
+ event.preventDefault()
+ event.stopPropagation()
+ }
+ }
+
+ const removeRootListener = editor.registerRootListener((rootElement, prevRootElement) => {
+ if (prevRootElement !== null) {
+ prevRootElement.removeEventListener("keydown", handleKeyDown)
+ }
+ if (rootElement !== null) {
+ rootElement.addEventListener("keydown", handleKeyDown)
+ }
+ })
+
+ return () => {
+ removeEnterCommand()
+ removeRootListener()
+ }
+ }, [editor, onKeyDown])
+
+ return null
+}
diff --git a/packages/internal/components/src/ui/lexical-rich-editor/types.ts b/packages/internal/components/src/ui/lexical-rich-editor/types.ts
index 83a0eb444..3021c9bb9 100644
--- a/packages/internal/components/src/ui/lexical-rich-editor/types.ts
+++ b/packages/internal/components/src/ui/lexical-rich-editor/types.ts
@@ -1,4 +1,4 @@
-import type { EditorState, LexicalEditor } from "lexical"
+import type { EditorState, Klass, LexicalEditor, LexicalNode } from "lexical"
export interface LexicalRichEditorRef {
getEditor: () => LexicalEditor
@@ -7,6 +7,13 @@ export interface LexicalRichEditorRef {
isEmpty: () => boolean
}
+export interface BuiltInPlugins {
+ history?: boolean
+ markdown?: boolean
+ list?: boolean
+ link?: boolean
+ autoFocus?: boolean
+}
export interface LexicalRichEditorProps {
placeholder?: string
className?: string
@@ -15,12 +22,11 @@ export interface LexicalRichEditorProps {
autoFocus?: boolean
namespace?: string
theme?: any
- enabledPlugins?: {
- history?: boolean
- markdown?: boolean
- list?: boolean
- link?: boolean
- autoFocus?: boolean
- }
+ enabledPlugins?: BuiltInPlugins
initalEditorState?: EditorState
+ plugins?: LexicalPluginFC[]
+}
+export type LexicalPluginFC
= React.FC & {
+ id: string
+ nodes?: ReadonlyArray>
}
diff --git a/packages/internal/tracker/src/enums.ts b/packages/internal/tracker/src/enums.ts
index badbd8e6c..69a52208d 100644
--- a/packages/internal/tracker/src/enums.ts
+++ b/packages/internal/tracker/src/enums.ts
@@ -25,7 +25,7 @@ export enum TrackerMapper {
Register = 3000,
OnBoarding = 3001,
Subscribe = 3002,
- EntryRead = 3003,
- EntryAction = 3004,
- ViewAction = 3005,
+
+ // AI
+ AIChatMessageSent = 4000,
}
diff --git a/packages/internal/tracker/src/tracker-points.ts b/packages/internal/tracker/src/tracker-points.ts
index b5c7c65c3..1c76c36ae 100644
--- a/packages/internal/tracker/src/tracker-points.ts
+++ b/packages/internal/tracker/src/tracker-points.ts
@@ -114,16 +114,8 @@ export class TrackerPoints {
this.track(TrackerMapper.Subscribe, props)
}
- entryRead(props: { entryId: string }) {
- this.track(TrackerMapper.EntryRead, props)
- }
-
- entryAction(props: { entryId: string; action: string }) {
- this.track(TrackerMapper.EntryAction, props)
- }
-
- viewAction(props: { view: string; action: string }) {
- this.track(TrackerMapper.ViewAction, props)
+ aiChatMessageSent() {
+ this.track(TrackerMapper.AIChatMessageSent)
}
private track(code: TrackerMapper, properties?: Record) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e4783f804..caf629044 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -22,6 +22,10 @@ overrides:
react-dom: 19.0.0
react-native-ios-context-menu: 3.1.1
react-native-ios-utilities: 5.1.5
+ '@floating-ui/core': 1.7.2
+ '@floating-ui/dom': 1.7.2
+ '@floating-ui/react-dom': 2.1.4
+ '@floating-ui/react': 0.27.14
patchedDependencies:
'@microflash/remark-callout-directives':
@@ -1444,6 +1448,15 @@ importers:
'@essentials/request-timeout':
specifier: 1.3.0
version: 1.3.0
+ '@floating-ui/core':
+ specifier: 1.7.2
+ version: 1.7.2
+ '@floating-ui/react':
+ specifier: 0.27.14
+ version: 0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@floating-ui/react-dom':
+ specifier: 2.1.4
+ version: 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@follow/hooks':
specifier: workspace:*
version: link:../hooks
@@ -4051,36 +4064,18 @@ packages:
'@firebase/webchannel-wrapper@1.0.3':
resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==}
- '@floating-ui/core@1.7.0':
- resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
-
'@floating-ui/core@1.7.2':
resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==}
- '@floating-ui/dom@1.7.0':
- resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==}
-
'@floating-ui/dom@1.7.2':
resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==}
- '@floating-ui/react-dom@2.1.2':
- resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==}
- peerDependencies:
- react: 19.0.0
- react-dom: 19.0.0
-
'@floating-ui/react-dom@2.1.4':
resolution: {integrity: sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
- '@floating-ui/react@0.26.28':
- resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
- peerDependencies:
- react: 19.0.0
- react-dom: 19.0.0
-
'@floating-ui/react@0.27.14':
resolution: {integrity: sha512-aSf9JXfyXpRQWMbtuW+CJQrnhzHu4Hg1Th9AkvR1o+wSW/vCUVMrtgXaRY5ToV5Fh5w3I7lXJdvlKVvYrQrppw==}
peerDependencies:
@@ -4090,9 +4085,6 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
- '@floating-ui/utils@0.2.9':
- resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
-
'@follow-app/client-sdk@0.3.25':
resolution: {integrity: sha512-+x9x3bvcCedkqH2ypw1ae2zYySzT70FniC9LH8/TdqpYKIUzQZaMSZxGNIA9KaAQT1EtvL9e7lO07/zpQiEPSw==}
@@ -19285,44 +19277,21 @@ snapshots:
'@firebase/webchannel-wrapper@1.0.3': {}
- '@floating-ui/core@1.7.0':
- dependencies:
- '@floating-ui/utils': 0.2.10
-
'@floating-ui/core@1.7.2':
dependencies:
'@floating-ui/utils': 0.2.10
- '@floating-ui/dom@1.7.0':
- dependencies:
- '@floating-ui/core': 1.7.0
- '@floating-ui/utils': 0.2.10
-
'@floating-ui/dom@1.7.2':
dependencies:
'@floating-ui/core': 1.7.2
'@floating-ui/utils': 0.2.10
- '@floating-ui/react-dom@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
- dependencies:
- '@floating-ui/dom': 1.7.0
- react: 19.0.0
- react-dom: 19.0.0(react@19.0.0)
-
'@floating-ui/react-dom@2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/dom': 1.7.2
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
- '@floating-ui/react@0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
- dependencies:
- '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
- '@floating-ui/utils': 0.2.9
- react: 19.0.0
- react-dom: 19.0.0(react@19.0.0)
- tabbable: 6.2.0
-
'@floating-ui/react@0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -19333,8 +19302,6 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
- '@floating-ui/utils@0.2.9': {}
-
'@follow-app/client-sdk@0.3.25(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)':
dependencies:
'@folo-services/constants': 0.1.15
@@ -19582,7 +19549,7 @@ snapshots:
'@headlessui/react@2.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
- '@floating-ui/react': 0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@floating-ui/react': 0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@react-aria/focus': 3.20.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@react-aria/interactions': 3.25.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@tanstack/react-virtual': 3.13.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -21027,7 +20994,7 @@ snapshots:
'@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
- '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@floating-ui/react-dom': 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.0.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.0.0)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 76ee0a4d7..9a078b067 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -51,6 +51,11 @@ overrides:
react-native-ios-context-menu: "3.1.1"
react-native-ios-utilities: "5.1.5"
+ "@floating-ui/core": "1.7.2"
+ "@floating-ui/dom": "1.7.2"
+ "@floating-ui/react-dom": "2.1.4"
+ "@floating-ui/react": "0.27.14"
+
catalog:
typescript: "5.8.3"
"@follow-app/client-sdk": "0.3.25"