mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 01:46:23 +02:00
Improve chat @ mentions
This commit is contained in:
parent
899f22ff61
commit
5c433805f6
7 changed files with 425 additions and 52 deletions
|
|
@ -320,6 +320,8 @@ function ChatInputInner({
|
|||
// Wrapper component with PromptInputProvider
|
||||
interface ChatInputWithMentionsProps {
|
||||
knowledgeFiles: string[]
|
||||
recentFiles: string[]
|
||||
visibleFiles: string[]
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
isProcessing: boolean
|
||||
contextUsage: LanguageModelUsage
|
||||
|
|
@ -329,6 +331,8 @@ interface ChatInputWithMentionsProps {
|
|||
|
||||
function ChatInputWithMentions({
|
||||
knowledgeFiles,
|
||||
recentFiles,
|
||||
visibleFiles,
|
||||
onSubmit,
|
||||
isProcessing,
|
||||
contextUsage,
|
||||
|
|
@ -336,7 +340,7 @@ function ChatInputWithMentions({
|
|||
usedTokens,
|
||||
}: ChatInputWithMentionsProps) {
|
||||
return (
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles}>
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||
<ChatInputInner
|
||||
onSubmit={onSubmit}
|
||||
isProcessing={isProcessing}
|
||||
|
|
@ -784,6 +788,30 @@ function App() {
|
|||
}, [])
|
||||
), [knowledgeFiles])
|
||||
|
||||
// Compute visible files (files whose parent directories are expanded)
|
||||
const visibleKnowledgeFiles = React.useMemo(() => {
|
||||
const visible: string[] = []
|
||||
const isPathVisible = (path: string) => {
|
||||
const parts = path.split('/')
|
||||
// Root level files in knowledge are always visible
|
||||
if (parts.length <= 2) return true
|
||||
// Check if all parent directories are expanded
|
||||
for (let i = 1; i < parts.length - 1; i++) {
|
||||
const parentPath = parts.slice(0, i + 1).join('/')
|
||||
if (!expandedPaths.has(parentPath)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
for (const file of knowledgeFiles) {
|
||||
const fullPath = toKnowledgePath(file)
|
||||
if (fullPath && isPathVisible(fullPath)) {
|
||||
visible.push(file)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}, [knowledgeFiles, expandedPaths])
|
||||
|
||||
// Get workspace root for full paths
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
|
||||
useEffect(() => {
|
||||
|
|
@ -1236,6 +1264,8 @@ function App() {
|
|||
<div className="mx-auto w-full max-w-4xl px-4">
|
||||
<ChatInputWithMentions
|
||||
knowledgeFiles={knowledgeFiles}
|
||||
recentFiles={recentWikiFiles}
|
||||
visibleFiles={visibleKnowledgeFiles}
|
||||
onSubmit={handlePromptSubmit}
|
||||
isProcessing={isProcessing}
|
||||
contextUsage={contextUsage}
|
||||
|
|
@ -1265,6 +1295,8 @@ function App() {
|
|||
maxTokens={maxTokens}
|
||||
usedTokens={usedTokens}
|
||||
knowledgeFiles={knowledgeFiles}
|
||||
recentFiles={recentWikiFiles}
|
||||
visibleFiles={visibleKnowledgeFiles}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ import {
|
|||
import { nanoid } from "nanoid";
|
||||
import { useMentionDetection } from "@/hooks/use-mention-detection";
|
||||
import { MentionPopover } from "@/components/mention-popover";
|
||||
import { toKnowledgePath } from "@/lib/wiki-links";
|
||||
import { toKnowledgePath, wikiLabel } from "@/lib/wiki-links";
|
||||
import { getMentionHighlightSegments } from "@/lib/mention-highlights";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ChangeEventHandler,
|
||||
|
|
@ -165,6 +166,8 @@ const useOptionalProviderMentions = () => useContext(ProviderMentionsContext);
|
|||
|
||||
export type KnowledgeFilesContext = {
|
||||
files: string[];
|
||||
recentFiles: string[];
|
||||
visibleFiles: string[];
|
||||
};
|
||||
|
||||
const ProviderKnowledgeFilesContext = createContext<KnowledgeFilesContext | null>(null);
|
||||
|
|
@ -176,6 +179,8 @@ export const useProviderKnowledgeFiles = () => {
|
|||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
knowledgeFiles?: string[];
|
||||
recentFiles?: string[];
|
||||
visibleFiles?: string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
|
@ -185,6 +190,8 @@ export type PromptInputProviderProps = PropsWithChildren<{
|
|||
export function PromptInputProvider({
|
||||
initialInput: initialTextInput = "",
|
||||
knowledgeFiles = [],
|
||||
recentFiles = [],
|
||||
visibleFiles = [],
|
||||
children,
|
||||
}: PromptInputProviderProps) {
|
||||
// ----- textInput state
|
||||
|
|
@ -323,8 +330,8 @@ export function PromptInputProvider({
|
|||
);
|
||||
|
||||
const knowledgeFilesContext = useMemo<KnowledgeFilesContext>(
|
||||
() => ({ files: knowledgeFiles }),
|
||||
[knowledgeFiles]
|
||||
() => ({ files: knowledgeFiles, recentFiles, visibleFiles }),
|
||||
[knowledgeFiles, recentFiles, visibleFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -913,9 +920,22 @@ export const PromptInputTextarea = ({
|
|||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentValue = controller?.textInput.value ?? "";
|
||||
const knowledgeFiles = knowledgeFilesCtx?.files ?? [];
|
||||
const recentFiles = knowledgeFilesCtx?.recentFiles ?? [];
|
||||
const visibleFiles = knowledgeFilesCtx?.visibleFiles ?? [];
|
||||
|
||||
// Build mention labels for highlighting (handles multi-word names like "AI Agents")
|
||||
const mentionLabels = useMemo(() => {
|
||||
if (knowledgeFiles.length === 0) return [];
|
||||
const labels = knowledgeFiles
|
||||
.map((path) => wikiLabel(path))
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(labels));
|
||||
}, [knowledgeFiles]);
|
||||
|
||||
const { activeMention, cursorCoords } = useMentionDetection(
|
||||
textareaRef,
|
||||
|
|
@ -923,6 +943,25 @@ export const PromptInputTextarea = ({
|
|||
knowledgeFiles.length > 0
|
||||
);
|
||||
|
||||
// Use proper regex-based highlight segmentation that handles multi-word names
|
||||
const mentionHighlights = useMemo(
|
||||
() => getMentionHighlightSegments(currentValue, activeMention, mentionLabels),
|
||||
[currentValue, activeMention, mentionLabels]
|
||||
);
|
||||
|
||||
// Sync highlight overlay scroll with textarea
|
||||
const syncHighlightScroll = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const highlight = highlightRef.current;
|
||||
if (!textarea || !highlight) return;
|
||||
highlight.scrollTop = textarea.scrollTop;
|
||||
highlight.scrollLeft = textarea.scrollLeft;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
syncHighlightScroll();
|
||||
}, [currentValue, mentionHighlights.hasHighlights, syncHighlightScroll]);
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(path: string, displayName: string) => {
|
||||
if (!controller || !activeMention) return;
|
||||
|
|
@ -1044,14 +1083,38 @@ export const PromptInputTextarea = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative contents">
|
||||
<div ref={containerRef} className="relative flex-1 min-w-0">
|
||||
{mentionHighlights.hasHighlights && (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words px-3 py-3 text-sm text-transparent"
|
||||
>
|
||||
{mentionHighlights.segments.map((segment, index) =>
|
||||
segment.highlighted ? (
|
||||
<span
|
||||
key={`mention-${index}`}
|
||||
className="rounded bg-primary/20 text-transparent ring-1 ring-primary/15 px-1 py-0.5 [box-decoration-break:clone]"
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`text-${index}`}>{segment.text}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<InputGroupTextarea
|
||||
ref={textareaRef}
|
||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||
className={cn(
|
||||
"field-sizing-content max-h-48 min-h-16 relative z-10",
|
||||
className
|
||||
)}
|
||||
name="message"
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={syncHighlightScroll}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
|
|
@ -1060,6 +1123,8 @@ export const PromptInputTextarea = ({
|
|||
{knowledgeFiles.length > 0 && (
|
||||
<MentionPopover
|
||||
files={knowledgeFiles}
|
||||
recentFiles={recentFiles}
|
||||
visibleFiles={visibleFiles}
|
||||
query={activeMention?.query ?? ""}
|
||||
position={cursorCoords}
|
||||
containerRef={containerRef}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowUp, PanelRightClose, Plus } from 'lucide-react'
|
||||
import type { LanguageModelUsage, ToolUIPart } from 'ai'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -26,6 +26,7 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme
|
|||
import { useMentionDetection } from '@/hooks/use-mention-detection'
|
||||
import { MentionPopover } from '@/components/mention-popover'
|
||||
import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { getMentionHighlightSegments } from '@/lib/mention-highlights'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
|
|
@ -115,6 +116,8 @@ interface ChatSidebarProps {
|
|||
maxTokens: number
|
||||
usedTokens: number
|
||||
knowledgeFiles?: string[]
|
||||
recentFiles?: string[]
|
||||
visibleFiles?: string[]
|
||||
selectedPath?: string | null
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +133,8 @@ export function ChatSidebar({
|
|||
onMessageChange,
|
||||
onSubmit,
|
||||
knowledgeFiles = [],
|
||||
recentFiles = [],
|
||||
visibleFiles = [],
|
||||
selectedPath,
|
||||
}: ChatSidebarProps) {
|
||||
const [width, setWidth] = useState(defaultWidth)
|
||||
|
|
@ -138,7 +143,20 @@ export function ChatSidebar({
|
|||
const startWidthRef = useRef(0)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const highlightRef = useRef<HTMLDivElement>(null)
|
||||
const [mentions, setMentions] = useState<FileMention[]>([])
|
||||
const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null)
|
||||
const lastSelectedPathRef = useRef<string | null>(null)
|
||||
|
||||
// Build mention labels for highlighting (handles multi-word names like "AI Agents")
|
||||
const mentionLabels = useMemo(() => {
|
||||
if (knowledgeFiles.length === 0) return []
|
||||
const labels = knowledgeFiles
|
||||
.map((path) => wikiLabel(path))
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean)
|
||||
return Array.from(new Set(labels))
|
||||
}, [knowledgeFiles])
|
||||
|
||||
const { activeMention, cursorCoords } = useMentionDetection(
|
||||
textareaRef,
|
||||
|
|
@ -146,6 +164,25 @@ export function ChatSidebar({
|
|||
knowledgeFiles.length > 0
|
||||
)
|
||||
|
||||
// Use proper regex-based highlight segmentation that handles multi-word names
|
||||
const mentionHighlights = useMemo(
|
||||
() => getMentionHighlightSegments(message, activeMention, mentionLabels),
|
||||
[message, activeMention, mentionLabels]
|
||||
)
|
||||
|
||||
// Sync highlight overlay scroll with textarea
|
||||
const syncHighlightScroll = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
const highlight = highlightRef.current
|
||||
if (!textarea || !highlight) return
|
||||
highlight.scrollTop = textarea.scrollTop
|
||||
highlight.scrollLeft = textarea.scrollLeft
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
syncHighlightScroll()
|
||||
}, [message, mentionHighlights.hasHighlights, syncHighlightScroll])
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(path: string, displayName: string) => {
|
||||
if (!activeMention) return
|
||||
|
|
@ -197,24 +234,54 @@ export function ChatSidebar({
|
|||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [width])
|
||||
|
||||
// Auto-focus textarea when sidebar opens and auto-populate with current file if applicable
|
||||
// Auto-focus textarea when sidebar opens
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Auto-populate with @currentfile if opening from a knowledge file
|
||||
if (selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md')) {
|
||||
// Only auto-populate if there's no existing message
|
||||
if (!message.trim()) {
|
||||
const displayName = wikiLabel(selectedPath)
|
||||
onMessageChange(`@${displayName} `)
|
||||
setMentions([{
|
||||
// Auto-populate with @currentfile when switching knowledge files
|
||||
useEffect(() => {
|
||||
if (selectedPath === lastSelectedPathRef.current) return
|
||||
lastSelectedPathRef.current = selectedPath ?? null
|
||||
|
||||
if (!selectedPath || !selectedPath.startsWith('knowledge/') || !selectedPath.endsWith('.md')) {
|
||||
return
|
||||
}
|
||||
|
||||
const displayName = wikiLabel(selectedPath)
|
||||
const previousAuto = autoMentionRef.current
|
||||
const trimmed = message.trim()
|
||||
const previousToken = previousAuto ? `@${previousAuto.displayName}` : null
|
||||
const shouldReplace = !trimmed || (previousToken && trimmed === previousToken)
|
||||
|
||||
if (!shouldReplace) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextText = `@${displayName} `
|
||||
if (message !== nextText) {
|
||||
onMessageChange(nextText)
|
||||
}
|
||||
|
||||
setMentions((prev) => {
|
||||
const withoutPrevious = previousAuto
|
||||
? prev.filter((mention) => mention.path !== previousAuto.path)
|
||||
: prev
|
||||
if (withoutPrevious.some((mention) => mention.path === selectedPath)) {
|
||||
return withoutPrevious
|
||||
}
|
||||
return [
|
||||
...withoutPrevious,
|
||||
{
|
||||
id: `mention-auto-${Date.now()}`,
|
||||
path: selectedPath,
|
||||
displayName,
|
||||
}])
|
||||
}
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
autoMentionRef.current = { path: selectedPath, displayName }
|
||||
}, [selectedPath, message, onMessageChange])
|
||||
|
||||
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
|
||||
const canSubmit = Boolean(message.trim()) && !isProcessing
|
||||
|
|
@ -377,17 +444,40 @@ export function ChatSidebar({
|
|||
{/* Input area - responsive to sidebar width, matches floating bar position exactly */}
|
||||
<div className="absolute bottom-6 left-14 right-6 z-10" ref={containerRef}>
|
||||
<div className="flex items-center gap-2 bg-background border border-border rounded-2xl shadow-xl px-4 py-2.5">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => onMessageChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything..."
|
||||
disabled={isProcessing}
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 resize-none max-h-32 min-h-[1.5rem]"
|
||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{mentionHighlights.hasHighlights && (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-sm text-transparent"
|
||||
>
|
||||
{mentionHighlights.segments.map((segment, index) =>
|
||||
segment.highlighted ? (
|
||||
<span
|
||||
key={`mention-${index}`}
|
||||
className="rounded bg-primary/20 text-transparent [box-decoration-break:clone] shadow-[inset_0_0_0_1px_hsl(var(--primary)/0.15),-3px_0_0_hsl(var(--primary)/0.2),3px_0_0_hsl(var(--primary)/0.2),0_-2px_0_hsl(var(--primary)/0.2),0_2px_0_hsl(var(--primary)/0.2)]"
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`text-${index}`}>{segment.text}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => onMessageChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={syncHighlightScroll}
|
||||
placeholder="Ask anything..."
|
||||
disabled={isProcessing}
|
||||
rows={1}
|
||||
className="relative z-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 resize-none max-h-32 min-h-[1.5rem]"
|
||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
|
|
@ -405,6 +495,8 @@ export function ChatSidebar({
|
|||
{knowledgeFiles.length > 0 && (
|
||||
<MentionPopover
|
||||
files={knowledgeFiles}
|
||||
recentFiles={recentFiles}
|
||||
visibleFiles={visibleFiles}
|
||||
query={activeMention?.query ?? ''}
|
||||
position={cursorCoords}
|
||||
containerRef={containerRef}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import type { CaretCoordinates } from '@/lib/textarea-caret'
|
|||
|
||||
interface MentionPopoverProps {
|
||||
files: string[]
|
||||
recentFiles?: string[]
|
||||
visibleFiles?: string[]
|
||||
query: string
|
||||
position: CaretCoordinates | null
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
|
|
@ -19,33 +21,64 @@ const MAX_VISIBLE_FILES = 8
|
|||
|
||||
export function MentionPopover({
|
||||
files,
|
||||
recentFiles = [],
|
||||
visibleFiles = [],
|
||||
query,
|
||||
position,
|
||||
containerRef,
|
||||
containerRef: _containerRef,
|
||||
onSelect,
|
||||
onClose,
|
||||
open,
|
||||
}: MentionPopoverProps) {
|
||||
void _containerRef // Reserved for future positioning logic
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// Filter files based on query
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!query) return files.slice(0, MAX_VISIBLE_FILES)
|
||||
|
||||
// Order files: visible > recent > rest, then filter by query
|
||||
const orderedAndFilteredFiles = useMemo(() => {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return files
|
||||
|
||||
// Create sets for quick lookup
|
||||
const visibleSet = new Set(visibleFiles)
|
||||
const recentSet = new Set(recentFiles)
|
||||
const allFiles = new Set(files)
|
||||
|
||||
// Categorize files
|
||||
const visible: string[] = []
|
||||
const recent: string[] = []
|
||||
const rest: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (visibleSet.has(file)) {
|
||||
visible.push(file)
|
||||
} else if (recentSet.has(file)) {
|
||||
recent.push(file)
|
||||
} else {
|
||||
rest.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain recent order for recent files
|
||||
const orderedRecent = recentFiles.filter(f => allFiles.has(f) && !visibleSet.has(f))
|
||||
|
||||
// Combine in order: visible > recent > rest
|
||||
const ordered = [...visible, ...orderedRecent, ...rest]
|
||||
|
||||
// Filter by query if present
|
||||
if (!query) return ordered.slice(0, MAX_VISIBLE_FILES)
|
||||
|
||||
return ordered
|
||||
.filter((path) => {
|
||||
const label = wikiLabel(path).toLowerCase()
|
||||
const normalized = stripKnowledgePrefix(path).toLowerCase()
|
||||
return label.includes(lowerQuery) || normalized.includes(lowerQuery)
|
||||
})
|
||||
.slice(0, MAX_VISIBLE_FILES)
|
||||
}, [files, query])
|
||||
}, [files, recentFiles, visibleFiles, query])
|
||||
|
||||
// Reset selection when filtered list changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [filteredFiles.length, query])
|
||||
}, [orderedAndFilteredFiles.length, query])
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
|
|
@ -56,18 +89,18 @@ export function MentionPopover({
|
|||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredFiles.length)
|
||||
setSelectedIndex((prev) => (prev + 1) % orderedAndFilteredFiles.length)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredFiles.length) % filteredFiles.length)
|
||||
setSelectedIndex((prev) => (prev - 1 + orderedAndFilteredFiles.length) % orderedAndFilteredFiles.length)
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (filteredFiles[selectedIndex]) {
|
||||
const path = filteredFiles[selectedIndex]
|
||||
if (orderedAndFilteredFiles[selectedIndex]) {
|
||||
const path = orderedAndFilteredFiles[selectedIndex]
|
||||
onSelect(path, wikiLabel(path))
|
||||
}
|
||||
break
|
||||
|
|
@ -79,14 +112,14 @@ export function MentionPopover({
|
|||
case 'Tab':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (filteredFiles[selectedIndex]) {
|
||||
const path = filteredFiles[selectedIndex]
|
||||
if (orderedAndFilteredFiles[selectedIndex]) {
|
||||
const path = orderedAndFilteredFiles[selectedIndex]
|
||||
onSelect(path, wikiLabel(path))
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
[open, filteredFiles, selectedIndex, onSelect, onClose]
|
||||
[open, orderedAndFilteredFiles, selectedIndex, onSelect, onClose]
|
||||
)
|
||||
|
||||
// Attach keyboard listener
|
||||
|
|
@ -100,7 +133,7 @@ export function MentionPopover({
|
|||
}
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
if (!open || !position || filteredFiles.length === 0) {
|
||||
if (!open || !position || orderedAndFilteredFiles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -128,10 +161,10 @@ export function MentionPopover({
|
|||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
{filteredFiles.length === 0 ? (
|
||||
{orderedAndFilteredFiles.length === 0 ? (
|
||||
<CommandEmpty>No files found</CommandEmpty>
|
||||
) : (
|
||||
filteredFiles.map((path, index) => (
|
||||
orderedAndFilteredFiles.map((path, index) => (
|
||||
<CommandItem
|
||||
key={path}
|
||||
value={path}
|
||||
|
|
|
|||
38
apps/x/apps/renderer/src/lib/mention-files.ts
Normal file
38
apps/x/apps/renderer/src/lib/mention-files.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { stripKnowledgePrefix } from '@/lib/wiki-links'
|
||||
|
||||
type BuildMentionFileListOptions = {
|
||||
files: string[]
|
||||
activePath?: string | null
|
||||
recentFiles?: string[]
|
||||
}
|
||||
|
||||
export const buildMentionFileList = ({
|
||||
files,
|
||||
activePath,
|
||||
recentFiles,
|
||||
}: BuildMentionFileListOptions) => {
|
||||
const ordered: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const normalizedFiles = files.map(stripKnowledgePrefix)
|
||||
const fileSet = new Set(normalizedFiles)
|
||||
|
||||
const addFile = (path?: string | null) => {
|
||||
if (!path) return
|
||||
const normalized = stripKnowledgePrefix(path)
|
||||
if (!fileSet.has(normalized) || seen.has(normalized)) {
|
||||
return
|
||||
}
|
||||
seen.add(normalized)
|
||||
ordered.push(normalized)
|
||||
}
|
||||
|
||||
addFile(activePath)
|
||||
for (const recent of recentFiles ?? []) {
|
||||
addFile(recent)
|
||||
}
|
||||
for (const file of normalizedFiles) {
|
||||
addFile(file)
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
115
apps/x/apps/renderer/src/lib/mention-highlights.ts
Normal file
115
apps/x/apps/renderer/src/lib/mention-highlights.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { ActiveMention } from '@/hooks/use-mention-detection'
|
||||
|
||||
type MentionRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export type MentionHighlightSegment = {
|
||||
text: string
|
||||
highlighted: boolean
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
export const getMentionHighlightSegments = (
|
||||
value: string,
|
||||
activeMention?: ActiveMention | null,
|
||||
mentionLabels?: string[]
|
||||
) => {
|
||||
if (!value) {
|
||||
return { segments: [], hasHighlights: false }
|
||||
}
|
||||
|
||||
const ranges: MentionRange[] = []
|
||||
const addRange = (start: number, end: number) => {
|
||||
if (end <= start) return
|
||||
ranges.push({ start, end })
|
||||
}
|
||||
|
||||
// First, match multi-word mention labels (like "AI Agents")
|
||||
if (mentionLabels && mentionLabels.length > 0) {
|
||||
const uniqueLabels = Array.from(
|
||||
new Set(mentionLabels.map((label) => label.trim()).filter(Boolean))
|
||||
)
|
||||
|
||||
for (const label of uniqueLabels) {
|
||||
const escaped = escapeRegExp(label)
|
||||
const labelRegex = new RegExp(
|
||||
`(^|\\s)(@${escaped})(?=$|\\s|[\\)\\]\\}\\.,!?;:])`,
|
||||
'gi'
|
||||
)
|
||||
let labelMatch: RegExpExecArray | null
|
||||
while ((labelMatch = labelRegex.exec(value)) !== null) {
|
||||
const prefix = labelMatch[1] ?? ''
|
||||
const mention = labelMatch[2] ?? ''
|
||||
if (!mention) continue
|
||||
const start = labelMatch.index + prefix.length
|
||||
const end = start + mention.length
|
||||
addRange(start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then match single-word mentions (fallback for non-file mentions)
|
||||
const mentionRegex = /(^|[\s])(@[^\s@]+)/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = mentionRegex.exec(value)) !== null) {
|
||||
const prefix = match[1] ?? ''
|
||||
const mention = match[2] ?? ''
|
||||
if (!mention) continue
|
||||
const start = match.index + prefix.length
|
||||
const end = start + mention.length
|
||||
addRange(start, end)
|
||||
}
|
||||
|
||||
// Highlight active mention trigger (just the @) when typing
|
||||
if (activeMention && activeMention.query.length === 0) {
|
||||
const start = activeMention.triggerIndex
|
||||
if (start >= 0 && start < value.length && value[start] === '@') {
|
||||
addRange(start, Math.min(value.length, start + 1))
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.length === 0) {
|
||||
return { segments: [{ text: value, highlighted: false }], hasHighlights: false }
|
||||
}
|
||||
|
||||
// Sort and merge overlapping ranges
|
||||
ranges.sort((a, b) => a.start - b.start)
|
||||
const merged: MentionRange[] = []
|
||||
for (const range of ranges) {
|
||||
const last = merged.at(-1)
|
||||
if (!last || range.start > last.end) {
|
||||
merged.push({ ...range })
|
||||
continue
|
||||
}
|
||||
last.end = Math.max(last.end, range.end)
|
||||
}
|
||||
|
||||
// Build segments from merged ranges
|
||||
const segments: MentionHighlightSegment[] = []
|
||||
let cursor = 0
|
||||
for (const range of merged) {
|
||||
if (range.start > cursor) {
|
||||
segments.push({
|
||||
text: value.slice(cursor, range.start),
|
||||
highlighted: false,
|
||||
})
|
||||
}
|
||||
if (range.end > range.start) {
|
||||
segments.push({
|
||||
text: value.slice(range.start, range.end),
|
||||
highlighted: true,
|
||||
})
|
||||
}
|
||||
cursor = range.end
|
||||
}
|
||||
if (cursor < value.length) {
|
||||
segments.push({ text: value.slice(cursor), highlighted: false })
|
||||
}
|
||||
|
||||
return { segments, hasHighlights: true }
|
||||
}
|
||||
|
|
@ -56,9 +56,6 @@ export function getCaretCoordinates(
|
|||
const style = div.style
|
||||
const computed = window.getComputedStyle(textarea)
|
||||
|
||||
// Default return value
|
||||
const defaultCoords: CaretCoordinates = { top: 0, left: 0, height: 0 }
|
||||
|
||||
// Position offscreen
|
||||
style.whiteSpace = 'pre-wrap'
|
||||
style.wordWrap = 'break-word'
|
||||
|
|
@ -68,7 +65,8 @@ export function getCaretCoordinates(
|
|||
|
||||
// Copy styles from textarea to mirror div
|
||||
for (const prop of PROPERTIES_TO_COPY) {
|
||||
style[prop as keyof CSSStyleDeclaration] = computed[prop as keyof CSSStyleDeclaration] as string
|
||||
const value = computed.getPropertyValue(prop.replace(/([A-Z])/g, '-$1').toLowerCase())
|
||||
style.setProperty(prop.replace(/([A-Z])/g, '-$1').toLowerCase(), value)
|
||||
}
|
||||
|
||||
// Firefox-specific handling
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue