mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
302
apps/agent/components/elements/tab-picker-popover.tsx
Normal file
302
apps/agent/components/elements/tab-picker-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user