Compare commits

...

6 Commits

Author SHA1 Message Date
Siddharth Ganesan
cca3d2d900 Mapping from superagnet to actions 2026-01-13 21:28:36 -08:00
Siddharth Ganesan
edd0874326 Fix newlines 2026-01-13 21:26:20 -08:00
Siddharth Ganesan
50f84dffa7 Fix 2026-01-13 21:24:10 -08:00
Siddharth Ganesan
8af735b2e5 Litn 2026-01-13 21:20:17 -08:00
Siddharth Ganesan
a54d2ffb8a Cleanup options 2026-01-13 21:20:02 -08:00
Siddharth Ganesan
356aaf1a03 Clean up 2026-01-13 21:01:48 -08:00
3 changed files with 155 additions and 322 deletions

View File

@@ -26,26 +26,14 @@ function formatTimestamp(iso: string): string {
}
}
/**
* Common text styling for loading and empty states
*/
const STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'
/**
* Loading state component for mention folders
*/
const LoadingState = () => <div className={STATE_TEXT_CLASSES}>Loading...</div>
/**
* Empty state component for mention folders
*/
const EmptyState = ({ message }: { message: string }) => (
<div className={STATE_TEXT_CLASSES}>{message}</div>
)
/**
* Aggregated item type for filtered results
*/
interface AggregatedItem {
id: string
label: string
@@ -78,14 +66,6 @@ interface MentionMenuProps {
}
}
/**
* MentionMenu component for mention menu dropdown.
* Handles rendering of mention options, submenus, and aggregated search results.
* Manages keyboard navigation and selection of mentions.
*
* @param props - Component props
* @returns Rendered mention menu
*/
export function MentionMenu({
mentionMenu,
mentionData,
@@ -100,6 +80,7 @@ export function MentionMenu({
submenuActiveIndex,
mentionActiveIndex,
openSubmenuFor,
setOpenSubmenuFor,
} = mentionMenu
const {
@@ -308,72 +289,55 @@ export function MentionMenu({
'Docs', // 7
] as const
// Get active folder based on navigation when not in submenu and no query
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
// Compute caret viewport position via mirror technique for precise anchoring
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => {
const textareaRect = textarea.getBoundingClientRect()
const style = window.getComputedStyle(textarea)
const caretPos = getCaretPos()
const textareaRect = textareaEl.getBoundingClientRect()
const style = window.getComputedStyle(textareaEl)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = message.substring(0, caretPos)
mirrorDiv.textContent = text.substring(0, caretPosition)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft
const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop
return {
left: textareaRect.left + leftOffset,
top: textareaRect.top + topOffset,
}
const caretViewport = {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
}
const caretPos = getCaretPos()
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
// Decide preferred side based on available space
const margin = 8
const spaceAbove = caretViewport.top - margin
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
return (
<Popover
open={open}
onOpenChange={() => {
/* controlled by mentionMenu */
}}
>
<Popover open={open} onOpenChange={() => {}}>
<PopoverAnchor asChild>
<div
style={{
@@ -399,7 +363,7 @@ export function MentionMenu({
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverBackButton />
<PopoverBackButton onClick={() => setOpenSubmenuFor(null)} />
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{openSubmenuFor ? (
// Submenu view - showing contents of a specific folder

View File

@@ -12,31 +12,19 @@ import {
} from '@/components/emcn'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
/**
* Top-level slash command options
*/
const TOP_LEVEL_COMMANDS = [
{ id: 'fast', label: 'fast' },
{ id: 'plan', label: 'plan' },
{ id: 'debug', label: 'debug' },
{ id: 'research', label: 'research' },
{ id: 'deploy', label: 'deploy' },
{ id: 'superagent', label: 'superagent' },
{ id: 'fast', label: 'Fast' },
{ id: 'research', label: 'Research' },
{ id: 'superagent', label: 'Actions' },
] as const
/**
* Web submenu commands
*/
const WEB_COMMANDS = [
{ id: 'search', label: 'search' },
{ id: 'read', label: 'read' },
{ id: 'scrape', label: 'scrape' },
{ id: 'crawl', label: 'crawl' },
{ id: 'search', label: 'Search' },
{ id: 'read', label: 'Read' },
{ id: 'scrape', label: 'Scrape' },
{ id: 'crawl', label: 'Crawl' },
] as const
/**
* All command labels for filtering
*/
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
interface SlashMenuProps {
@@ -45,13 +33,6 @@ interface SlashMenuProps {
onSelectCommand: (command: string) => void
}
/**
* SlashMenu component for slash command dropdown.
* Shows command options when user types '/'.
*
* @param props - Component props
* @returns Rendered slash menu
*/
export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) {
const {
mentionMenuRef,
@@ -64,92 +45,71 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
setOpenSubmenuFor,
} = mentionMenu
/**
* Get the current query string after /
*/
const currentQuery = useMemo(() => {
const caretPos = getCaretPos()
const active = getActiveSlashQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
/**
* Filter commands based on query (search across all commands when there's a query)
*/
const filteredCommands = useMemo(() => {
if (!currentQuery) return null // Show folder view when no query
return ALL_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery))
if (!currentQuery) return null
return ALL_COMMANDS.filter(
(cmd) =>
cmd.id.toLowerCase().includes(currentQuery) ||
cmd.label.toLowerCase().includes(currentQuery)
)
}, [currentQuery])
// Show aggregated view when there's a query
const showAggregatedView = currentQuery.length > 0
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
// Compute caret viewport position via mirror technique for precise anchoring
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => {
const textareaRect = textarea.getBoundingClientRect()
const style = window.getComputedStyle(textarea)
const caretPos = getCaretPos()
const textareaRect = textareaEl.getBoundingClientRect()
const style = window.getComputedStyle(textareaEl)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = message.substring(0, caretPos)
mirrorDiv.textContent = text.substring(0, caretPosition)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft
const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop
return {
left: textareaRect.left + leftOffset,
top: textareaRect.top + topOffset,
}
const caretViewport = {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
}
const caretPos = getCaretPos()
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
// Decide preferred side based on available space
const margin = 8
const spaceAbove = caretViewport.top - margin
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
// Check if we're in folder navigation mode (no query, not in submenu)
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
return (
<Popover
open={true}
onOpenChange={() => {
/* controlled externally */
}}
>
<Popover open={true} onOpenChange={() => {}}>
<PopoverAnchor asChild>
<div
style={{
@@ -175,24 +135,22 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverBackButton />
<PopoverBackButton onClick={() => setOpenSubmenuFor(null)} />
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{openSubmenuFor === 'Web' ? (
// Web submenu view
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.label)}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate capitalize'>{cmd.label}</span>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
// Aggregated filtered view
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className='px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'>
@@ -202,26 +160,25 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.label)}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate capitalize'>{cmd.label}</span>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
// Folder navigation view
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.label)}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate capitalize'>{cmd.label}</span>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
@@ -235,8 +192,8 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.label)}>
<span className='truncate capitalize'>{cmd.label}</span>
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>

View File

@@ -40,6 +40,24 @@ import { useCopilotStore } from '@/stores/panel'
const logger = createLogger('CopilotUserInput')
const TOP_LEVEL_COMMANDS = ['fast', 'research', 'superagent'] as const
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] as const
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
const COMMAND_DISPLAY_LABELS: Record<string, string> = {
superagent: 'Actions',
}
/**
* Calculates the next index for circular navigation (wraps around at bounds)
*/
function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number {
if (direction === 'down') {
return current >= maxIndex ? 0 : current + 1
}
return current <= 0 ? maxIndex : current - 1
}
interface UserInputProps {
onSubmit: (
message: string,
@@ -110,7 +128,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
},
ref
) => {
// Refs and external hooks
const { data: session } = useSession()
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -122,19 +139,16 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
// Internal state
const [internalMessage, setInternalMessage] = useState('')
const [isNearTop, setIsNearTop] = useState(false)
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
const [showSlashMenu, setShowSlashMenu] = useState(false)
// Controlled vs uncontrolled message state
const message = controlledValue !== undefined ? controlledValue : internalMessage
const setMessage =
controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage
// Effective placeholder
const effectivePlaceholder =
placeholder ||
(mode === 'ask'
@@ -143,11 +157,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
? 'Plan your workflow'
: 'Plan, search, build anything')
// Custom hooks - order matters for ref sharing
// Context management (manages selectedContexts state)
const contextManagement = useContextManagement({ message, initialContexts })
// Mention menu
const mentionMenu = useMentionMenu({
message,
selectedContexts: contextManagement.selectedContexts,
@@ -155,7 +166,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
onMessageChange: setMessage,
})
// Mention token utilities
const mentionTokensWithContext = useMentionTokens({
message,
selectedContexts: contextManagement.selectedContexts,
@@ -183,7 +193,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
isLoading,
})
// Insert mention handlers
const insertHandlers = useMentionInsertHandlers({
mentionMenu,
workflowId: workflowId || null,
@@ -191,14 +200,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
onContextAdd: contextManagement.addContext,
})
// Keyboard navigation hook
const mentionKeyboard = useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
})
// Expose focus method to parent
useImperativeHandle(
ref,
() => ({
@@ -215,9 +222,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
[mentionMenu.textareaRef]
)
// Note: textarea auto-resize is handled by the useTextareaAutoResize hook
// Load workflows on mount if we have a workflowId
useEffect(() => {
if (workflowId) {
void mentionData.ensureWorkflowsLoaded()
@@ -225,7 +229,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowId])
// Detect if input is near top of screen
useEffect(() => {
const checkPosition = () => {
if (containerRef) {
@@ -253,7 +256,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [containerRef])
// Also check position when mention menu opens
useEffect(() => {
if (mentionMenu.showMentionMenu && containerRef) {
const rect = containerRef.getBoundingClientRect()
@@ -261,7 +263,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [mentionMenu.showMentionMenu, containerRef])
// Preload mention data when query is active
useEffect(() => {
if (!mentionMenu.showMentionMenu || mentionMenu.openSubmenuFor) {
return
@@ -273,7 +274,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
.toLowerCase()
if (q && q.length > 0) {
// Prefetch all lists when there's any query for instant filtering
void mentionData.ensurePastChatsLoaded()
void mentionData.ensureWorkflowsLoaded()
void mentionData.ensureWorkflowBlocksLoaded()
@@ -282,15 +282,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
void mentionData.ensureTemplatesLoaded()
void mentionData.ensureLogsLoaded()
// Reset to first item when query changes
mentionMenu.setSubmenuActiveIndex(0)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
}
// Only depend on values that trigger data loading, not the entire objects
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionMenu.showMentionMenu, mentionMenu.openSubmenuFor, message])
// When switching into a submenu, select the first item and scroll to it
useEffect(() => {
if (mentionMenu.openSubmenuFor) {
mentionMenu.setSubmenuActiveIndex(0)
@@ -299,12 +296,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionMenu.openSubmenuFor])
// Handlers
const handleSubmit = useCallback(
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
const targetMessage = overrideMessage ?? message
const trimmedMessage = targetMessage.trim()
// Allow submission even when isLoading - store will queue the message
if (!trimmedMessage || disabled) return
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
@@ -377,17 +372,13 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const handleSlashCommandSelect = useCallback(
(command: string) => {
// Capitalize the command for display
const capitalizedCommand = command.charAt(0).toUpperCase() + command.slice(1)
// Replace the active slash query with the capitalized command
mentionMenu.replaceActiveSlashWith(capitalizedCommand)
// Add as a context so it gets highlighted
const displayLabel =
COMMAND_DISPLAY_LABELS[command] || command.charAt(0).toUpperCase() + command.slice(1)
mentionMenu.replaceActiveSlashWith(displayLabel)
contextManagement.addContext({
kind: 'slash_command',
command,
label: capitalizedCommand,
label: displayLabel,
})
setShowSlashMenu(false)
@@ -398,7 +389,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Escape key handling
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
e.preventDefault()
if (mentionMenu.openSubmenuFor) {
@@ -411,65 +401,33 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return
}
// Arrow navigation in slash menu
if (showSlashMenu) {
const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent']
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl']
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (mentionMenu.openSubmenuFor === 'Web') {
// Navigate in Web submenu
const last = WEB_COMMANDS.length - 1
mentionMenu.setSubmenuActiveIndex((prev) => {
const next =
e.key === 'ArrowDown'
? prev >= last
? 0
: prev + 1
: prev <= 0
? last
: prev - 1
const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else if (showAggregatedView) {
// Navigate in filtered view
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
const last = Math.max(0, filtered.length - 1)
mentionMenu.setSubmenuActiveIndex((prev) => {
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown'
? prev >= last
? 0
: prev + 1
: prev <= 0
? last
: prev - 1
const next = getNextIndex(prev, direction, filtered.length - 1)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else {
// Navigate in folder view (top-level + Web folder)
const totalItems = TOP_LEVEL_COMMANDS.length + 1 // +1 for Web folder
const last = totalItems - 1
mentionMenu.setMentionActiveIndex((prev) => {
const next =
e.key === 'ArrowDown'
? prev >= last
? 0
: prev + 1
: prev <= 0
? last
: prev - 1
const next = getNextIndex(prev, direction, TOP_LEVEL_COMMANDS.length)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
@@ -477,11 +435,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return
}
// Arrow right to enter Web submenu
if (e.key === 'ArrowRight') {
e.preventDefault()
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
// Check if Web folder is selected (it's after all top-level commands)
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
@@ -490,7 +446,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return
}
// Arrow left to exit submenu
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (mentionMenu.openSubmenuFor) {
@@ -500,44 +455,33 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}
// Arrow navigation in mention menu
if (mentionKeyboard.handleArrowNavigation(e)) return
if (mentionKeyboard.handleArrowRight(e)) return
if (mentionKeyboard.handleArrowLeft(e)) return
// Enter key handling
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
if (showSlashMenu) {
const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent']
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl']
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
if (mentionMenu.openSubmenuFor === 'Web') {
// Select from Web submenu
const selectedCommand =
WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
handleSlashCommandSelect(selectedCommand)
} else if (showAggregatedView) {
// Select from filtered view
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
if (filtered.length > 0) {
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
handleSlashCommandSelect(selectedCommand)
}
} else {
// Folder navigation view
const selectedIndex = mentionMenu.mentionActiveIndex
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
// Top-level command selected
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
// Web folder selected - open it
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
}
@@ -552,7 +496,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return
}
// Handle mention token behavior (backspace, delete, arrow keys) when menu is closed
if (!mentionMenu.showMentionMenu) {
const textarea = mentionMenu.textareaRef.current
const selStart = textarea?.selectionStart ?? 0
@@ -561,11 +504,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (e.key === 'Backspace' || e.key === 'Delete') {
if (selectionLength > 0) {
// Multi-character selection: Clean up contexts for any overlapping mentions
// but let the default behavior handle the actual text deletion
mentionTokensWithContext.removeContextsInSelection(selStart, selEnd)
} else {
// Single character delete - check if cursor is inside/at a mention token
const ranges = mentionTokensWithContext.computeMentionRanges()
const target =
e.key === 'Backspace'
@@ -604,7 +544,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}
// Prevent typing inside token
if (e.key.length === 1 || e.key === 'Space') {
const blocked =
selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart)
@@ -637,14 +576,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const newValue = e.target.value
setMessage(newValue)
// Skip mention menu logic if mentions are disabled
if (disableMentions) return
const caret = e.target.selectionStart ?? newValue.length
// Check for @ mention trigger
const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
// Check for / slash command trigger
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue)
if (activeMention) {
@@ -686,84 +621,66 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [mentionMenu.textareaRef, mentionTokensWithContext])
const handleOpenMentionMenuWithAt = useCallback(() => {
if (disabled || isLoading) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
textarea.focus()
const pos = textarea.selectionStart ?? message.length
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
const insertTriggerAndOpenMenu = useCallback(
(trigger: '@' | '/') => {
if (disabled || isLoading) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
const insertText = needsSpaceBefore ? ' @' : '@'
const start = textarea.selectionStart ?? message.length
const end = textarea.selectionEnd ?? message.length
const before = message.slice(0, start)
const after = message.slice(end)
const next = `${before}${insertText}${after}`
setMessage(next)
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
const start = textarea.selectionStart ?? message.length
const end = textarea.selectionEnd ?? message.length
const needsSpaceBefore = start > 0 && !/\s/.test(message.charAt(start - 1))
mentionMenu.setShowMentionMenu(true)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setMentionActiveIndex(0)
mentionMenu.setSubmenuActiveIndex(0)
}, [disabled, isLoading, mentionMenu, message, setMessage])
const insertText = needsSpaceBefore ? ` ${trigger}` : trigger
const before = message.slice(0, start)
const after = message.slice(end)
setMessage(`${before}${insertText}${after}`)
const handleOpenSlashMenu = useCallback(() => {
if (disabled || isLoading) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
textarea.focus()
const pos = textarea.selectionStart ?? message.length
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
const insertText = needsSpaceBefore ? ' /' : '/'
const start = textarea.selectionStart ?? message.length
const end = textarea.selectionEnd ?? message.length
const before = message.slice(0, start)
const after = message.slice(end)
const next = `${before}${insertText}${after}`
setMessage(next)
if (trigger === '@') {
mentionMenu.setShowMentionMenu(true)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setMentionActiveIndex(0)
} else {
setShowSlashMenu(true)
}
mentionMenu.setSubmenuActiveIndex(0)
},
[disabled, isLoading, mentionMenu, message, setMessage]
)
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
const handleOpenMentionMenuWithAt = useCallback(
() => insertTriggerAndOpenMenu('@'),
[insertTriggerAndOpenMenu]
)
setShowSlashMenu(true)
mentionMenu.setSubmenuActiveIndex(0)
}, [disabled, isLoading, mentionMenu, message, setMessage])
const handleOpenSlashMenu = useCallback(
() => insertTriggerAndOpenMenu('/'),
[insertTriggerAndOpenMenu]
)
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
const showAbortButton = isLoading && onAbort
// Render overlay content with highlighted mentions
const renderOverlayContent = useCallback(() => {
const contexts = contextManagement.selectedContexts
// Handle empty message
if (!message) {
return <span>{'\u00A0'}</span>
}
// If no contexts, render the message directly with proper newline handling
if (contexts.length === 0) {
// Add a zero-width space at the end if message ends with newline
// This ensures the newline is rendered and height is calculated correctly
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
return <span>{displayText}</span>
}
const elements: React.ReactNode[] = []
const labels = contexts.map((c) => c.label).filter(Boolean)
// Build ranges for all mentions to highlight them including spaces
const ranges = mentionTokensWithContext.computeMentionRanges()
if (ranges.length === 0) {
@@ -775,14 +692,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
// Add text before mention
if (range.start > lastIndex) {
const before = message.slice(lastIndex, range.start)
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
}
// Add highlighted mention (including spaces)
// Use index + start + end to ensure unique keys even with duplicate contexts
const mentionText = message.slice(range.start, range.end)
elements.push(
<span
@@ -797,12 +711,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const tail = message.slice(lastIndex)
if (tail) {
// Add a zero-width space at the end if tail ends with newline
const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail
elements.push(<span key={`tail-${lastIndex}`}>{displayTail}</span>)
}
// Ensure there's always something to render for height calculation
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])