feat: tab-picker + tab-popover merged with simple ui (#296)

Co-authored-by: Dani Akash <DaniAkash@users.noreply.github.com>
This commit is contained in:
Nikhil
2026-02-03 09:53:09 -08:00
committed by GitHub
parent 7788695230
commit b36d74638c
6 changed files with 593 additions and 468 deletions

View File

@@ -1,189 +0,0 @@
import type * as React from 'react'
import type { FC } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { TabListItem } from './tab-list-item'
import { useAvailableTabs } from './use-available-tabs'
interface TabMentionPopoverProps {
isOpen: boolean
filterText: string
selectedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
onClose: () => void
anchorRef: React.RefObject<HTMLTextAreaElement | null>
}
export const TabMentionPopover: FC<TabMentionPopoverProps> = ({
isOpen,
filterText,
selectedTabs,
onToggleTab,
onClose,
anchorRef,
}) => {
const { tabs, allTabs } = useAvailableTabs({ enabled: isOpen, filterText })
const [focusedIndex, setFocusedIndex] = useState(0)
const listRef = useRef<HTMLDivElement>(null)
const selectedTabIds = useMemo(
() => new Set(selectedTabs.map((t) => t.id)),
[selectedTabs],
)
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset focus when filter changes
useEffect(() => {
setFocusedIndex(0)
}, [filterText])
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setFocusedIndex((prev) => (prev < tabs.length - 1 ? prev + 1 : prev))
break
case 'ArrowUp':
e.preventDefault()
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
case 'Enter':
e.preventDefault()
if (tabs[focusedIndex]) {
onToggleTab(tabs[focusedIndex])
}
break
case 'Escape':
e.preventDefault()
onClose()
break
case 'Tab':
onClose()
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, tabs, focusedIndex, onToggleTab, onClose])
useEffect(() => {
if (listRef.current && focusedIndex >= 0) {
const items = listRef.current.querySelectorAll('[data-tab-item]')
items[focusedIndex]?.scrollIntoView({ block: 'nearest' })
}
}, [focusedIndex])
if (!isOpen) return null
return (
<Popover open={isOpen} onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor
virtualRef={anchorRef as React.RefObject<HTMLTextAreaElement>}
/>
<PopoverContent
side="top"
align="start"
sideOffset={8}
className="w-[calc(100vw-24px)] max-w-[400px] p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
role="dialog"
aria-label="Select tabs to attach"
>
<Command
className="[&_svg:not([class*='text-'])]:text-muted-foreground"
shouldFilter={false}
>
<div className="border-border/50 border-b px-3 py-2">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted-foreground text-xs uppercase tracking-wide">
Attach Tabs
</span>
{filterText && (
<span className="text-muted-foreground text-xs">
Filtering: "{filterText}"
</span>
)}
</div>
{selectedTabs.length > 0 && (
<span className="mt-1 block text-[var(--accent-orange)] text-xs">
{selectedTabs.length} tab{selectedTabs.length !== 1 ? 's' : ''}{' '}
selected
</span>
)}
</div>
<CommandList
ref={listRef}
className="max-h-64 overflow-auto"
role="listbox"
aria-label="Available tabs"
aria-multiselectable="true"
>
<CommandEmpty className="py-6 text-center">
<div className="text-muted-foreground text-sm">
{allTabs.length === 0
? 'No active tabs'
: `No tabs matching "${filterText}"`}
</div>
<div className="mt-1 text-muted-foreground/70 text-xs">
{allTabs.length === 0
? 'Open some web pages to attach them'
: 'Try a different search term'}
</div>
</CommandEmpty>
<CommandGroup>
{tabs.map((tab, index) => (
<CommandItem
key={tab.id}
data-tab-item
value={`${tab.id}`}
onSelect={() => onToggleTab(tab)}
onMouseEnter={() => setFocusedIndex(index)}
className="p-0 data-[selected=true]:bg-transparent"
>
<TabListItem
tab={tab}
isSelected={selectedTabIds.has(tab.id)}
className={index === focusedIndex ? 'bg-accent' : undefined}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
<div className="border-border/50 border-t px-3 py-2">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>
<kbd className="rounded border border-border bg-muted px-1 py-0.5">
</kbd>{' '}
navigate
</span>
<span>
<kbd className="rounded border border-border bg-muted px-1 py-0.5">
Enter
</kbd>{' '}
select
</span>
<span>
<kbd className="rounded border border-border bg-muted px-1 py-0.5">
Esc
</kbd>{' '}
close
</span>
</div>
</div>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,302 @@
import type * as React from 'react'
import type { FC, PropsWithChildren } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { TabListItem } from './tab-list-item'
import { useAvailableTabs } from './use-available-tabs'
type PopoverSide = 'top' | 'bottom' | 'left' | 'right'
interface TabPickerCommonProps {
selectedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
}
interface TabPickerMentionPopoverProps extends TabPickerCommonProps {
variant: 'mention'
isOpen: boolean
filterText: string
onClose: () => void
anchorRef: React.RefObject<HTMLElement | null>
}
interface TabPickerSelectorPopoverProps
extends PropsWithChildren<TabPickerCommonProps> {
variant: 'selector'
side?: PopoverSide
}
type TabPickerPopoverProps =
| TabPickerMentionPopoverProps
| TabPickerSelectorPopoverProps
export const TabPickerPopover: FC<TabPickerPopoverProps> = (props) => {
if (props.variant === 'mention') {
return <TabPickerMentionPopover {...props} />
}
return <TabPickerSelectorPopover {...props} />
}
const TabPickerMentionPopover: FC<TabPickerMentionPopoverProps> = ({
isOpen,
filterText,
selectedTabs,
onToggleTab,
onClose,
anchorRef,
}) => {
const { tabs, allTabs, isLoading } = useAvailableTabs({
enabled: isOpen,
filterText,
})
const selectedTabIds = useMemo(
() => new Set(selectedTabs.map((t) => t.id)),
[selectedTabs],
)
const [focusedIndex, setFocusedIndex] = useState(0)
const listRef = useRef<HTMLDivElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset focus when filter changes
useEffect(() => {
setFocusedIndex(0)
}, [filterText])
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setFocusedIndex((prev) => (prev < tabs.length - 1 ? prev + 1 : prev))
break
case 'ArrowUp':
e.preventDefault()
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
case 'Enter':
e.preventDefault()
if (tabs[focusedIndex]) {
onToggleTab(tabs[focusedIndex])
}
break
case 'Escape':
e.preventDefault()
onClose()
break
case 'Tab':
onClose()
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, tabs, focusedIndex, onToggleTab, onClose])
useEffect(() => {
if (listRef.current && focusedIndex >= 0) {
const items = listRef.current.querySelectorAll('[data-tab-item]')
items[focusedIndex]?.scrollIntoView({ block: 'nearest' })
}
}, [focusedIndex])
if (!isOpen) return null
return (
<Popover open={isOpen} onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor virtualRef={anchorRef as React.RefObject<HTMLElement>} />
<PopoverContent
side="top"
align="start"
sideOffset={8}
className="w-[calc(100vw-24px)] max-w-[400px] p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
role="dialog"
aria-label="Select tabs to attach"
>
<Command
className="[&_svg:not([class*='text-'])]:text-muted-foreground"
shouldFilter={false}
>
<div className="border-border/50 border-b px-3 py-2">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted-foreground text-xs uppercase tracking-wide">
Attach Tabs
</span>
<span className="text-muted-foreground text-xs">
{filterText ? `Filtering: "${filterText}"` : 'Type to filter'}
</span>
</div>
{selectedTabs.length > 0 && (
<span className="mt-1 block text-[var(--accent-orange)] text-xs">
{selectedTabs.length} tab{selectedTabs.length !== 1 ? 's' : ''}{' '}
selected
</span>
)}
</div>
<CommandList
ref={listRef}
className="max-h-64 overflow-auto"
role="listbox"
aria-label="Available tabs"
aria-multiselectable="true"
>
<CommandEmpty className="py-6 text-center">
{isLoading ? (
<div className="text-muted-foreground text-sm">
Loading tabs
</div>
) : (
<>
<div className="text-muted-foreground text-sm">
{allTabs.length === 0
? 'No active tabs'
: `No tabs matching "${filterText}"`}
</div>
<div className="mt-1 text-muted-foreground/70 text-xs">
{allTabs.length === 0
? 'Open some web pages to attach them'
: 'Try a different search term'}
</div>
</>
)}
</CommandEmpty>
<CommandGroup>
{tabs.map((tab, index) => (
<CommandItem
key={tab.id}
data-tab-item
value={`${tab.id}`}
onSelect={() => onToggleTab(tab)}
onMouseEnter={() => setFocusedIndex(index)}
className="p-0 data-[selected=true]:bg-transparent"
>
<TabListItem
tab={tab}
isSelected={selectedTabIds.has(tab.id)}
className={index === focusedIndex ? 'bg-accent' : undefined}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
const TabPickerSelectorPopover: FC<TabPickerSelectorPopoverProps> = ({
children,
selectedTabs,
onToggleTab,
side,
}) => {
const [open, setOpen] = useState(false)
const [filterText, setFilterText] = useState('')
const { tabs, allTabs, isLoading } = useAvailableTabs({
enabled: open,
filterText,
})
const selectedTabIds = useMemo(
() => new Set(selectedTabs.map((t) => t.id)),
[selectedTabs],
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side={side ?? 'bottom'}
align="start"
className="w-72 p-0"
role="dialog"
aria-label="Select tabs"
>
<Command
className="[&_svg:not([class*='text-'])]:text-muted-foreground"
shouldFilter={false}
>
<CommandInput
placeholder="Search tabs..."
className="h-9"
value={filterText}
onValueChange={setFilterText}
/>
<CommandList
className="max-h-64 overflow-auto"
role="listbox"
aria-label="Available tabs"
aria-multiselectable="true"
>
<div className="border-border/50 border-b px-3 py-2">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted-foreground text-xs uppercase tracking-wide">
Tabs
</span>
{selectedTabs.length > 0 && (
<span className="text-[var(--accent-orange)] text-xs">
{selectedTabs.length} selected
</span>
)}
</div>
</div>
<CommandEmpty className="py-6 text-center">
{isLoading ? (
<div className="text-muted-foreground text-sm">
Loading tabs
</div>
) : (
<>
<div className="text-muted-foreground text-sm">
{allTabs.length === 0
? 'No active tabs'
: `No tabs matching "${filterText}"`}
</div>
<div className="mt-1 text-muted-foreground/70 text-xs">
{allTabs.length === 0
? 'Open some web pages to attach them'
: 'Try a different search term'}
</div>
</>
)}
</CommandEmpty>
<CommandGroup>
{tabs.map((tab) => (
<CommandItem
key={tab.id}
value={`${tab.id} ${tab.title} ${tab.url}`}
onSelect={() => onToggleTab(tab)}
className="p-0"
>
<TabListItem
tab={tab}
isSelected={selectedTabIds.has(tab.id)}
className="p-3"
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,100 +0,0 @@
import type { FC, PropsWithChildren } from 'react'
import { useMemo, useState } from 'react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { TabListItem } from './tab-list-item'
import { useAvailableTabs } from './use-available-tabs'
interface TabSelectorProps {
selectedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
side?: 'top' | 'bottom' | 'left' | 'right'
}
export const TabSelector: FC<PropsWithChildren<TabSelectorProps>> = ({
children,
selectedTabs,
onToggleTab,
side = 'bottom',
}) => {
const [open, setOpen] = useState(false)
const [filterText, setFilterText] = useState('')
const { tabs } = useAvailableTabs({ enabled: open, filterText })
const selectedTabIds = useMemo(
() => new Set(selectedTabs.map((t) => t.id)),
[selectedTabs],
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side={side}
align="start"
className="w-72 p-0"
role="dialog"
aria-label="Select tabs"
>
<Command
className="[&_svg:not([class*='text-'])]:text-muted-foreground"
shouldFilter={false}
>
<CommandInput
placeholder="Search tabs..."
className="h-9"
value={filterText}
onValueChange={setFilterText}
/>
<CommandList
className="max-h-64 overflow-auto"
role="listbox"
aria-label="Available tabs"
aria-multiselectable="true"
>
<div className="my-3 px-2 font-semibold text-muted-foreground text-xs uppercase tracking-wide">
Select Tabs
</div>
<CommandEmpty>No active tabs</CommandEmpty>
<CommandGroup>
{tabs.map((tab) => (
<CommandItem
key={tab.id}
value={`${tab.id} ${tab.title} ${tab.url}`}
onSelect={() => onToggleTab(tab)}
className="p-0"
>
<TabListItem
tab={tab}
isSelected={selectedTabIds.has(tab.id)}
className="p-3"
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
<div className="border-border/50 border-t px-3 py-2">
<span className="text-[10px] text-muted-foreground">
Tip: Type{' '}
<kbd className="rounded border border-border bg-muted px-1 py-0.5">
@
</kbd>{' '}
in chat to mention tabs
</span>
</div>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -16,7 +16,7 @@ import {
GlowingBorder,
GlowingElement,
} from '@/components/elements/glowing-border'
import { TabSelector } from '@/components/elements/tab-selector'
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
import { Button } from '@/components/ui/button'
import {
@@ -362,7 +362,8 @@ export const NewTab = () => {
)}
<div className="relative" ref={tabsDropdownRef}>
<TabSelector
<TabPickerPopover
variant="selector"
selectedTabs={selectedTabs}
onToggleTab={toggleTab}
>
@@ -379,7 +380,7 @@ export const NewTab = () => {
<Layers className="h-4 w-4" />
<span>Tabs</span>
</Button>
</TabSelector>
</TabPickerPopover>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { ChevronDown, Folder, Layers, PlugZap } from 'lucide-react'
import type { FC, FormEvent } from 'react'
import { useRef, useState } from 'react'
import { AppSelector } from '@/components/elements/AppSelector'
import { TabSelector } from '@/components/elements/tab-selector'
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
@@ -11,7 +11,7 @@ import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
import { cn } from '@/lib/utils'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { ChatAttachedTabs } from './ChatAttachedTabs'
import { ChatInput } from './ChatInput'
import { ChatInput, type ChatInputHandle } from './ChatInput'
import { ChatModeToggle } from './ChatModeToggle'
import type { ChatMode } from './chatTypes'
@@ -44,6 +44,8 @@ export const ChatFooter: FC<ChatFooterProps> = ({
const { supports } = useCapabilities()
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
const chatInputRef = useRef<ChatInputHandle>(null)
const [isTabMentionOpen, setIsTabMentionOpen] = useState(false)
const connectedManagedServers = mcpServers.filter((s) => {
if (s.type !== 'managed' || !s.managedServerName) return false
@@ -63,25 +65,24 @@ export const ChatFooter: FC<ChatFooterProps> = ({
<div className="h-4 w-px bg-border/50" />
<div className="flex items-center gap-1">
<TabSelector
selectedTabs={attachedTabs}
onToggleTab={onToggleTab}
side="top"
<button
type="button"
onClick={() => chatInputRef.current?.toggleTabMention()}
data-tab-mention-trigger
data-state={isTabMentionOpen ? 'open' : 'closed'}
aria-expanded={isTabMentionOpen}
aria-haspopup="dialog"
className="flex cursor-pointer items-center gap-1 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
title="Attach tabs (@)"
>
<button
type="button"
className="flex cursor-pointer items-center gap-1 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
title="Select tabs"
>
<Layers className="h-4 w-4" />
{attachedTabs.length > 0 && (
<span className="font-medium text-[var(--accent-orange)] text-xs">
{attachedTabs.length}
</span>
)}
<ChevronDown className="h-3 w-3" />
</button>
</TabSelector>
<Layers className="h-4 w-4" />
{attachedTabs.length > 0 && (
<span className="font-medium text-[var(--accent-orange)] text-xs">
{attachedTabs.length}
</span>
)}
<ChevronDown className="h-3 w-3" />
</button>
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) && (
<WorkspaceSelector side="top">
@@ -157,6 +158,8 @@ export const ChatFooter: FC<ChatFooterProps> = ({
onStop={onStop}
selectedTabs={attachedTabs}
onToggleTab={onToggleTab}
onTabMentionOpenChange={setIsTabMentionOpen}
ref={chatInputRef}
/>
</div>
</footer>

View File

@@ -1,7 +1,14 @@
import { Send, SquareStop } from 'lucide-react'
import type { FC, FormEvent, KeyboardEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { TabMentionPopover } from '@/components/elements/tab-mention-popover'
import type { FormEvent, KeyboardEvent } from 'react'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react'
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
import { cn } from '@/lib/utils'
import type { ChatMode } from './chatTypes'
@@ -20,180 +27,281 @@ interface ChatInputProps {
onStop: () => void
selectedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
onTabMentionOpenChange?: (isOpen: boolean) => void
}
export const ChatInput: FC<ChatInputProps> = ({
input,
status,
mode,
onInputChange,
onSubmit,
onStop,
selectedTabs,
onToggleTab,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [mentionState, setMentionState] = useState<MentionState>({
isOpen: false,
filterText: '',
startPosition: 0,
})
export interface ChatInputHandle {
openTabMention: () => void
closeTabMention: () => void
toggleTabMention: () => void
focus: () => void
}
const inputRef = useRef(input)
const mentionStateRef = useRef(mentionState)
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
(
{
input,
status,
mode,
onInputChange,
onSubmit: onSubmitProp,
onStop,
selectedTabs,
onToggleTab,
onTabMentionOpenChange,
},
ref,
) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [mentionState, setMentionState] = useState<MentionState>({
isOpen: false,
filterText: '',
startPosition: 0,
})
useEffect(() => {
inputRef.current = input
mentionStateRef.current = mentionState
})
const inputRef = useRef(input)
const mentionStateRef = useRef(mentionState)
const closeMention = useCallback(() => {
const state = mentionStateRef.current
if (state.isOpen) {
const currentInput = inputRef.current
const beforeMention = currentInput.slice(0, state.startPosition)
const afterMention = currentInput.slice(
state.startPosition + 1 + state.filterText.length,
)
onInputChange(beforeMention + afterMention)
setMentionState({ isOpen: false, filterText: '', startPosition: 0 })
useEffect(() => {
inputRef.current = input
mentionStateRef.current = mentionState
})
requestAnimationFrame(() => {
textareaRef.current?.focus()
const newPosition = beforeMention.length
textareaRef.current?.setSelectionRange(newPosition, newPosition)
})
}
}, [onInputChange])
useEffect(() => {
onTabMentionOpenChange?.(mentionState.isOpen)
}, [mentionState.isOpen, onTabMentionOpenChange])
const handleInputChange = (value: string) => {
const textarea = textareaRef.current
const cursorPosition = textarea?.selectionStart ?? value.length
if (mentionState.isOpen) {
const textAfterAt = value.slice(mentionState.startPosition + 1)
const spaceIndex = textAfterAt.search(/\s/)
const filterText =
spaceIndex === -1 ? textAfterAt : textAfterAt.slice(0, spaceIndex)
if (
cursorPosition <= mentionState.startPosition ||
value[mentionState.startPosition] !== '@'
) {
setMentionState({ isOpen: false, filterText: '', startPosition: 0 })
} else {
setMentionState((prev) => ({ ...prev, filterText }))
}
} else {
const charBeforeCursor = value[cursorPosition - 1]
const textBeforeAt = value.slice(0, cursorPosition - 1)
const isAtWordBoundary = /(?:^|[\s\n])$/.test(textBeforeAt)
if (charBeforeCursor === '@' && isAtWordBoundary) {
setMentionState({
isOpen: true,
const closeMention = useCallback(() => {
const state = mentionStateRef.current
if (state.isOpen) {
const currentInput = inputRef.current
const beforeMention = currentInput.slice(0, state.startPosition)
const afterMention = currentInput.slice(
state.startPosition + 1 + state.filterText.length,
)
const nextInput = beforeMention + afterMention
inputRef.current = nextInput
onInputChange(nextInput)
const nextMentionState = {
isOpen: false,
filterText: '',
startPosition: cursorPosition - 1,
startPosition: 0,
}
mentionStateRef.current = nextMentionState
setMentionState(nextMentionState)
requestAnimationFrame(() => {
textareaRef.current?.focus()
const newPosition = beforeMention.length
textareaRef.current?.setSelectionRange(newPosition, newPosition)
})
}
}
}, [onInputChange])
onInputChange(value)
}
const openMentionAtCursor = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (mentionState.isOpen) {
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'Enter' ||
e.key === 'Escape'
) {
textarea.focus()
if (mentionStateRef.current.isOpen) return
const currentInput = inputRef.current
const cursorPosition = textarea.selectionStart ?? currentInput.length
const beforeCursor = currentInput.slice(0, cursorPosition)
const afterCursor = currentInput.slice(cursorPosition)
const nextInput = `${beforeCursor}@${afterCursor}`
inputRef.current = nextInput
onInputChange(nextInput)
const nextMentionState = {
isOpen: true,
filterText: '',
startPosition: cursorPosition,
}
mentionStateRef.current = nextMentionState
setMentionState(nextMentionState)
requestAnimationFrame(() => {
textarea.focus()
const newPosition = cursorPosition + 1
textarea.setSelectionRange(newPosition, newPosition)
})
}, [onInputChange])
const toggleMentionAtCursor = useCallback(() => {
if (mentionStateRef.current.isOpen) {
closeMention()
return
}
if (e.key === 'Tab') {
openMentionAtCursor()
}, [closeMention, openMentionAtCursor])
useImperativeHandle(
ref,
() => ({
openTabMention: openMentionAtCursor,
closeTabMention: closeMention,
toggleTabMention: toggleMentionAtCursor,
focus: () => textareaRef.current?.focus(),
}),
[closeMention, openMentionAtCursor, toggleMentionAtCursor],
)
const handleSubmit = (e: FormEvent) => {
if (mentionStateRef.current.isOpen) {
e.preventDefault()
closeMention()
return
}
onSubmitProp(e)
}
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault()
if (input.trim()) {
e.currentTarget.form?.requestSubmit()
}
}
}
const handleInputChange = (value: string) => {
const textarea = textareaRef.current
const cursorPosition = textarea?.selectionStart ?? value.length
useEffect(() => {
if (!mentionState.isOpen) return
const state = mentionStateRef.current
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (
!textareaRef.current?.contains(target) &&
!target.closest('[data-slot="popover-content"]')
) {
closeMention()
}
}
if (state.isOpen) {
const textAfterAt = value.slice(state.startPosition + 1)
const spaceIndex = textAfterAt.search(/\s/)
const filterText =
spaceIndex === -1 ? textAfterAt : textAfterAt.slice(0, spaceIndex)
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [mentionState.isOpen, closeMention])
return (
<form
onSubmit={onSubmit}
className="relative mt-2 flex w-full items-end gap-2"
>
<TabMentionPopover
isOpen={mentionState.isOpen}
filterText={mentionState.filterText}
selectedTabs={selectedTabs}
onToggleTab={onToggleTab}
onClose={closeMention}
anchorRef={textareaRef}
/>
<textarea
ref={textareaRef}
className={cn(
'field-sizing-content max-h-60 min-h-[42px] flex-1 resize-none overflow-hidden rounded-2xl border border-border/50 bg-muted/50 px-4 py-2.5 pr-11 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
)}
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
mode === 'chat' ? 'Ask about this page...' : 'What should I do?'
if (
cursorPosition <= state.startPosition ||
value[state.startPosition] !== '@'
) {
const nextMentionState = {
isOpen: false,
filterText: '',
startPosition: 0,
}
mentionStateRef.current = nextMentionState
setMentionState(nextMentionState)
} else {
const nextMentionState = { ...state, filterText }
mentionStateRef.current = nextMentionState
setMentionState(nextMentionState)
}
rows={1}
/>
{status === 'streaming' ? (
<button
type="button"
onClick={onStop}
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
>
<SquareStop className="h-3.5 w-3.5" />
<span className="sr-only">Stop</span>
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
<span className="sr-only">Send</span>
</button>
)}
</form>
)
}
} else {
const charBeforeCursor = value[cursorPosition - 1]
const textBeforeAt = value.slice(0, cursorPosition - 1)
const isAtWordBoundary = /(?:^|[\s\n])$/.test(textBeforeAt)
if (charBeforeCursor === '@' && isAtWordBoundary) {
const nextMentionState = {
isOpen: true,
filterText: '',
startPosition: cursorPosition - 1,
}
mentionStateRef.current = nextMentionState
setMentionState(nextMentionState)
}
}
inputRef.current = value
onInputChange(value)
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (mentionState.isOpen) {
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'Enter' ||
e.key === 'Escape'
) {
return
}
if (e.key === 'Tab') {
e.preventDefault()
closeMention()
return
}
}
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault()
if (input.trim()) {
e.currentTarget.form?.requestSubmit()
}
}
}
useEffect(() => {
if (!mentionState.isOpen) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-tab-mention-trigger]')) return
if (
!textareaRef.current?.contains(target) &&
!target.closest('[data-slot="popover-content"]')
) {
closeMention()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [mentionState.isOpen, closeMention])
return (
<form
onSubmit={handleSubmit}
className="relative mt-2 flex w-full items-end gap-2"
>
<TabPickerPopover
variant="mention"
isOpen={mentionState.isOpen}
filterText={mentionState.filterText}
selectedTabs={selectedTabs}
onToggleTab={onToggleTab}
onClose={closeMention}
anchorRef={textareaRef}
/>
<textarea
ref={textareaRef}
className={cn(
'field-sizing-content max-h-60 min-h-[42px] flex-1 resize-none overflow-hidden rounded-2xl border border-border/50 bg-muted/50 px-4 py-2.5 pr-11 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
)}
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
mode === 'chat' ? 'Ask about this page...' : 'What should I do?'
}
rows={1}
/>
{status === 'streaming' ? (
<button
type="button"
onClick={onStop}
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
>
<SquareStop className="h-3.5 w-3.5" />
<span className="sr-only">Stop</span>
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
<span className="sr-only">Send</span>
</button>
)}
</form>
)
},
)
ChatInput.displayName = 'ChatInput'