feat: add wikilink autocomplete, animated border, structured context wiring
- WikilinkChatInput: [[ trigger, debounced dropdown, colored type pills, keyboard nav - AiPanel: uses buildContextSnapshot, WikilinkChatInput, animated blue border - EditorRightPanel/Editor: thread openTabs to AI panel - CSS: ai-border-pulse + typing-bounce animations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextualPrompt } from '../utils/ai-context'
|
||||
import { collectLinkedEntries, buildContextSnapshot, type NoteReference } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
@@ -14,6 +15,7 @@ interface AiPanelProps {
|
||||
activeEntry?: VaultEntry | null
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
openTabs?: VaultEntry[]
|
||||
}
|
||||
|
||||
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
|
||||
@@ -103,54 +105,9 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
)
|
||||
}
|
||||
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext, inputRef }: {
|
||||
input: string; onInputChange: (v: string) => void
|
||||
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
|
||||
isActive: boolean; hasContext: boolean; inputRef: React.RefObject<HTMLInputElement | null>
|
||||
}) {
|
||||
const sendDisabled = isActive || !input.trim()
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="flex-1 border border-border bg-transparent text-foreground"
|
||||
style={{
|
||||
fontSize: 13, borderRadius: 8, padding: '8px 10px',
|
||||
outline: 'none', fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
disabled={isActive}
|
||||
data-testid="agent-input"
|
||||
/>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: sendDisabled ? 'var(--muted)' : 'var(--primary)',
|
||||
color: sendDisabled ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: sendDisabled ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={onSend}
|
||||
disabled={sendDisabled}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
|
||||
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent, openTabs }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
|
||||
@@ -160,9 +117,15 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !allContent) return undefined
|
||||
return buildContextualPrompt(activeEntry, linkedEntries, allContent)
|
||||
}, [activeEntry, linkedEntries, allContent])
|
||||
if (!activeEntry || !allContent || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
allContent,
|
||||
openTabs,
|
||||
entries,
|
||||
references: pendingRefs.length > 0 ? pendingRefs : undefined,
|
||||
})
|
||||
}, [activeEntry, allContent, openTabs, entries, pendingRefs])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt)
|
||||
const hasContext = !!activeEntry
|
||||
@@ -193,26 +156,28 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isActive) return
|
||||
agent.sendMessage(input)
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
agent.sendMessage(text)
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
}, [isActive, agent])
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
|
||||
style={{ outline: 'none' }}
|
||||
className="flex flex-1 flex-col overflow-hidden bg-background text-foreground"
|
||||
style={{
|
||||
outline: 'none',
|
||||
borderLeft: isActive
|
||||
? '2px solid var(--accent-blue, #3b82f6)'
|
||||
: '1px solid var(--border)',
|
||||
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
|
||||
transition: 'border-color 0.3s ease',
|
||||
}}
|
||||
data-testid="ai-panel"
|
||||
data-ai-active={isActive || undefined}
|
||||
>
|
||||
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
|
||||
{activeEntry && (
|
||||
@@ -224,15 +189,39 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
|
||||
onOpenNote={onOpenNote}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<InputBar
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSend={handleSend}
|
||||
onKeyDown={handleKeyDown}
|
||||
isActive={isActive}
|
||||
hasContext={hasContext}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries ?? []}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => handleSend(input, pendingRefs)}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ export const Editor = memo(function Editor({
|
||||
allContent={allContent}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath ?? ''}
|
||||
openTabs={tabs.map(t => t.entry)}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface EditorRightPanelProps {
|
||||
allContent: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
vaultPath: string
|
||||
openTabs?: VaultEntry[]
|
||||
onToggleInspector: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
@@ -24,7 +25,7 @@ interface EditorRightPanelProps {
|
||||
|
||||
export function EditorRightPanel({
|
||||
showAIChat, inspectorCollapsed, inspectorWidth,
|
||||
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath,
|
||||
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath, openTabs,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
|
||||
}: EditorRightPanelProps) {
|
||||
@@ -41,6 +42,7 @@ export function EditorRightPanel({
|
||||
activeEntry={inspectorEntry}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
openTabs={openTabs}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
264
src/components/WikilinkChatInput.tsx
Normal file
264
src/components/WikilinkChatInput.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
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))
|
||||
),
|
||||
)
|
||||
return matches.slice(0, MAX_SUGGESTIONS).map(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>>()
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -289,3 +289,26 @@
|
||||
.ai-markdown .hljs-attribute { color: #005cc5; }
|
||||
.ai-markdown .hljs-deletion { color: #b31d28; background: #ffeef0; }
|
||||
.ai-markdown .hljs-meta { color: #6a737d; }
|
||||
|
||||
/* --- AI panel working border animation --- */
|
||||
|
||||
@keyframes ai-border-pulse {
|
||||
0%, 100% { border-color: var(--accent-blue, #3b82f6); opacity: 1; }
|
||||
50% { border-color: var(--accent-blue, #93c5fd); opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* --- Typing indicator dots --- */
|
||||
|
||||
.typing-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted-foreground);
|
||||
animation: typing-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
|
||||
30% { opacity: 1; transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user