Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Brain, Check, FileText } from 'lucide-react'
import type { FC } from 'react'
import type { ContextAttachment } from '@/lib/context-attachments'
import { cn } from '@/lib/utils'

interface ContextListItemProps {
attachment: ContextAttachment
isSelected: boolean
className?: string
}

export const ContextListItem: FC<ContextListItemProps> = ({
attachment,
isSelected,
className,
}) => {
const Icon = attachment.kind === 'memory' ? Brain : FileText

return (
<div
className={cn(
'flex w-full cursor-pointer items-center gap-3 rounded-lg p-2.5 transition-colors',
className,
)}
>
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border transition-colors',
isSelected
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]'
: 'border-border bg-background',
)}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</div>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border border-border bg-background">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground text-xs">
{attachment.title}
</div>
{attachment.source ? (
<div className="truncate text-[10px] text-muted-foreground">
{attachment.source}
</div>
) : null}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import type { ContextAttachment } from '@/lib/context-attachments'
import { ContextListItem } from './context-list-item'
import { TabListItem } from './tab-list-item'
import {
type ContextPickerItem,
useAvailableContext,
} from './use-available-context'
import { useAvailableTabs } from './use-available-tabs'

type PopoverSide = 'top' | 'bottom' | 'left' | 'right'
Expand All @@ -29,6 +35,8 @@ interface TabPickerMentionPopoverProps extends TabPickerCommonProps {
variant: 'mention'
isOpen: boolean
filterText: string
selectedContexts?: ContextAttachment[]
onToggleContext?: (attachment: ContextAttachment) => void
onClose: () => void
anchorRef: React.RefObject<HTMLElement | null>
side?: PopoverSide
Expand All @@ -55,21 +63,47 @@ const TabPickerMentionPopover: FC<TabPickerMentionPopoverProps> = ({
isOpen,
filterText,
selectedTabs,
selectedContexts = [],
onToggleTab,
onToggleContext,
onClose,
anchorRef,
side,
}) => {
const { tabs, allTabs, isLoading } = useAvailableTabs({
enabled: isOpen,
filterText,
})
const contextEnabled = Boolean(onToggleContext)
const { tabs, files, memories, allTabs, isLoading, hasWorkspace } =
useAvailableContext({
enabled: isOpen,
filterText,
includeAttachments: contextEnabled,
})
const visibleFiles = contextEnabled ? files : []
const visibleMemories = contextEnabled ? memories : []
const items = useMemo<ContextPickerItem[]>(
() => [
...tabs.map((tab) => ({ type: 'tab' as const, tab })),
...visibleFiles.map((attachment) => ({
type: 'file' as const,
attachment,
})),
...visibleMemories.map((attachment) => ({
type: 'memory' as const,
attachment,
})),
],
[tabs, visibleFiles, visibleMemories],
)
const selectedTabIds = useMemo(
() => new Set(selectedTabs.map((t) => t.id)),
[selectedTabs],
)
const selectedContextIds = useMemo(
() => new Set(selectedContexts.map((context) => context.id)),
[selectedContexts],
)
const [focusedIndex, setFocusedIndex] = useState(0)
const listRef = useRef<HTMLDivElement>(null)
const selectedCount = selectedTabs.length + selectedContexts.length

// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset focus when filter changes
useEffect(() => {
Expand All @@ -94,17 +128,18 @@ const TabPickerMentionPopover: FC<TabPickerMentionPopoverProps> = ({
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setFocusedIndex((prev) => (prev < tabs.length - 1 ? prev + 1 : prev))
setFocusedIndex((prev) => (prev < items.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])
}
toggleContextPickerItem(items[focusedIndex], {
onToggleTab,
onToggleContext,
})
break
case 'Escape':
e.preventDefault()
Expand All @@ -119,12 +154,12 @@ const TabPickerMentionPopover: FC<TabPickerMentionPopoverProps> = ({

document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [isOpen, tabs, focusedIndex, onToggleTab, onClose])
}, [isOpen, items, focusedIndex, onToggleTab, onToggleContext, onClose])

useEffect(() => {
if (listRef.current && focusedIndex >= 0) {
const items = listRef.current.querySelectorAll('[data-tab-item]')
items[focusedIndex]?.scrollIntoView({ block: 'nearest' })
const elements = listRef.current.querySelectorAll('[data-context-item]')
elements[focusedIndex]?.scrollIntoView({ block: 'nearest' })
}
}, [focusedIndex])

Expand All @@ -141,7 +176,7 @@ const TabPickerMentionPopover: FC<TabPickerMentionPopoverProps> = ({
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
role="dialog"
aria-label="Select tabs to attach"
aria-label="Select context to attach"
>
<Command
className="[&_svg:not([class*='text-'])]:text-muted-foreground"
Expand All @@ -150,71 +185,179 @@ const TabPickerMentionPopover: FC<TabPickerMentionPopoverProps> = ({
<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
Attach Context
</span>
<span className="text-muted-foreground text-xs">
{filterText ? `Filtering: "${filterText}"` : 'Type to filter'}
</span>
</div>
{selectedTabs.length > 0 && (
{selectedCount > 0 && (
<span className="mt-1 block text-[var(--accent-orange)] text-xs">
{selectedTabs.length} tab{selectedTabs.length !== 1 ? 's' : ''}{' '}
selected
{selectedCount} item{selectedCount !== 1 ? 's' : ''} selected
</span>
)}
</div>
<CommandList
ref={listRef}
className="max-h-64 overflow-auto"
role="listbox"
aria-label="Available tabs"
aria-label="Available context"
aria-multiselectable="true"
>
<CommandEmpty className="py-6 text-center">
{isLoading ? (
<div className="text-muted-foreground text-sm">
Loading tabs…
Loading context...
</div>
) : (
<>
<div className="text-muted-foreground text-sm">
{allTabs.length === 0
? 'No active tabs'
: `No tabs matching "${filterText}"`}
{getContextEmptyTitle({
allTabsCount: allTabs.length,
hasWorkspace,
filterText,
contextEnabled,
})}
</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'}
{getContextEmptyDescription({
allTabsCount: allTabs.length,
hasWorkspace,
contextEnabled,
})}
</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>
{tabs.length > 0 ? (
<CommandGroup heading="Tabs">
{tabs.map((tab, index) => (
<CommandItem
key={`tab:${tab.id}`}
data-context-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>
) : null}
{visibleFiles.length > 0 ? (
<CommandGroup heading="Files">
{visibleFiles.map((attachment, index) => {
const itemIndex = tabs.length + index
return (
<CommandItem
key={attachment.id}
data-context-item
value={attachment.id}
onSelect={() => onToggleContext?.(attachment)}
onMouseEnter={() => setFocusedIndex(itemIndex)}
className="p-0 data-[selected=true]:bg-transparent"
>
<ContextListItem
attachment={attachment}
isSelected={selectedContextIds.has(attachment.id)}
className={
itemIndex === focusedIndex ? 'bg-accent' : undefined
}
/>
</CommandItem>
)
})}
</CommandGroup>
) : null}
{visibleMemories.length > 0 ? (
<CommandGroup heading="Memories">
{visibleMemories.map((attachment, index) => {
const itemIndex = tabs.length + visibleFiles.length + index
return (
<CommandItem
key={attachment.id}
data-context-item
value={attachment.id}
onSelect={() => onToggleContext?.(attachment)}
onMouseEnter={() => setFocusedIndex(itemIndex)}
className="p-0 data-[selected=true]:bg-transparent"
>
<ContextListItem
attachment={attachment}
isSelected={selectedContextIds.has(attachment.id)}
className={
itemIndex === focusedIndex ? 'bg-accent' : undefined
}
/>
</CommandItem>
)
})}
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

function toggleContextPickerItem(
item: ContextPickerItem | undefined,
handlers: {
onToggleTab: (tab: chrome.tabs.Tab) => void
onToggleContext?: (attachment: ContextAttachment) => void
},
) {
if (!item) return
if (item.type === 'tab') {
handlers.onToggleTab(item.tab)
return
}
handlers.onToggleContext?.(item.attachment)
}

function getContextEmptyTitle({
allTabsCount,
hasWorkspace,
filterText,
contextEnabled,
}: {
allTabsCount: number
hasWorkspace: boolean
filterText: string
contextEnabled: boolean
}): string {
if (filterText) return `No context matching "${filterText}"`
if (!contextEnabled) return allTabsCount === 0 ? 'No active tabs' : 'No tabs'
if (!hasWorkspace && allTabsCount === 0) return 'No context available'
return 'No context found'
}

function getContextEmptyDescription({
allTabsCount,
hasWorkspace,
contextEnabled,
}: {
allTabsCount: number
hasWorkspace: boolean
contextEnabled: boolean
}): string {
if (!contextEnabled) {
return allTabsCount === 0
? 'Open some web pages to attach them'
: 'Try a different search term'
}
if (!hasWorkspace) return 'Select a workspace to attach files'
return 'Try a different search term'
}

const TabPickerSelectorPopover: FC<TabPickerSelectorPopoverProps> = ({
children,
selectedTabs,
Expand Down
Loading
Loading