Files
tolaria/src/components/WikilinkChatInput.tsx

269 lines
8.4 KiB
TypeScript
Raw Normal View History

/**
* Chat input with [[wikilink]] autocomplete.
*
* When the user types `[[`, a dropdown appears with vault note suggestions.
* Selecting a note inserts a colored pill in the input and records a reference.
*/
import { useState, useRef, useMemo, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { bestSearchRank } from '../utils/fuzzyMatch'
const MAX_SUGGESTIONS = 20
const MIN_QUERY_LENGTH = 1
const DEBOUNCE_MS = 100
interface WikilinkChatInputProps {
entries: VaultEntry[]
value: string
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
disabled?: boolean
placeholder?: string
inputRef?: React.RefObject<HTMLInputElement | null>
}
interface Pill {
title: string
path: string
type: string | null
color?: string
lightColor?: string
}
interface SuggestionEntry {
title: string
path: string
isA: string | null
color?: string
lightColor?: string
}
function matchEntries(
entries: VaultEntry[],
query: string,
typeEntryMap: Record<string, VaultEntry>,
): SuggestionEntry[] {
if (query.length < MIN_QUERY_LENGTH) return []
const lower = query.toLowerCase()
const matches = entries
.filter(e =>
!e.trashed && !e.archived && (
e.title.toLowerCase().includes(lower) ||
e.aliases.some(a => a.toLowerCase().includes(lower))
),
)
.map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) }))
.sort((a, b) => a.rank - b.rank)
return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => {
const te = typeEntryMap[e.isA ?? '']
return {
title: e.title,
path: e.path,
isA: e.isA,
color: e.isA ? getTypeColor(e.isA, te?.color) : undefined,
lightColor: e.isA ? getTypeLightColor(e.isA, te?.color) : undefined,
}
})
}
export function WikilinkChatInput({
entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef,
}: WikilinkChatInputProps) {
const [pills, setPills] = useState<Pill[]>([])
const [showMenu, setShowMenu] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [debouncedQuery, setDebouncedQuery] = useState('')
const internalRef = useRef<HTMLInputElement>(null)
const inputRefToUse = externalRef ?? internalRef
const menuRef = useRef<HTMLDivElement>(null)
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const suggestions = useMemo(
() => showMenu ? matchEntries(entries, debouncedQuery, typeEntryMap) : [],
[entries, debouncedQuery, typeEntryMap, showMenu],
)
// Clamp selection to valid range
const clampedIndex = suggestions.length > 0
? Math.min(selectedIndex, suggestions.length - 1)
: 0
useEffect(() => {
if (!menuRef.current || clampedIndex < 0) return
const el = menuRef.current.children[clampedIndex] as HTMLElement | undefined
el?.scrollIntoView?.({ block: 'nearest' })
}, [clampedIndex])
function selectSuggestion(suggestion: SuggestionEntry) {
const cursor = inputRefToUse.current?.selectionStart ?? value.length
const textBefore = value.slice(0, cursor)
const bracketIdx = textBefore.lastIndexOf('[[')
if (bracketIdx < 0) return
const textAfter = value.slice(cursor)
const newValue = textBefore.slice(0, bracketIdx) + textAfter
onChange(newValue)
setPills(prev => {
if (prev.some(p => p.path === suggestion.path)) return prev
return [...prev, {
title: suggestion.title,
path: suggestion.path,
type: suggestion.isA,
color: suggestion.color,
lightColor: suggestion.lightColor,
}]
})
setShowMenu(false)
setTimeout(() => inputRefToUse.current?.focus(), 0)
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const newValue = e.target.value
onChange(newValue)
const cursor = e.target.selectionStart ?? newValue.length
const textBefore = newValue.slice(0, cursor)
const bracketIdx = textBefore.lastIndexOf('[[')
if (bracketIdx >= 0 && !textBefore.slice(bracketIdx).includes(']]')) {
const query = textBefore.slice(bracketIdx + 2)
setShowMenu(true)
setSelectedIndex(0)
clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS)
} else {
setShowMenu(false)
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (showMenu && suggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => (i + 1) % suggestions.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1))
return
}
if (e.key === 'Enter') {
e.preventDefault()
selectSuggestion(suggestions[clampedIndex])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setShowMenu(false)
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!value.trim() && pills.length === 0) return
const references: NoteReference[] = pills.map(p => ({
title: p.title,
path: p.path,
type: p.type,
}))
onSend(value, references)
setPills([])
}
}
return (
<div style={{ position: 'relative' }}>
{pills.length > 0 && (
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
{pills.map(pill => (
<span
key={pill.path}
className="inline-flex items-center gap-1 text-xs"
style={{
background: pill.lightColor ?? 'var(--muted)',
color: pill.color ?? 'var(--foreground)',
borderRadius: 9999,
padding: '1px 8px 1px 6px',
fontWeight: 500,
}}
data-testid="reference-pill"
>
{pill.title}
<button
className="border-none bg-transparent p-0 cursor-pointer"
style={{ color: 'inherit', opacity: 0.6, fontSize: 10, lineHeight: 1 }}
onClick={() => setPills(prev => prev.filter(p => p.path !== pill.path))}
tabIndex={-1}
>
×
</button>
</span>
))}
</div>
)}
<input
ref={inputRefToUse}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
className="flex-1 border border-border bg-transparent text-foreground"
style={{
fontSize: 13, borderRadius: 8, padding: '8px 10px',
outline: 'none', fontFamily: 'inherit', width: '100%', boxSizing: 'border-box',
}}
placeholder={placeholder}
disabled={disabled}
data-testid="agent-input"
/>
{showMenu && suggestions.length > 0 && (
<div
ref={menuRef}
className="wikilink-menu"
style={{
position: 'absolute', bottom: '100%', left: 0, right: 0,
marginBottom: 4, maxHeight: 260, overflowY: 'auto',
}}
data-testid="wikilink-menu"
>
{suggestions.map((s, i) => (
<div
key={s.path}
className="flex items-center justify-between gap-2 cursor-pointer transition-colors"
style={{
padding: '6px 10px',
fontSize: 13,
background: i === clampedIndex ? 'var(--accent)' : undefined,
}}
onMouseDown={e => e.preventDefault()}
onClick={() => selectSuggestion(s)}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="truncate">{s.title}</span>
{s.isA && s.isA !== 'Note' && (
<span
className="shrink-0 text-xs"
style={{
color: s.color,
backgroundColor: s.lightColor,
borderRadius: 9999,
padding: '1px 8px',
}}
>
{s.isA}
</span>
)}
</div>
))}
</div>
)}
</div>
)
}