add Chat context

This commit is contained in:
tusharmagar 2026-01-19 11:19:22 +05:30
parent 2f1131c57f
commit 899f22ff61
7 changed files with 794 additions and 73 deletions

View file

@ -31,9 +31,12 @@ import {
PromptInputBody,
PromptInputFooter,
type PromptInputMessage,
PromptInputProvider,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
usePromptInputController,
type FileMention,
} from '@/components/ai-elements/prompt-input';
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning';
import { Shimmer } from '@/components/ai-elements/shimmer';
@ -252,6 +255,99 @@ const collectDirPaths = (nodes: TreeNode[]): string[] =>
const collectFilePaths = (nodes: TreeNode[]): string[] =>
nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : []))
// Inner component that uses the controller to access mentions
interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
isProcessing: boolean
contextUsage: LanguageModelUsage
maxTokens: number
usedTokens: number
}
function ChatInputInner({
onSubmit,
isProcessing,
contextUsage,
maxTokens,
usedTokens,
}: ChatInputInnerProps) {
const controller = usePromptInputController()
const message = controller.textInput.value
const canSubmit = Boolean(message.trim()) && !isProcessing
const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready'
const handleSubmit = useCallback((msg: PromptInputMessage) => {
onSubmit(msg, controller.mentions.mentions)
controller.mentions.clearMentions()
}, [onSubmit, controller.mentions])
return (
<PromptInput onSubmit={handleSubmit}>
<PromptInputBody>
<PromptInputTextarea
placeholder="Type your message..."
disabled={isProcessing}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<Context
maxTokens={maxTokens}
usedTokens={usedTokens}
usage={contextUsage}
>
<ContextTrigger size="sm" />
<ContextContent>
<ContextContentHeader />
<ContextContentBody>
<ContextInputUsage />
<ContextOutputUsage />
<ContextReasoningUsage />
<ContextCacheUsage />
</ContextContentBody>
</ContextContent>
</Context>
</PromptInputTools>
<PromptInputSubmit
disabled={!canSubmit}
status={submitStatus}
/>
</PromptInputFooter>
</PromptInput>
)
}
// Wrapper component with PromptInputProvider
interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
isProcessing: boolean
contextUsage: LanguageModelUsage
maxTokens: number
usedTokens: number
}
function ChatInputWithMentions({
knowledgeFiles,
onSubmit,
isProcessing,
contextUsage,
maxTokens,
usedTokens,
}: ChatInputWithMentionsProps) {
return (
<PromptInputProvider knowledgeFiles={knowledgeFiles}>
<ChatInputInner
onSubmit={onSubmit}
isProcessing={isProcessing}
contextUsage={contextUsage}
maxTokens={maxTokens}
usedTokens={usedTokens}
/>
</PromptInputProvider>
)
}
function App() {
// File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
@ -577,7 +673,7 @@ function App() {
}
}
const handlePromptSubmit = async (message: PromptInputMessage) => {
const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => {
if (isProcessing) return
const { text } = message;
@ -604,9 +700,32 @@ function App() {
setRunId(currentRunId)
}
// Read mentioned file contents and format message with XML context
let formattedMessage = userMessage
if (mentions && mentions.length > 0) {
const attachedFiles = await Promise.all(
mentions.map(async (m) => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: m.path })
return { path: m.path, content: result.data as string }
} catch (err) {
console.error('Failed to read mentioned file:', m.path, err)
return { path: m.path, content: `[Error reading file: ${m.path}]` }
}
})
)
if (attachedFiles.length > 0) {
const filesXml = attachedFiles
.map(f => `<file path="${f.path}">\n${f.content}\n</file>`)
.join('\n')
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\n\n${userMessage}`
}
}
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: userMessage,
message: formattedMessage,
})
} catch (error) {
console.error('Failed to send message:', error)
@ -979,8 +1098,6 @@ function App() {
const conversationContentClassName = hasConversation
? "mx-auto w-full max-w-4xl pb-28"
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready'
const canSubmit = Boolean(message.trim()) && !isProcessing
const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat')
return (
@ -1117,40 +1234,14 @@ function App() {
<div className="sticky bottom-0 z-10 bg-background pb-4 pt-6 shadow-lg">
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
<div className="mx-auto w-full max-w-4xl px-4">
<PromptInput onSubmit={handlePromptSubmit}>
<PromptInputBody>
<PromptInputTextarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={isProcessing}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<Context
maxTokens={maxTokens}
usedTokens={usedTokens}
usage={contextUsage}
>
<ContextTrigger size="sm" />
<ContextContent>
<ContextContentHeader />
<ContextContentBody>
<ContextInputUsage />
<ContextOutputUsage />
<ContextReasoningUsage />
<ContextCacheUsage />
</ContextContentBody>
</ContextContent>
</Context>
</PromptInputTools>
<PromptInputSubmit
disabled={!canSubmit}
status={submitStatus}
/>
</PromptInputFooter>
</PromptInput>
<ChatInputWithMentions
knowledgeFiles={knowledgeFiles}
onSubmit={handlePromptSubmit}
isProcessing={isProcessing}
contextUsage={contextUsage}
maxTokens={maxTokens}
usedTokens={usedTokens}
/>
</div>
</div>
</div>
@ -1173,6 +1264,8 @@ function App() {
contextUsage={contextUsage}
maxTokens={maxTokens}
usedTokens={usedTokens}
knowledgeFiles={knowledgeFiles}
selectedPath={selectedPath}
/>
)}
</SidebarProvider>

View file

@ -47,6 +47,9 @@ import {
XIcon,
} from "lucide-react";
import { nanoid } from "nanoid";
import { useMentionDetection } from "@/hooks/use-mention-detection";
import { MentionPopover } from "@/components/mention-popover";
import { toKnowledgePath } from "@/lib/wiki-links";
import {
type ChangeEvent,
type ChangeEventHandler,
@ -83,6 +86,19 @@ export type AttachmentsContext = {
fileInputRef: RefObject<HTMLInputElement | null>;
};
export type FileMention = {
id: string;
path: string; // "knowledge/notes.md"
displayName: string; // "notes"
};
export type MentionsContext = {
mentions: FileMention[];
addMention: (path: string, displayName: string) => void;
removeMention: (id: string) => void;
clearMentions: () => void;
};
export type TextInputContext = {
value: string;
setInput: (v: string) => void;
@ -92,6 +108,7 @@ export type TextInputContext = {
export type PromptInputControllerProps = {
textInput: TextInputContext;
attachments: AttachmentsContext;
mentions: MentionsContext;
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
__registerFileInput: (
ref: RefObject<HTMLInputElement | null>,
@ -105,6 +122,7 @@ const PromptInputController = createContext<PromptInputControllerProps | null>(
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null
);
const ProviderMentionsContext = createContext<MentionsContext | null>(null);
export const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
@ -133,8 +151,31 @@ export const useProviderAttachments = () => {
const useOptionalProviderAttachments = () =>
useContext(ProviderAttachmentsContext);
export const useProviderMentions = () => {
const ctx = useContext(ProviderMentionsContext);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use useProviderMentions()."
);
}
return ctx;
};
const useOptionalProviderMentions = () => useContext(ProviderMentionsContext);
export type KnowledgeFilesContext = {
files: string[];
};
const ProviderKnowledgeFilesContext = createContext<KnowledgeFilesContext | null>(null);
export const useProviderKnowledgeFiles = () => {
return useContext(ProviderKnowledgeFilesContext);
};
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
knowledgeFiles?: string[];
}>;
/**
@ -143,6 +184,7 @@ export type PromptInputProviderProps = PropsWithChildren<{
*/
export function PromptInputProvider({
initialInput: initialTextInput = "",
knowledgeFiles = [],
children,
}: PromptInputProviderProps) {
// ----- textInput state
@ -227,6 +269,37 @@ export function PromptInputProvider({
[attachmentFiles, add, remove, clear, openFileDialog]
);
// ----- mentions state (for @ file mentions)
const [mentionsList, setMentionsList] = useState<FileMention[]>([]);
const addMention = useCallback((path: string, displayName: string) => {
setMentionsList((prev) => {
// Avoid duplicates
if (prev.some((m) => m.path === path)) {
return prev;
}
return [...prev, { id: nanoid(), path, displayName }];
});
}, []);
const removeMention = useCallback((id: string) => {
setMentionsList((prev) => prev.filter((m) => m.id !== id));
}, []);
const clearMentions = useCallback(() => {
setMentionsList([]);
}, []);
const mentions = useMemo<MentionsContext>(
() => ({
mentions: mentionsList,
addMention,
removeMention,
clearMentions,
}),
[mentionsList, addMention, removeMention, clearMentions]
);
const __registerFileInput = useCallback(
(ref: RefObject<HTMLInputElement | null>, open: () => void) => {
fileInputRef.current = ref.current;
@ -243,15 +316,25 @@ export function PromptInputProvider({
clear: clearInput,
},
attachments,
mentions,
__registerFileInput,
}),
[textInput, clearInput, attachments, __registerFileInput]
[textInput, clearInput, attachments, mentions, __registerFileInput]
);
const knowledgeFilesContext = useMemo<KnowledgeFilesContext>(
() => ({ files: knowledgeFiles }),
[knowledgeFiles]
);
return (
<PromptInputController.Provider value={controller}>
<ProviderAttachmentsContext.Provider value={attachments}>
{children}
<ProviderMentionsContext.Provider value={mentions}>
<ProviderKnowledgeFilesContext.Provider value={knowledgeFilesContext}>
{children}
</ProviderKnowledgeFilesContext.Provider>
</ProviderMentionsContext.Provider>
</ProviderAttachmentsContext.Provider>
</PromptInputController.Provider>
);
@ -824,10 +907,66 @@ export const PromptInputTextarea = ({
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const mentionsCtx = useOptionalProviderMentions();
const knowledgeFilesCtx = useProviderKnowledgeFiles();
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const currentValue = controller?.textInput.value ?? "";
const knowledgeFiles = knowledgeFilesCtx?.files ?? [];
const { activeMention, cursorCoords } = useMentionDetection(
textareaRef,
currentValue,
knowledgeFiles.length > 0
);
const handleMentionSelect = useCallback(
(path: string, displayName: string) => {
if (!controller || !activeMention) return;
// Calculate the text before and after the @query
const currentText = controller.textInput.value;
const beforeAt = currentText.substring(0, activeMention.triggerIndex);
const afterQuery = currentText.substring(
activeMention.triggerIndex + 1 + activeMention.query.length
);
// Replace @query with @displayName followed by a space
const newText = `${beforeAt}@${displayName} ${afterQuery}`;
controller.textInput.setInput(newText);
// Convert to knowledge path and add mention
const fullPath = toKnowledgePath(path);
if (fullPath && mentionsCtx) {
mentionsCtx.addMention(fullPath, displayName);
}
// Focus back on textarea
textareaRef.current?.focus();
},
[controller, activeMention, mentionsCtx]
);
const handleMentionClose = useCallback(() => {
// The popover handles its own closing
}, []);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
// If mention popover is open, let it handle navigation keys
if (activeMention && ["ArrowDown", "ArrowUp", "Tab"].includes(e.key)) {
// Don't prevent default here - the popover handles this via document listener
return;
}
if (e.key === "Enter") {
// If mention popover is open, Enter should select the item
if (activeMention) {
return;
}
if (isComposing || e.nativeEvent.isComposing) {
return;
}
@ -860,6 +999,12 @@ export const PromptInputTextarea = ({
attachments.remove(lastAttachment.id);
}
}
// Close mention popover on Escape
if (e.key === "Escape" && activeMention) {
// Let the popover handle this
return;
}
};
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
@ -899,17 +1044,31 @@ export const PromptInputTextarea = ({
};
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
/>
<div ref={containerRef} className="relative contents">
<InputGroupTextarea
ref={textareaRef}
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
/>
{knowledgeFiles.length > 0 && (
<MentionPopover
files={knowledgeFiles}
query={activeMention?.query ?? ""}
position={cursorCoords}
containerRef={containerRef}
onSelect={handleMentionSelect}
onClose={handleMentionClose}
open={Boolean(activeMention)}
/>
)}
</div>
);
};

View file

@ -22,7 +22,10 @@ import {
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
import { type PromptInputMessage } from '@/components/ai-elements/prompt-input'
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
import { useMentionDetection } from '@/hooks/use-mention-detection'
import { MentionPopover } from '@/components/mention-popover'
import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
interface ChatMessage {
id: string
@ -107,10 +110,12 @@ interface ChatSidebarProps {
isProcessing: boolean
message: string
onMessageChange: (message: string) => void
onSubmit: (message: PromptInputMessage) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
contextUsage: LanguageModelUsage
maxTokens: number
usedTokens: number
knowledgeFiles?: string[]
selectedPath?: string | null
}
export function ChatSidebar({
@ -124,12 +129,51 @@ export function ChatSidebar({
message,
onMessageChange,
onSubmit,
knowledgeFiles = [],
selectedPath,
}: ChatSidebarProps) {
const [width, setWidth] = useState(defaultWidth)
const [isResizing, setIsResizing] = useState(false)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
const inputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [mentions, setMentions] = useState<FileMention[]>([])
const { activeMention, cursorCoords } = useMentionDetection(
textareaRef,
message,
knowledgeFiles.length > 0
)
const handleMentionSelect = useCallback(
(path: string, displayName: string) => {
if (!activeMention) return
const beforeAt = message.substring(0, activeMention.triggerIndex)
const afterQuery = message.substring(
activeMention.triggerIndex + 1 + activeMention.query.length
)
const newText = `${beforeAt}@${displayName} ${afterQuery}`
onMessageChange(newText)
const fullPath = toKnowledgePath(path)
if (fullPath) {
setMentions(prev => {
if (prev.some(m => m.path === fullPath)) return prev
return [...prev, { id: `mention-${Date.now()}`, path: fullPath, displayName }]
})
}
textareaRef.current?.focus()
},
[activeMention, message, onMessageChange]
)
const handleMentionClose = useCallback(() => {
// The popover handles its own closing
}, [])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
@ -153,10 +197,24 @@ export function ChatSidebar({
document.addEventListener('mouseup', handleMouseUp)
}, [width])
// Auto-focus input when sidebar opens
// Auto-focus textarea when sidebar opens and auto-populate with current file if applicable
useEffect(() => {
inputRef.current?.focus()
}, [])
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([{
id: `mention-auto-${Date.now()}`,
path: selectedPath,
displayName,
}])
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
const canSubmit = Boolean(message.trim()) && !isProcessing
@ -164,14 +222,27 @@ export function ChatSidebar({
const handleSubmit = () => {
const trimmed = message.trim()
if (trimmed && !isProcessing) {
onSubmit({ text: trimmed, files: [] })
onSubmit({ text: trimmed, files: [] }, mentions)
setMentions([])
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
// If mention popover is open, let it handle navigation keys
if (activeMention && ['ArrowDown', 'ArrowUp', 'Tab', 'Escape'].includes(e.key)) {
return
}
if (e.key === 'Enter') {
// If mention popover is open, Enter should select the item
if (activeMention) {
return
}
if (!e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
}
@ -304,24 +375,25 @@ export function ChatSidebar({
</Conversation>
{/* Input area - responsive to sidebar width, matches floating bar position exactly */}
<div className="absolute bottom-6 left-14 right-6 z-10">
<div className="flex items-center gap-2 bg-background border border-border rounded-full shadow-xl px-4 py-2.5">
<input
ref={inputRef}
type="text"
<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}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50"
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}
/>
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
"h-7 w-7 rounded-full shrink-0 transition-all self-end",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
@ -330,6 +402,17 @@ export function ChatSidebar({
<ArrowUp className="h-4 w-4" />
</Button>
</div>
{knowledgeFiles.length > 0 && (
<MentionPopover
files={knowledgeFiles}
query={activeMention?.query ?? ''}
position={cursorCoords}
containerRef={containerRef}
onSelect={handleMentionSelect}
onClose={handleMentionClose}
open={Boolean(activeMention)}
/>
)}
</div>
</div>
</div>

View file

@ -0,0 +1,152 @@
import { useMemo, useEffect, useState, useCallback } from 'react'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import { wikiLabel, stripKnowledgePrefix } from '@/lib/wiki-links'
import { FileTextIcon } from 'lucide-react'
import type { CaretCoordinates } from '@/lib/textarea-caret'
interface MentionPopoverProps {
files: string[]
query: string
position: CaretCoordinates | null
containerRef: React.RefObject<HTMLElement | null>
onSelect: (path: string, displayName: string) => void
onClose: () => void
open: boolean
}
const MAX_VISIBLE_FILES = 8
export function MentionPopover({
files,
query,
position,
containerRef,
onSelect,
onClose,
open,
}: MentionPopoverProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
// Filter files based on query
const filteredFiles = useMemo(() => {
if (!query) return files.slice(0, MAX_VISIBLE_FILES)
const lowerQuery = query.toLowerCase()
return files
.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])
// Reset selection when filtered list changes
useEffect(() => {
setSelectedIndex(0)
}, [filteredFiles.length, query])
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!open) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => (prev + 1) % filteredFiles.length)
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => (prev - 1 + filteredFiles.length) % filteredFiles.length)
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
if (filteredFiles[selectedIndex]) {
const path = filteredFiles[selectedIndex]
onSelect(path, wikiLabel(path))
}
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
onClose()
break
case 'Tab':
e.preventDefault()
e.stopPropagation()
if (filteredFiles[selectedIndex]) {
const path = filteredFiles[selectedIndex]
onSelect(path, wikiLabel(path))
}
break
}
},
[open, filteredFiles, selectedIndex, onSelect, onClose]
)
// Attach keyboard listener
useEffect(() => {
if (!open) return
// Use capture phase to intercept before textarea handles it
document.addEventListener('keydown', handleKeyDown, true)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
}
}, [open, handleKeyDown])
if (!open || !position || filteredFiles.length === 0) {
return null
}
return (
<Popover open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<PopoverAnchor asChild>
<span
className="mention-popover-anchor"
style={{
position: 'absolute',
left: position.left,
top: position.top + position.height + 4,
width: 0,
height: 0,
pointerEvents: 'none',
}}
/>
</PopoverAnchor>
<PopoverContent
className="w-64 p-1"
align="start"
side="bottom"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
{filteredFiles.length === 0 ? (
<CommandEmpty>No files found</CommandEmpty>
) : (
filteredFiles.map((path, index) => (
<CommandItem
key={path}
value={path}
onSelect={() => onSelect(path, wikiLabel(path))}
className={index === selectedIndex ? 'bg-accent' : ''}
onMouseEnter={() => setSelectedIndex(index)}
>
<FileTextIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{wikiLabel(path)}</span>
</CommandItem>
))
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -144,12 +144,13 @@ function InputGroupInput({
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
const InputGroupTextarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<Textarea
ref={ref}
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
@ -158,7 +159,8 @@ function InputGroupTextarea({
{...props}
/>
)
}
})
InputGroupTextarea.displayName = "InputGroupTextarea"
export {
InputGroup,

View file

@ -0,0 +1,111 @@
import { useState, useEffect, useCallback, type RefObject } from 'react'
import { getCaretCoordinates, type CaretCoordinates } from '@/lib/textarea-caret'
export interface ActiveMention {
query: string
triggerIndex: number
}
export interface UseMentionDetectionResult {
activeMention: ActiveMention | null
cursorCoords: CaretCoordinates | null
}
/**
* Hook that detects when a user types @ in a textarea and provides
* the query string and cursor coordinates for showing a mention popover.
*/
export function useMentionDetection(
textareaRef: RefObject<HTMLTextAreaElement | null>,
value: string,
enabled: boolean
): UseMentionDetectionResult {
const [activeMention, setActiveMention] = useState<ActiveMention | null>(null)
const [cursorCoords, setCursorCoords] = useState<CaretCoordinates | null>(null)
const detectMention = useCallback(() => {
if (!enabled) {
setActiveMention(null)
setCursorCoords(null)
return
}
const textarea = textareaRef.current
if (!textarea) {
setActiveMention(null)
setCursorCoords(null)
return
}
const cursorPos = textarea.selectionStart
const textBeforeCursor = value.substring(0, cursorPos)
// Find the last @ symbol before cursor
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
if (lastAtIndex === -1) {
setActiveMention(null)
setCursorCoords(null)
return
}
// Check if @ is the start of an email (has non-whitespace before it)
if (lastAtIndex > 0) {
const charBefore = textBeforeCursor[lastAtIndex - 1]
// If char before @ is not whitespace or newline, it's likely an email
if (charBefore && !/[\s\n]/.test(charBefore)) {
setActiveMention(null)
setCursorCoords(null)
return
}
}
// Get text between @ and cursor
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1)
// If there's a space or newline after @, the mention is closed
if (/[\s\n]/.test(textAfterAt)) {
setActiveMention(null)
setCursorCoords(null)
return
}
// We have an active mention
const query = textAfterAt
setActiveMention({
query,
triggerIndex: lastAtIndex,
})
// Calculate cursor coordinates
const coords = getCaretCoordinates(textarea, lastAtIndex)
setCursorCoords(coords)
}, [textareaRef, value, enabled])
// Detect mention on value or cursor position change
useEffect(() => {
detectMention()
}, [detectMention])
// Also detect on selection change (cursor movement)
useEffect(() => {
const textarea = textareaRef.current
if (!textarea || !enabled) return
const handleSelectionChange = () => {
detectMention()
}
// Listen for selection changes
document.addEventListener('selectionchange', handleSelectionChange)
return () => {
document.removeEventListener('selectionchange', handleSelectionChange)
}
}, [textareaRef, enabled, detectMention])
return {
activeMention,
cursorCoords,
}
}

View file

@ -0,0 +1,121 @@
/**
* Get the pixel coordinates of a position within a textarea.
* Uses the mirror div technique to calculate cursor position.
*/
// Properties that affect text layout and must be copied to the mirror div
const PROPERTIES_TO_COPY = [
'direction',
'boxSizing',
'width',
'height',
'overflowX',
'overflowY',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderStyle',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontSizeAdjust',
'lineHeight',
'fontFamily',
'textAlign',
'textTransform',
'textIndent',
'textDecoration',
'letterSpacing',
'wordSpacing',
'tabSize',
'MozTabSize',
] as const
export interface CaretCoordinates {
top: number
left: number
height: number
}
export function getCaretCoordinates(
textarea: HTMLTextAreaElement,
position: number
): CaretCoordinates {
// Create a mirror div to measure text position
const div = document.createElement('div')
div.id = 'textarea-caret-position-mirror-div'
document.body.appendChild(div)
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'
style.position = 'absolute'
style.visibility = 'hidden'
style.overflow = 'hidden'
// 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
}
// Firefox-specific handling
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
if (isFirefox) {
if (textarea.scrollHeight > parseInt(computed.height)) {
style.overflowY = 'scroll'
}
} else {
style.overflow = 'hidden'
}
// Set the text content up to the position
div.textContent = textarea.value.substring(0, position)
// Create a span at the cursor position
const span = document.createElement('span')
// Add a zero-width space to ensure the span has height
span.textContent = textarea.value.substring(position) || '\u200B'
div.appendChild(span)
try {
const coordinates: CaretCoordinates = {
top: span.offsetTop + parseInt(computed.borderTopWidth) - textarea.scrollTop,
left: span.offsetLeft + parseInt(computed.borderLeftWidth) - textarea.scrollLeft,
height: parseInt(computed.lineHeight) || parseInt(computed.fontSize) * 1.2,
}
return coordinates
} finally {
document.body.removeChild(div)
}
}
/**
* Get absolute coordinates relative to the viewport
*/
export function getCaretAbsoluteCoordinates(
textarea: HTMLTextAreaElement,
position: number
): CaretCoordinates {
const relative = getCaretCoordinates(textarea, position)
const rect = textarea.getBoundingClientRect()
return {
top: rect.top + relative.top,
left: rect.left + relative.left,
height: relative.height,
}
}