From 7efcaa11c469dbb175db6fe087c41340ecd7735c Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 4 Mar 2026 12:15:06 +0100 Subject: [PATCH] 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 --- src/components/AiPanel.tsx | 135 +++++++------- src/components/Editor.tsx | 1 + src/components/EditorRightPanel.tsx | 4 +- src/components/WikilinkChatInput.tsx | 264 +++++++++++++++++++++++++++ src/index.css | 23 +++ 5 files changed, 353 insertions(+), 74 deletions(-) create mode 100644 src/components/WikilinkChatInput.tsx diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index 54a6f98f..1a14c636 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -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 + 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 -}) { - const sendDisabled = isActive || !input.trim() - return ( -
-
- 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" - /> - -
-
- ) -} - -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([]) const inputRef = useRef(null) const panelRef = useRef(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 ( ) } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index b6e3e419..78932c42 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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} diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index e57206f2..79507196 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -12,6 +12,7 @@ interface EditorRightPanelProps { allContent: Record 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} /> ) diff --git a/src/components/WikilinkChatInput.tsx b/src/components/WikilinkChatInput.tsx new file mode 100644 index 00000000..3c03a839 --- /dev/null +++ b/src/components/WikilinkChatInput.tsx @@ -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 +} + +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, +): 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([]) + const [showMenu, setShowMenu] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(0) + const [debouncedQuery, setDebouncedQuery] = useState('') + const internalRef = useRef(null) + const inputRefToUse = externalRef ?? internalRef + const menuRef = useRef(null) + const debounceTimer = useRef>() + + 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) { + 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 ( +
+ {pills.length > 0 && ( +
+ {pills.map(pill => ( + + {pill.title} + + + ))} +
+ )} + + {showMenu && suggestions.length > 0 && ( +
+ {suggestions.map((s, i) => ( +
e.preventDefault()} + onClick={() => selectSuggestion(s)} + onMouseEnter={() => setSelectedIndex(i)} + > + {s.title} + {s.isA && s.isA !== 'Note' && ( + + {s.isA} + + )} +
+ ))} +
+ )} +
+ ) +} diff --git a/src/index.css b/src/index.css index 56acd820..69fd0be1 100644 --- a/src/index.css +++ b/src/index.css @@ -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); } +}