Improve chat @ mentions

This commit is contained in:
tusharmagar 2026-01-19 20:19:10 +05:30
parent 899f22ff61
commit 5c433805f6
7 changed files with 425 additions and 52 deletions

View file

@ -320,6 +320,8 @@ function ChatInputInner({
// Wrapper component with PromptInputProvider
interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
isProcessing: boolean
contextUsage: LanguageModelUsage
@ -329,6 +331,8 @@ interface ChatInputWithMentionsProps {
function ChatInputWithMentions({
knowledgeFiles,
recentFiles,
visibleFiles,
onSubmit,
isProcessing,
contextUsage,
@ -336,7 +340,7 @@ function ChatInputWithMentions({
usedTokens,
}: ChatInputWithMentionsProps) {
return (
<PromptInputProvider knowledgeFiles={knowledgeFiles}>
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
<ChatInputInner
onSubmit={onSubmit}
isProcessing={isProcessing}
@ -784,6 +788,30 @@ function App() {
}, [])
), [knowledgeFiles])
// Compute visible files (files whose parent directories are expanded)
const visibleKnowledgeFiles = React.useMemo(() => {
const visible: string[] = []
const isPathVisible = (path: string) => {
const parts = path.split('/')
// Root level files in knowledge are always visible
if (parts.length <= 2) return true
// Check if all parent directories are expanded
for (let i = 1; i < parts.length - 1; i++) {
const parentPath = parts.slice(0, i + 1).join('/')
if (!expandedPaths.has(parentPath)) return false
}
return true
}
for (const file of knowledgeFiles) {
const fullPath = toKnowledgePath(file)
if (fullPath && isPathVisible(fullPath)) {
visible.push(file)
}
}
return visible
}, [knowledgeFiles, expandedPaths])
// Get workspace root for full paths
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
useEffect(() => {
@ -1236,6 +1264,8 @@ function App() {
<div className="mx-auto w-full max-w-4xl px-4">
<ChatInputWithMentions
knowledgeFiles={knowledgeFiles}
recentFiles={recentWikiFiles}
visibleFiles={visibleKnowledgeFiles}
onSubmit={handlePromptSubmit}
isProcessing={isProcessing}
contextUsage={contextUsage}
@ -1265,6 +1295,8 @@ function App() {
maxTokens={maxTokens}
usedTokens={usedTokens}
knowledgeFiles={knowledgeFiles}
recentFiles={recentWikiFiles}
visibleFiles={visibleKnowledgeFiles}
selectedPath={selectedPath}
/>
)}

View file

@ -49,7 +49,8 @@ import {
import { nanoid } from "nanoid";
import { useMentionDetection } from "@/hooks/use-mention-detection";
import { MentionPopover } from "@/components/mention-popover";
import { toKnowledgePath } from "@/lib/wiki-links";
import { toKnowledgePath, wikiLabel } from "@/lib/wiki-links";
import { getMentionHighlightSegments } from "@/lib/mention-highlights";
import {
type ChangeEvent,
type ChangeEventHandler,
@ -165,6 +166,8 @@ const useOptionalProviderMentions = () => useContext(ProviderMentionsContext);
export type KnowledgeFilesContext = {
files: string[];
recentFiles: string[];
visibleFiles: string[];
};
const ProviderKnowledgeFilesContext = createContext<KnowledgeFilesContext | null>(null);
@ -176,6 +179,8 @@ export const useProviderKnowledgeFiles = () => {
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
knowledgeFiles?: string[];
recentFiles?: string[];
visibleFiles?: string[];
}>;
/**
@ -185,6 +190,8 @@ export type PromptInputProviderProps = PropsWithChildren<{
export function PromptInputProvider({
initialInput: initialTextInput = "",
knowledgeFiles = [],
recentFiles = [],
visibleFiles = [],
children,
}: PromptInputProviderProps) {
// ----- textInput state
@ -323,8 +330,8 @@ export function PromptInputProvider({
);
const knowledgeFilesContext = useMemo<KnowledgeFilesContext>(
() => ({ files: knowledgeFiles }),
[knowledgeFiles]
() => ({ files: knowledgeFiles, recentFiles, visibleFiles }),
[knowledgeFiles, recentFiles, visibleFiles]
);
return (
@ -913,9 +920,22 @@ export const PromptInputTextarea = ({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const currentValue = controller?.textInput.value ?? "";
const knowledgeFiles = knowledgeFilesCtx?.files ?? [];
const recentFiles = knowledgeFilesCtx?.recentFiles ?? [];
const visibleFiles = knowledgeFilesCtx?.visibleFiles ?? [];
// Build mention labels for highlighting (handles multi-word names like "AI Agents")
const mentionLabels = useMemo(() => {
if (knowledgeFiles.length === 0) return [];
const labels = knowledgeFiles
.map((path) => wikiLabel(path))
.map((label) => label.trim())
.filter(Boolean);
return Array.from(new Set(labels));
}, [knowledgeFiles]);
const { activeMention, cursorCoords } = useMentionDetection(
textareaRef,
@ -923,6 +943,25 @@ export const PromptInputTextarea = ({
knowledgeFiles.length > 0
);
// Use proper regex-based highlight segmentation that handles multi-word names
const mentionHighlights = useMemo(
() => getMentionHighlightSegments(currentValue, activeMention, mentionLabels),
[currentValue, activeMention, mentionLabels]
);
// Sync highlight overlay scroll with textarea
const syncHighlightScroll = useCallback(() => {
const textarea = textareaRef.current;
const highlight = highlightRef.current;
if (!textarea || !highlight) return;
highlight.scrollTop = textarea.scrollTop;
highlight.scrollLeft = textarea.scrollLeft;
}, []);
useEffect(() => {
syncHighlightScroll();
}, [currentValue, mentionHighlights.hasHighlights, syncHighlightScroll]);
const handleMentionSelect = useCallback(
(path: string, displayName: string) => {
if (!controller || !activeMention) return;
@ -1044,14 +1083,38 @@ export const PromptInputTextarea = ({
};
return (
<div ref={containerRef} className="relative contents">
<div ref={containerRef} className="relative flex-1 min-w-0">
{mentionHighlights.hasHighlights && (
<div
ref={highlightRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words px-3 py-3 text-sm text-transparent"
>
{mentionHighlights.segments.map((segment, index) =>
segment.highlighted ? (
<span
key={`mention-${index}`}
className="rounded bg-primary/20 text-transparent ring-1 ring-primary/15 px-1 py-0.5 [box-decoration-break:clone]"
>
{segment.text}
</span>
) : (
<span key={`text-${index}`}>{segment.text}</span>
)
)}
</div>
)}
<InputGroupTextarea
ref={textareaRef}
className={cn("field-sizing-content max-h-48 min-h-16", className)}
className={cn(
"field-sizing-content max-h-48 min-h-16 relative z-10",
className
)}
name="message"
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onScroll={syncHighlightScroll}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
@ -1060,6 +1123,8 @@ export const PromptInputTextarea = ({
{knowledgeFiles.length > 0 && (
<MentionPopover
files={knowledgeFiles}
recentFiles={recentFiles}
visibleFiles={visibleFiles}
query={activeMention?.query ?? ""}
position={cursorCoords}
containerRef={containerRef}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUp, PanelRightClose, Plus } from 'lucide-react'
import type { LanguageModelUsage, ToolUIPart } from 'ai'
import { Button } from '@/components/ui/button'
@ -26,6 +26,7 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme
import { useMentionDetection } from '@/hooks/use-mention-detection'
import { MentionPopover } from '@/components/mention-popover'
import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { getMentionHighlightSegments } from '@/lib/mention-highlights'
interface ChatMessage {
id: string
@ -115,6 +116,8 @@ interface ChatSidebarProps {
maxTokens: number
usedTokens: number
knowledgeFiles?: string[]
recentFiles?: string[]
visibleFiles?: string[]
selectedPath?: string | null
}
@ -130,6 +133,8 @@ export function ChatSidebar({
onMessageChange,
onSubmit,
knowledgeFiles = [],
recentFiles = [],
visibleFiles = [],
selectedPath,
}: ChatSidebarProps) {
const [width, setWidth] = useState(defaultWidth)
@ -138,7 +143,20 @@ export function ChatSidebar({
const startWidthRef = useRef(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const highlightRef = useRef<HTMLDivElement>(null)
const [mentions, setMentions] = useState<FileMention[]>([])
const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null)
const lastSelectedPathRef = useRef<string | null>(null)
// Build mention labels for highlighting (handles multi-word names like "AI Agents")
const mentionLabels = useMemo(() => {
if (knowledgeFiles.length === 0) return []
const labels = knowledgeFiles
.map((path) => wikiLabel(path))
.map((label) => label.trim())
.filter(Boolean)
return Array.from(new Set(labels))
}, [knowledgeFiles])
const { activeMention, cursorCoords } = useMentionDetection(
textareaRef,
@ -146,6 +164,25 @@ export function ChatSidebar({
knowledgeFiles.length > 0
)
// Use proper regex-based highlight segmentation that handles multi-word names
const mentionHighlights = useMemo(
() => getMentionHighlightSegments(message, activeMention, mentionLabels),
[message, activeMention, mentionLabels]
)
// Sync highlight overlay scroll with textarea
const syncHighlightScroll = useCallback(() => {
const textarea = textareaRef.current
const highlight = highlightRef.current
if (!textarea || !highlight) return
highlight.scrollTop = textarea.scrollTop
highlight.scrollLeft = textarea.scrollLeft
}, [])
useEffect(() => {
syncHighlightScroll()
}, [message, mentionHighlights.hasHighlights, syncHighlightScroll])
const handleMentionSelect = useCallback(
(path: string, displayName: string) => {
if (!activeMention) return
@ -197,24 +234,54 @@ export function ChatSidebar({
document.addEventListener('mouseup', handleMouseUp)
}, [width])
// Auto-focus textarea when sidebar opens and auto-populate with current file if applicable
// Auto-focus textarea when sidebar opens
useEffect(() => {
textareaRef.current?.focus()
}, [])
// Auto-populate with @currentfile if opening from a knowledge file
if (selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md')) {
// Only auto-populate if there's no existing message
if (!message.trim()) {
const displayName = wikiLabel(selectedPath)
onMessageChange(`@${displayName} `)
setMentions([{
// Auto-populate with @currentfile when switching knowledge files
useEffect(() => {
if (selectedPath === lastSelectedPathRef.current) return
lastSelectedPathRef.current = selectedPath ?? null
if (!selectedPath || !selectedPath.startsWith('knowledge/') || !selectedPath.endsWith('.md')) {
return
}
const displayName = wikiLabel(selectedPath)
const previousAuto = autoMentionRef.current
const trimmed = message.trim()
const previousToken = previousAuto ? `@${previousAuto.displayName}` : null
const shouldReplace = !trimmed || (previousToken && trimmed === previousToken)
if (!shouldReplace) {
return
}
const nextText = `@${displayName} `
if (message !== nextText) {
onMessageChange(nextText)
}
setMentions((prev) => {
const withoutPrevious = previousAuto
? prev.filter((mention) => mention.path !== previousAuto.path)
: prev
if (withoutPrevious.some((mention) => mention.path === selectedPath)) {
return withoutPrevious
}
return [
...withoutPrevious,
{
id: `mention-auto-${Date.now()}`,
path: selectedPath,
displayName,
}])
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
},
]
})
autoMentionRef.current = { path: selectedPath, displayName }
}, [selectedPath, message, onMessageChange])
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
const canSubmit = Boolean(message.trim()) && !isProcessing
@ -377,17 +444,40 @@ export function ChatSidebar({
{/* Input area - responsive to sidebar width, matches floating bar position exactly */}
<div className="absolute bottom-6 left-14 right-6 z-10" ref={containerRef}>
<div className="flex items-center gap-2 bg-background border border-border rounded-2xl shadow-xl px-4 py-2.5">
<textarea
ref={textareaRef}
value={message}
onChange={(e) => onMessageChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything..."
disabled={isProcessing}
rows={1}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 resize-none max-h-32 min-h-[1.5rem]"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<div className="relative flex-1 min-w-0">
{mentionHighlights.hasHighlights && (
<div
ref={highlightRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-sm text-transparent"
>
{mentionHighlights.segments.map((segment, index) =>
segment.highlighted ? (
<span
key={`mention-${index}`}
className="rounded bg-primary/20 text-transparent [box-decoration-break:clone] shadow-[inset_0_0_0_1px_hsl(var(--primary)/0.15),-3px_0_0_hsl(var(--primary)/0.2),3px_0_0_hsl(var(--primary)/0.2),0_-2px_0_hsl(var(--primary)/0.2),0_2px_0_hsl(var(--primary)/0.2)]"
>
{segment.text}
</span>
) : (
<span key={`text-${index}`}>{segment.text}</span>
)
)}
</div>
)}
<textarea
ref={textareaRef}
value={message}
onChange={(e) => onMessageChange(e.target.value)}
onKeyDown={handleKeyDown}
onScroll={syncHighlightScroll}
placeholder="Ask anything..."
disabled={isProcessing}
rows={1}
className="relative z-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 resize-none max-h-32 min-h-[1.5rem]"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
</div>
<Button
size="icon"
onClick={handleSubmit}
@ -405,6 +495,8 @@ export function ChatSidebar({
{knowledgeFiles.length > 0 && (
<MentionPopover
files={knowledgeFiles}
recentFiles={recentFiles}
visibleFiles={visibleFiles}
query={activeMention?.query ?? ''}
position={cursorCoords}
containerRef={containerRef}

View file

@ -7,6 +7,8 @@ import type { CaretCoordinates } from '@/lib/textarea-caret'
interface MentionPopoverProps {
files: string[]
recentFiles?: string[]
visibleFiles?: string[]
query: string
position: CaretCoordinates | null
containerRef: React.RefObject<HTMLElement | null>
@ -19,33 +21,64 @@ const MAX_VISIBLE_FILES = 8
export function MentionPopover({
files,
recentFiles = [],
visibleFiles = [],
query,
position,
containerRef,
containerRef: _containerRef,
onSelect,
onClose,
open,
}: MentionPopoverProps) {
void _containerRef // Reserved for future positioning logic
const [selectedIndex, setSelectedIndex] = useState(0)
// Filter files based on query
const filteredFiles = useMemo(() => {
if (!query) return files.slice(0, MAX_VISIBLE_FILES)
// Order files: visible > recent > rest, then filter by query
const orderedAndFilteredFiles = useMemo(() => {
const lowerQuery = query.toLowerCase()
return files
// Create sets for quick lookup
const visibleSet = new Set(visibleFiles)
const recentSet = new Set(recentFiles)
const allFiles = new Set(files)
// Categorize files
const visible: string[] = []
const recent: string[] = []
const rest: string[] = []
for (const file of files) {
if (visibleSet.has(file)) {
visible.push(file)
} else if (recentSet.has(file)) {
recent.push(file)
} else {
rest.push(file)
}
}
// Maintain recent order for recent files
const orderedRecent = recentFiles.filter(f => allFiles.has(f) && !visibleSet.has(f))
// Combine in order: visible > recent > rest
const ordered = [...visible, ...orderedRecent, ...rest]
// Filter by query if present
if (!query) return ordered.slice(0, MAX_VISIBLE_FILES)
return ordered
.filter((path) => {
const label = wikiLabel(path).toLowerCase()
const normalized = stripKnowledgePrefix(path).toLowerCase()
return label.includes(lowerQuery) || normalized.includes(lowerQuery)
})
.slice(0, MAX_VISIBLE_FILES)
}, [files, query])
}, [files, recentFiles, visibleFiles, query])
// Reset selection when filtered list changes
useEffect(() => {
setSelectedIndex(0)
}, [filteredFiles.length, query])
}, [orderedAndFilteredFiles.length, query])
// Handle keyboard navigation
const handleKeyDown = useCallback(
@ -56,18 +89,18 @@ export function MentionPopover({
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => (prev + 1) % filteredFiles.length)
setSelectedIndex((prev) => (prev + 1) % orderedAndFilteredFiles.length)
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => (prev - 1 + filteredFiles.length) % filteredFiles.length)
setSelectedIndex((prev) => (prev - 1 + orderedAndFilteredFiles.length) % orderedAndFilteredFiles.length)
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
if (filteredFiles[selectedIndex]) {
const path = filteredFiles[selectedIndex]
if (orderedAndFilteredFiles[selectedIndex]) {
const path = orderedAndFilteredFiles[selectedIndex]
onSelect(path, wikiLabel(path))
}
break
@ -79,14 +112,14 @@ export function MentionPopover({
case 'Tab':
e.preventDefault()
e.stopPropagation()
if (filteredFiles[selectedIndex]) {
const path = filteredFiles[selectedIndex]
if (orderedAndFilteredFiles[selectedIndex]) {
const path = orderedAndFilteredFiles[selectedIndex]
onSelect(path, wikiLabel(path))
}
break
}
},
[open, filteredFiles, selectedIndex, onSelect, onClose]
[open, orderedAndFilteredFiles, selectedIndex, onSelect, onClose]
)
// Attach keyboard listener
@ -100,7 +133,7 @@ export function MentionPopover({
}
}, [open, handleKeyDown])
if (!open || !position || filteredFiles.length === 0) {
if (!open || !position || orderedAndFilteredFiles.length === 0) {
return null
}
@ -128,10 +161,10 @@ export function MentionPopover({
>
<Command shouldFilter={false}>
<CommandList>
{filteredFiles.length === 0 ? (
{orderedAndFilteredFiles.length === 0 ? (
<CommandEmpty>No files found</CommandEmpty>
) : (
filteredFiles.map((path, index) => (
orderedAndFilteredFiles.map((path, index) => (
<CommandItem
key={path}
value={path}

View file

@ -0,0 +1,38 @@
import { stripKnowledgePrefix } from '@/lib/wiki-links'
type BuildMentionFileListOptions = {
files: string[]
activePath?: string | null
recentFiles?: string[]
}
export const buildMentionFileList = ({
files,
activePath,
recentFiles,
}: BuildMentionFileListOptions) => {
const ordered: string[] = []
const seen = new Set<string>()
const normalizedFiles = files.map(stripKnowledgePrefix)
const fileSet = new Set(normalizedFiles)
const addFile = (path?: string | null) => {
if (!path) return
const normalized = stripKnowledgePrefix(path)
if (!fileSet.has(normalized) || seen.has(normalized)) {
return
}
seen.add(normalized)
ordered.push(normalized)
}
addFile(activePath)
for (const recent of recentFiles ?? []) {
addFile(recent)
}
for (const file of normalizedFiles) {
addFile(file)
}
return ordered
}

View file

@ -0,0 +1,115 @@
import type { ActiveMention } from '@/hooks/use-mention-detection'
type MentionRange = {
start: number
end: number
}
export type MentionHighlightSegment = {
text: string
highlighted: boolean
}
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const getMentionHighlightSegments = (
value: string,
activeMention?: ActiveMention | null,
mentionLabels?: string[]
) => {
if (!value) {
return { segments: [], hasHighlights: false }
}
const ranges: MentionRange[] = []
const addRange = (start: number, end: number) => {
if (end <= start) return
ranges.push({ start, end })
}
// First, match multi-word mention labels (like "AI Agents")
if (mentionLabels && mentionLabels.length > 0) {
const uniqueLabels = Array.from(
new Set(mentionLabels.map((label) => label.trim()).filter(Boolean))
)
for (const label of uniqueLabels) {
const escaped = escapeRegExp(label)
const labelRegex = new RegExp(
`(^|\\s)(@${escaped})(?=$|\\s|[\\)\\]\\}\\.,!?;:])`,
'gi'
)
let labelMatch: RegExpExecArray | null
while ((labelMatch = labelRegex.exec(value)) !== null) {
const prefix = labelMatch[1] ?? ''
const mention = labelMatch[2] ?? ''
if (!mention) continue
const start = labelMatch.index + prefix.length
const end = start + mention.length
addRange(start, end)
}
}
}
// Then match single-word mentions (fallback for non-file mentions)
const mentionRegex = /(^|[\s])(@[^\s@]+)/g
let match: RegExpExecArray | null
while ((match = mentionRegex.exec(value)) !== null) {
const prefix = match[1] ?? ''
const mention = match[2] ?? ''
if (!mention) continue
const start = match.index + prefix.length
const end = start + mention.length
addRange(start, end)
}
// Highlight active mention trigger (just the @) when typing
if (activeMention && activeMention.query.length === 0) {
const start = activeMention.triggerIndex
if (start >= 0 && start < value.length && value[start] === '@') {
addRange(start, Math.min(value.length, start + 1))
}
}
if (ranges.length === 0) {
return { segments: [{ text: value, highlighted: false }], hasHighlights: false }
}
// Sort and merge overlapping ranges
ranges.sort((a, b) => a.start - b.start)
const merged: MentionRange[] = []
for (const range of ranges) {
const last = merged.at(-1)
if (!last || range.start > last.end) {
merged.push({ ...range })
continue
}
last.end = Math.max(last.end, range.end)
}
// Build segments from merged ranges
const segments: MentionHighlightSegment[] = []
let cursor = 0
for (const range of merged) {
if (range.start > cursor) {
segments.push({
text: value.slice(cursor, range.start),
highlighted: false,
})
}
if (range.end > range.start) {
segments.push({
text: value.slice(range.start, range.end),
highlighted: true,
})
}
cursor = range.end
}
if (cursor < value.length) {
segments.push({ text: value.slice(cursor), highlighted: false })
}
return { segments, hasHighlights: true }
}

View file

@ -56,9 +56,6 @@ export function getCaretCoordinates(
const style = div.style
const computed = window.getComputedStyle(textarea)
// Default return value
const defaultCoords: CaretCoordinates = { top: 0, left: 0, height: 0 }
// Position offscreen
style.whiteSpace = 'pre-wrap'
style.wordWrap = 'break-word'
@ -68,7 +65,8 @@ export function getCaretCoordinates(
// Copy styles from textarea to mirror div
for (const prop of PROPERTIES_TO_COPY) {
style[prop as keyof CSSStyleDeclaration] = computed[prop as keyof CSSStyleDeclaration] as string
const value = computed.getPropertyValue(prop.replace(/([A-Z])/g, '-$1').toLowerCase())
style.setProperty(prop.replace(/([A-Z])/g, '-$1').toLowerCase(), value)
}
// Firefox-specific handling