feat: add inline AI prompts to command palette
This commit is contained in:
19
src/App.tsx
19
src/App.tsx
@@ -64,6 +64,7 @@ import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
|
||||
import {
|
||||
INBOX_SELECTION,
|
||||
isExplicitOrganizationEnabled,
|
||||
@@ -97,10 +98,20 @@ function App() {
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
const { showAIChat, toggleAIChat } = dialogs
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
const openFeedback = useCallback(() => setShowFeedback(true), [])
|
||||
const closeFeedback = useCallback(() => setShowFeedback(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenAiChat = () => {
|
||||
if (!showAIChat) toggleAIChat()
|
||||
}
|
||||
|
||||
window.addEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
}, [showAIChat, toggleAIChat])
|
||||
|
||||
// onSwitch closure captures `notes` declared below — safe because it's only
|
||||
// called on user interaction, never during render (refs inside the hook
|
||||
// guarantee the latest closure is always used).
|
||||
@@ -729,7 +740,13 @@ function App() {
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
entries={vault.entries}
|
||||
claudeCodeReady={claudeCodeStatus === 'installed'}
|
||||
onClose={dialogs.closeCommandPalette}
|
||||
/>
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
||||
|
||||
@@ -2,16 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt } from '../utils/aiPromptBridge'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
|
||||
const mockSendMessage = vi.fn()
|
||||
const mockClearConversation = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
sendMessage: mockSendMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -48,6 +52,8 @@ describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
mockStatus = 'idle'
|
||||
mockSendMessage.mockReset()
|
||||
mockClearConversation.mockReset()
|
||||
})
|
||||
|
||||
it('renders panel with AI Chat header', () => {
|
||||
@@ -114,7 +120,7 @@ describe('AiPanel', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toBeTruthy()
|
||||
expect((input as HTMLInputElement).disabled).toBe(false)
|
||||
expect(input).toHaveAttribute('contenteditable', 'true')
|
||||
})
|
||||
|
||||
it('has send button disabled when input is empty', () => {
|
||||
@@ -128,14 +134,14 @@ describe('AiPanel', () => {
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask about this note...')
|
||||
})
|
||||
|
||||
it('shows generic placeholder when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask the AI agent...')
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask the AI agent...')
|
||||
})
|
||||
|
||||
it('auto-focuses input on mount', async () => {
|
||||
@@ -203,4 +209,20 @@ describe('AiPanel', () => {
|
||||
fireEvent.click(wikilinks[1])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
|
||||
})
|
||||
|
||||
it('auto-sends a queued prompt from the command palette bridge', async () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" entries={[makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' })]} />)
|
||||
|
||||
await act(async () => {
|
||||
queueAiPrompt('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
expect(screen.getByTestId('agent-send')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import { type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
|
||||
import { useAiPanelFocus } from './useAiPanelFocus'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
|
||||
@@ -111,75 +115,89 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
|
||||
)
|
||||
}
|
||||
|
||||
function AiPanelComposer({
|
||||
entries,
|
||||
hasContext,
|
||||
input,
|
||||
inputRef,
|
||||
isActive,
|
||||
onChange,
|
||||
onSend,
|
||||
}: {
|
||||
entries: VaultEntry[]
|
||||
hasContext: boolean
|
||||
input: string
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
isActive: boolean
|
||||
onChange: (value: string) => void
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
}) {
|
||||
return (
|
||||
<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={onChange}
|
||||
onSend={onSend}
|
||||
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={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: pendingRefs.length > 0 ? pendingRefs : undefined,
|
||||
})
|
||||
}, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
} = useAiPanelController({
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
})
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
} else {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
agent.sendMessage(text, references)
|
||||
setInput('')
|
||||
}, [isActive, agent])
|
||||
useAiPanelPromptQueue({ agent, input, isActive, setInput })
|
||||
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -208,39 +226,15 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<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>
|
||||
<AiPanelComposer
|
||||
entries={entries ?? []}
|
||||
hasContext={hasContext}
|
||||
input={input}
|
||||
inputRef={inputRef}
|
||||
isActive={isActive}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import type { CommandAction } from '../hooks/useCommandRegistry'
|
||||
|
||||
// jsdom doesn't implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
vi.mock('../utils/aiPromptBridge', () => ({
|
||||
queueAiPrompt: vi.fn(),
|
||||
requestOpenAiChat: vi.fn(),
|
||||
}))
|
||||
|
||||
const makeCommand = (overrides: Partial<CommandAction> = {}): CommandAction => ({
|
||||
id: 'test-cmd',
|
||||
label: 'Test Command',
|
||||
@@ -25,6 +32,59 @@ const commands: CommandAction[] = [
|
||||
makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }),
|
||||
]
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' }),
|
||||
]
|
||||
|
||||
function setSelection(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const targetNode = editor.firstChild ?? editor
|
||||
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
|
||||
? Math.min(offset, targetNode.textContent?.length ?? 0)
|
||||
: Math.min(offset, targetNode.childNodes.length)
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(targetNode, safeOffset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function updateAiInput(text: string) {
|
||||
const editor = screen.getByTestId('command-palette-ai-input')
|
||||
editor.textContent = text
|
||||
setSelection(editor, text.length)
|
||||
fireEvent.input(editor)
|
||||
return editor
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
@@ -166,6 +226,54 @@ describe('CommandPalette', () => {
|
||||
expect(screen.getByText('esc close')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches into AI mode when the query starts with a leading space', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
|
||||
expect(screen.getByTestId('command-palette-ai-input')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Ask Claude Code').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to command mode when the leading space is deleted', () => {
|
||||
render(
|
||||
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
updateAiInput('new')
|
||||
|
||||
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
|
||||
expect(screen.queryByTestId('command-palette-ai-input')).toBeNull()
|
||||
expect(input.value).toBe('new')
|
||||
expect(screen.getByText('New Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('queues a stripped AI prompt and closes on Enter in AI mode', () => {
|
||||
render(
|
||||
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
const editor = updateAiInput(' hello world')
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
expect(queueAiPrompt).toHaveBeenCalledWith('hello world', [])
|
||||
expect(requestOpenAiChat).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes without queueing when AI mode only contains the trigger space', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
fireEvent.keyDown(screen.getByTestId('command-palette-ai-input'), { key: 'Enter' })
|
||||
|
||||
expect(queueAiPrompt).not.toHaveBeenCalled()
|
||||
expect(requestOpenAiChat).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('relevance ranking', () => {
|
||||
const relevanceCommands: CommandAction[] = [
|
||||
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { fuzzyMatch } from '../utils/fuzzyMatch'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
|
||||
import { groupSortKey } from '../hooks/useCommandRegistry'
|
||||
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
commands: CommandAction[]
|
||||
entries?: VaultEntry[]
|
||||
claudeCodeReady?: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -15,17 +21,17 @@ interface ScoredCommand {
|
||||
score: number
|
||||
}
|
||||
|
||||
function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null {
|
||||
const labelResult = fuzzyMatch(query, cmd.label)
|
||||
if (labelResult.match) return { command: cmd, score: labelResult.score }
|
||||
function matchCommand(query: string, command: CommandAction): ScoredCommand | null {
|
||||
const labelResult = fuzzyMatch(query, command.label)
|
||||
if (labelResult.match) return { command, score: labelResult.score }
|
||||
|
||||
for (const kw of cmd.keywords ?? []) {
|
||||
const kwResult = fuzzyMatch(query, kw)
|
||||
if (kwResult.match) return { command: cmd, score: kwResult.score - 1 }
|
||||
for (const keyword of command.keywords ?? []) {
|
||||
const keywordResult = fuzzyMatch(query, keyword)
|
||||
if (keywordResult.match) return { command, score: keywordResult.score - 1 }
|
||||
}
|
||||
|
||||
const groupResult = fuzzyMatch(query, cmd.group)
|
||||
if (groupResult.match) return { command: cmd, score: groupResult.score - 2 }
|
||||
const groupResult = fuzzyMatch(query, command.group)
|
||||
if (groupResult.match) return { command, score: groupResult.score - 2 }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -34,83 +40,262 @@ function groupResults(
|
||||
commands: CommandAction[],
|
||||
byRelevance: boolean,
|
||||
): { group: CommandGroup; items: CommandAction[] }[] {
|
||||
const map = new Map<CommandGroup, CommandAction[]>()
|
||||
for (const cmd of commands) {
|
||||
const list = map.get(cmd.group)
|
||||
if (list) list.push(cmd)
|
||||
else map.set(cmd.group, [cmd])
|
||||
const groupedCommands = new Map<CommandGroup, CommandAction[]>()
|
||||
|
||||
for (const command of commands) {
|
||||
const existing = groupedCommands.get(command.group)
|
||||
if (existing) {
|
||||
existing.push(command)
|
||||
continue
|
||||
}
|
||||
groupedCommands.set(command.group, [command])
|
||||
}
|
||||
const entries = Array.from(map.entries())
|
||||
|
||||
const entries = Array.from(groupedCommands.entries())
|
||||
if (!byRelevance) {
|
||||
entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
|
||||
entries.sort((left, right) => groupSortKey(left[0]) - groupSortKey(right[0]))
|
||||
}
|
||||
|
||||
return entries.map(([group, items]) => ({ group, items }))
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
|
||||
setSelectedIndex(0)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function usePaletteResults(commands: CommandAction[], query: string) {
|
||||
const enabledCommands = useMemo(
|
||||
() => commands.filter(c => c.enabled),
|
||||
() => commands.filter((command) => command.enabled),
|
||||
[commands],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!query.trim()) return enabledCommands
|
||||
return enabledCommands
|
||||
.map(cmd => matchCommand(query, cmd))
|
||||
.filter((r): r is ScoredCommand => r !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(r => r.command)
|
||||
.map((command) => matchCommand(query, command))
|
||||
.filter((result): result is ScoredCommand => result !== null)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.map((result) => result.command)
|
||||
}, [enabledCommands, query])
|
||||
|
||||
const hasQuery = !!query.trim()
|
||||
const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery])
|
||||
const flatList = useMemo(() => groups.flatMap(g => g.items), [groups])
|
||||
const hasQuery = query.trim().length > 0
|
||||
const groups = useMemo(
|
||||
() => groupResults(filteredCommands, hasQuery),
|
||||
[filteredCommands, hasQuery],
|
||||
)
|
||||
|
||||
return {
|
||||
groups,
|
||||
flatList: groups.flatMap((group) => group.items),
|
||||
}
|
||||
}
|
||||
|
||||
function CommandPaletteInput({
|
||||
inputRef,
|
||||
query,
|
||||
onChange,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
query: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteResults({
|
||||
groups,
|
||||
selectedIndex,
|
||||
listRef,
|
||||
onHover,
|
||||
onSelect,
|
||||
}: {
|
||||
groups: { group: CommandGroup; items: CommandAction[] }[]
|
||||
selectedIndex: number
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
onHover: (index: number) => void
|
||||
onSelect: (command: CommandAction) => void
|
||||
}) {
|
||||
const flatList = groups.flatMap((group) => group.items)
|
||||
|
||||
if (flatList.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
No matching commands
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = groups.reduce<Array<{ group: CommandGroup; items: CommandAction[]; startIndex: number }>>(
|
||||
(acc, group) => {
|
||||
const previous = acc.at(-1)
|
||||
acc.push({
|
||||
...group,
|
||||
startIndex: previous ? previous.startIndex + previous.items.length : 0,
|
||||
})
|
||||
return acc
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{sections.map(({ group, items, startIndex }) => {
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((command, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
return (
|
||||
<CommandRow
|
||||
key={command.id}
|
||||
command={command}
|
||||
selected={globalIndex === selectedIndex}
|
||||
onHover={() => onHover(globalIndex)}
|
||||
onSelect={() => onSelect(command)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteFooter({
|
||||
aiMode,
|
||||
}: {
|
||||
aiMode: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{aiMode ? 'Claude mode' : '↑↓ navigate'}</span>
|
||||
<span>{aiMode ? '↵ send' : '↵ select'}</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, ...props }: CommandPaletteProps) {
|
||||
if (!open) return null
|
||||
return <OpenCommandPalette {...props} />
|
||||
}
|
||||
|
||||
function OpenCommandPalette({
|
||||
commands,
|
||||
entries = [],
|
||||
claudeCodeReady = true,
|
||||
onClose,
|
||||
}: Omit<CommandPaletteProps, 'open'>) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [aiValue, setAiValue] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const aiInputRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const aiMode = aiValue.startsWith(' ')
|
||||
const { groups, flatList } = usePaletteResults(commands, query)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change
|
||||
}, [query])
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
if (aiMode) {
|
||||
aiInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
inputRef.current?.focus()
|
||||
}, 50)
|
||||
return () => window.clearTimeout(focusTimer)
|
||||
}, [aiMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const el = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | undefined
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
if (aiMode || !listRef.current) return
|
||||
const selectedElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null
|
||||
selectedElement?.scrollIntoView({ block: 'nearest' })
|
||||
}, [aiMode, selectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault(); onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, flatList.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const cmd = flatList[selectedIndex]
|
||||
if (cmd) { onClose(); cmd.execute() }
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (aiMode) return
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((current) => Math.min(current + 1, flatList.length - 1))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((current) => Math.max(current - 1, 0))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const command = flatList[selectedIndex]
|
||||
if (!command) return
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [open, flatList, selectedIndex, onClose])
|
||||
|
||||
if (!open) return null
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [aiMode, flatList, onClose, selectedIndex])
|
||||
|
||||
let runningIndex = 0
|
||||
const handleQueryChange = (nextQuery: string) => {
|
||||
setSelectedIndex(0)
|
||||
if (nextQuery.startsWith(' ')) {
|
||||
setAiValue(nextQuery)
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
|
||||
setQuery(nextQuery)
|
||||
}
|
||||
|
||||
const handleAiValueChange = (nextValue: string) => {
|
||||
setSelectedIndex(0)
|
||||
if (nextValue.startsWith(' ')) {
|
||||
setAiValue(nextValue)
|
||||
return
|
||||
}
|
||||
|
||||
setAiValue('')
|
||||
setQuery(nextValue)
|
||||
}
|
||||
|
||||
const handleSelectCommand = (command: CommandAction) => {
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
|
||||
const handleSubmitAiPrompt = (text: string, references: NoteReference[]) => {
|
||||
if (!text.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (!claudeCodeReady) return
|
||||
|
||||
queueAiPrompt(text, references)
|
||||
requestOpenAiChat()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -118,53 +303,33 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex w-[520px] max-w-[90vw] max-h-[440px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={cn(
|
||||
'flex w-[520px] max-h-[440px] max-w-[90vw] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]',
|
||||
aiMode && 'min-h-[220px]',
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{flatList.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
No matching commands
|
||||
</div>
|
||||
) : (
|
||||
groups.map(({ group, items }) => {
|
||||
const startIndex = runningIndex
|
||||
runningIndex += items.length
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((cmd, i) => {
|
||||
const globalIdx = startIndex + i
|
||||
return (
|
||||
<CommandRow
|
||||
key={cmd.id}
|
||||
command={cmd}
|
||||
selected={globalIdx === selectedIndex}
|
||||
onHover={() => setSelectedIndex(globalIdx)}
|
||||
onSelect={() => { onClose(); cmd.execute() }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>↑↓ navigate</span>
|
||||
<span>↵ select</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
{aiMode ? (
|
||||
<CommandPaletteAiMode
|
||||
entries={entries}
|
||||
value={aiValue}
|
||||
claudeCodeReady={claudeCodeReady}
|
||||
onChange={handleAiValueChange}
|
||||
onSubmit={handleSubmitAiPrompt}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CommandPaletteInput inputRef={inputRef} query={query} onChange={handleQueryChange} />
|
||||
<CommandPaletteResults
|
||||
groups={groups}
|
||||
selectedIndex={selectedIndex}
|
||||
listRef={listRef}
|
||||
onHover={setSelectedIndex}
|
||||
onSelect={handleSelectCommand}
|
||||
/>
|
||||
<CommandPaletteFooter aiMode={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
67
src/components/CommandPaletteAiMode.tsx
Normal file
67
src/components/CommandPaletteAiMode.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Sparkle } from '@phosphor-icons/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
|
||||
interface CommandPaletteAiModeProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
claudeCodeReady: boolean
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
|
||||
function stripLeadingSpace(value: string): string {
|
||||
return value.startsWith(' ') ? value.slice(1) : value
|
||||
}
|
||||
|
||||
export function CommandPaletteAiMode({
|
||||
entries,
|
||||
value,
|
||||
claudeCodeReady,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: CommandPaletteAiModeProps) {
|
||||
return (
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={(text, references) => onSubmit(stripLeadingSpace(text), references)}
|
||||
submitOnEmpty={true}
|
||||
placeholder="Ask Claude Code..."
|
||||
dataTestId="command-palette-ai-input"
|
||||
editorClassName="border-none px-0 py-0 text-[15px]"
|
||||
suggestionListVariant="palette"
|
||||
paletteHeader={(
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<Sparkle size={12} weight="fill" />
|
||||
<span>Ask Claude Code</span>
|
||||
</div>
|
||||
)}
|
||||
paletteEmptyState={(
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
{!claudeCodeReady ? (
|
||||
'Claude Code is not available on this machine.'
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 font-medium text-foreground">Ask Claude Code</div>
|
||||
<div>
|
||||
{value.trim().length === 0
|
||||
? 'Type your prompt after the leading space.'
|
||||
: 'Type [[ to insert a note reference inline.'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
paletteFooter={(
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>Claude mode</span>
|
||||
<span>↵ send</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
198
src/components/InlineWikilinkInput.tsx
Normal file
198
src/components/InlineWikilinkInput.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import {
|
||||
buildInlineWikilinkSegments,
|
||||
extractInlineWikilinkReferences,
|
||||
findActiveWikilinkQuery,
|
||||
findInlineChipDeletionRange,
|
||||
} from './inlineWikilinkText'
|
||||
import {
|
||||
InlineWikilinkEditorField,
|
||||
InlineWikilinkPaletteLayout,
|
||||
InlineWikilinkSuggestionList,
|
||||
} from './InlineWikilinkParts'
|
||||
import { handleInlineWikilinkKeyDown } from './inlineWikilinkKeydown'
|
||||
import { useInlineWikilinkSelection } from './useInlineWikilinkSelection'
|
||||
import { useInlineWikilinkSuggestionsState } from './useInlineWikilinkSuggestionsState'
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
|
||||
interface InlineWikilinkInputProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit?: (text: string, references: NoteReference[]) => void
|
||||
submitOnEmpty?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
dataTestId?: string
|
||||
editorClassName?: string
|
||||
suggestionListVariant?: 'floating' | 'palette'
|
||||
suggestionEmptyLabel?: string
|
||||
paletteHeader?: ReactNode
|
||||
paletteEmptyState?: ReactNode
|
||||
paletteFooter?: ReactNode
|
||||
}
|
||||
|
||||
function deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
}: {
|
||||
direction: 'backward' | 'forward'
|
||||
segments: ReturnType<typeof buildInlineWikilinkSegments>
|
||||
selectionIndex: number
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSelectionIndexChange: (selectionIndex: number) => void
|
||||
}) {
|
||||
const deletionRange = findInlineChipDeletionRange(segments, selectionIndex, direction)
|
||||
if (!deletionRange) return false
|
||||
|
||||
onChange(value.slice(0, deletionRange.start) + value.slice(deletionRange.end))
|
||||
onSelectionIndexChange(deletionRange.start)
|
||||
return true
|
||||
}
|
||||
|
||||
function submitInlineValue({
|
||||
onSubmit,
|
||||
submitOnEmpty,
|
||||
value,
|
||||
references,
|
||||
}: {
|
||||
onSubmit?: (text: string, references: NoteReference[]) => void
|
||||
submitOnEmpty: boolean
|
||||
value: string
|
||||
references: NoteReference[]
|
||||
}) {
|
||||
if (!onSubmit) return
|
||||
const normalizedValue = normalizeInlineWikilinkValue(value)
|
||||
if (!submitOnEmpty && !normalizedValue.trim()) return
|
||||
onSubmit(normalizedValue, references)
|
||||
}
|
||||
|
||||
export function InlineWikilinkInput({
|
||||
entries,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
submitOnEmpty = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
inputRef,
|
||||
dataTestId = 'agent-input',
|
||||
editorClassName,
|
||||
suggestionListVariant = 'floating',
|
||||
suggestionEmptyLabel = 'No matching notes',
|
||||
paletteHeader,
|
||||
paletteEmptyState,
|
||||
paletteFooter,
|
||||
}: InlineWikilinkInputProps) {
|
||||
const segments = useMemo(
|
||||
() => buildInlineWikilinkSegments(value, entries),
|
||||
[entries, value],
|
||||
)
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const {
|
||||
selectionIndex,
|
||||
setSelectionIndex,
|
||||
setCombinedRef,
|
||||
syncSelectionIndex,
|
||||
commitValueFromEditor,
|
||||
focusSelectionAt,
|
||||
} = useInlineWikilinkSelection({
|
||||
value,
|
||||
onChange,
|
||||
inputRef,
|
||||
})
|
||||
const activeQuery = useMemo(
|
||||
() => findActiveWikilinkQuery(value, selectionIndex),
|
||||
[selectionIndex, value],
|
||||
)
|
||||
const references = useMemo(() => extractInlineWikilinkReferences(value, entries), [entries, value])
|
||||
const {
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
setSuggestionIndex,
|
||||
selectSuggestion,
|
||||
cycleSuggestions,
|
||||
} = useInlineWikilinkSuggestionsState({
|
||||
activeQueryKey: activeQuery ? `${activeQuery.start}:${activeQuery.query}` : '',
|
||||
entries,
|
||||
query: activeQuery?.query ?? null,
|
||||
value,
|
||||
selectionIndex,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
focusSelectionAt,
|
||||
})
|
||||
const deleteAdjacentChip = (direction: 'backward' | 'forward') => {
|
||||
return deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
})
|
||||
}
|
||||
const submitValue = () =>
|
||||
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
handleInlineWikilinkKeyDown({
|
||||
event,
|
||||
disabled,
|
||||
suggestionsOpen: suggestions.length > 0,
|
||||
onCycleSuggestions: cycleSuggestions,
|
||||
onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex),
|
||||
onDeleteAdjacentChip: deleteAdjacentChip,
|
||||
canSubmit: onSubmit !== undefined,
|
||||
onSubmit: submitValue,
|
||||
})
|
||||
const editor = (
|
||||
<InlineWikilinkEditorField
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
inputRef={setCombinedRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
onInput={commitValueFromEditor}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSelectionChange={syncSelectionIndex}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
const suggestionList = suggestions.length > 0 ? (
|
||||
<InlineWikilinkSuggestionList
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedSuggestionIndex}
|
||||
onHover={setSuggestionIndex}
|
||||
onSelect={selectSuggestion}
|
||||
typeEntryMap={typeEntryMap}
|
||||
variant={suggestionListVariant}
|
||||
emptyLabel={suggestionEmptyLabel}
|
||||
/>
|
||||
) : null
|
||||
if (suggestionListVariant === 'palette') {
|
||||
return (
|
||||
<InlineWikilinkPaletteLayout
|
||||
header={paletteHeader}
|
||||
editor={editor}
|
||||
suggestionList={suggestionList}
|
||||
emptyState={paletteEmptyState}
|
||||
footer={paletteFooter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div className="relative">{editor}{suggestionList}</div>
|
||||
}
|
||||
251
src/components/InlineWikilinkParts.tsx
Normal file
251
src/components/InlineWikilinkParts.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { createElement } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { getTypeIcon } from './note-item/typeIcon'
|
||||
import type {
|
||||
InlineWikilinkChip,
|
||||
InlineWikilinkSegment,
|
||||
} from './inlineWikilinkText'
|
||||
import type { InlineWikilinkSuggestion } from './inlineWikilinkSuggestions'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function InlineWikilinkChipView({
|
||||
chip,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
chip: InlineWikilinkChip
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const typeEntry = chip.entry.isA ? typeEntryMap[chip.entry.isA] : undefined
|
||||
const color = getTypeColor(chip.entry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(chip.entry.isA, typeEntry?.color)
|
||||
const typeIcon = getTypeIcon(chip.entry.isA, typeEntry?.icon)
|
||||
|
||||
return (
|
||||
<span
|
||||
contentEditable={false}
|
||||
data-chip-target={chip.target}
|
||||
data-testid="inline-wikilink-chip"
|
||||
className="mx-[1px] inline-flex max-w-full items-center gap-1 rounded-full align-baseline"
|
||||
style={{
|
||||
backgroundColor,
|
||||
color,
|
||||
padding: '1px 8px 1px 6px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{chip.entry.icon ? (
|
||||
<NoteTitleIcon icon={chip.entry.icon} size={11} color={color} />
|
||||
) : (
|
||||
createElement(typeIcon, {
|
||||
'aria-hidden': true,
|
||||
width: 11,
|
||||
height: 11,
|
||||
className: 'shrink-0',
|
||||
})
|
||||
)}
|
||||
<span className="truncate">{chip.entry.title}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineSuggestionRow({
|
||||
suggestion,
|
||||
selected,
|
||||
onHover,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
suggestion: InlineWikilinkSuggestion
|
||||
selected: boolean
|
||||
onHover: () => void
|
||||
onSelect: () => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const typeEntry = suggestion.entry.isA ? typeEntryMap[suggestion.entry.isA] : undefined
|
||||
const color = getTypeColor(suggestion.entry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(suggestion.entry.isA, typeEntry?.color)
|
||||
const typeIcon = getTypeIcon(suggestion.entry.isA, typeEntry?.icon)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-1 flex cursor-pointer items-center justify-between rounded-md px-3 py-2 transition-colors',
|
||||
selected ? 'bg-accent' : 'hover:bg-secondary',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHover}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor, color }}
|
||||
>
|
||||
{suggestion.entry.icon ? (
|
||||
<NoteTitleIcon icon={suggestion.entry.icon} size={11} color={color} />
|
||||
) : (
|
||||
createElement(typeIcon, {
|
||||
'aria-hidden': true,
|
||||
width: 11,
|
||||
height: 11,
|
||||
className: 'shrink-0',
|
||||
})
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-foreground">{suggestion.title}</span>
|
||||
</div>
|
||||
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
|
||||
{suggestion.entry.isA ?? 'Note'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkSuggestionList({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onHover,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
variant = 'floating',
|
||||
emptyLabel = 'No matching notes',
|
||||
}: {
|
||||
suggestions: InlineWikilinkSuggestion[]
|
||||
selectedIndex: number
|
||||
onHover: (index: number) => void
|
||||
onSelect: (index: number) => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
variant?: 'floating' | 'palette'
|
||||
emptyLabel?: string
|
||||
}) {
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-5 text-center text-[13px] text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={variant === 'floating'
|
||||
? 'absolute bottom-full left-0 right-0 z-10 mb-1 max-h-64 overflow-y-auto rounded-lg border border-border bg-popover py-1 shadow-lg'
|
||||
: 'py-1'}
|
||||
data-testid="wikilink-menu"
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<InlineSuggestionRow
|
||||
key={`${suggestion.entry.path}:${suggestion.target}`}
|
||||
suggestion={suggestion}
|
||||
selected={index === selectedIndex}
|
||||
onHover={() => onHover(index)}
|
||||
onSelect={() => onSelect(index)}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
value: string
|
||||
placeholder?: string
|
||||
disabled: boolean
|
||||
inputRef: React.Ref<HTMLDivElement>
|
||||
dataTestId: string
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: InlineWikilinkSegment[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{value.length === 0 && placeholder && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-muted-foreground"
|
||||
style={{ padding: '8px 10px', fontSize: 13 }}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={inputRef}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning={true}
|
||||
role="textbox"
|
||||
aria-multiline="false"
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-placeholder={placeholder}
|
||||
data-testid={dataTestId}
|
||||
className={cn(
|
||||
'min-h-[34px] w-full rounded-lg border border-border bg-transparent px-[10px] py-[8px] text-[13px] text-foreground outline-none',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
editorClassName,
|
||||
)}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={onSelectionChange}
|
||||
onKeyUp={onSelectionChange}
|
||||
onMouseUp={onSelectionChange}
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
segment.kind === 'text'
|
||||
? <span key={`text-${index}`}>{segment.text}</span>
|
||||
: (
|
||||
<InlineWikilinkChipView
|
||||
key={`chip-${segment.chip.entry.path}-${segment.chip.target}`}
|
||||
chip={segment.chip}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkPaletteLayout({
|
||||
header,
|
||||
editor,
|
||||
suggestionList,
|
||||
emptyState,
|
||||
footer,
|
||||
}: {
|
||||
header?: React.ReactNode
|
||||
editor: React.ReactNode
|
||||
suggestionList: React.ReactNode
|
||||
emptyState?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
{header}
|
||||
{editor}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{suggestionList ?? emptyState}
|
||||
</div>
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -31,249 +31,154 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person' }),
|
||||
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person', aliases: ['BLT'] }),
|
||||
makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }),
|
||||
makeEntry({ path: '/vault/archived.md', title: 'Archived', filename: 'archived.md', archived: true }),
|
||||
]
|
||||
|
||||
/** Wrapper that manages controlled value state so onChange/selectSuggestion work correctly. */
|
||||
function Controlled({ onSend, ...props }: {
|
||||
entries: VaultEntry[]
|
||||
function Controlled({
|
||||
onSend,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
}: {
|
||||
onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<WikilinkChatInput
|
||||
entries={props.entries}
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSend={onSend ?? vi.fn()}
|
||||
disabled={props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Helper: type in the input and wait for debounce to flush. */
|
||||
async function typeAndWait(text: string) {
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: text, selectionStart: text.length },
|
||||
})
|
||||
await act(() => { vi.advanceTimersByTime(150) })
|
||||
function setSelection(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const targetNode = editor.firstChild ?? editor
|
||||
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
|
||||
? Math.min(offset, targetNode.textContent?.length ?? 0)
|
||||
: Math.min(offset, targetNode.childNodes.length)
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(targetNode, safeOffset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function updateEditorText(text: string) {
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
fireEvent.focus(editor)
|
||||
editor.textContent = text
|
||||
setSelection(editor, text.length)
|
||||
fireEvent.input(editor)
|
||||
}
|
||||
|
||||
function clickFirstSuggestion() {
|
||||
const rows = screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
fireEvent.click(rows[0])
|
||||
}
|
||||
|
||||
describe('WikilinkChatInput', () => {
|
||||
it('renders input with placeholder', () => {
|
||||
render(<Controlled entries={entries} placeholder="Ask something..." />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask something...')
|
||||
it('renders the placeholder overlay for an empty draft', () => {
|
||||
render(<Controlled placeholder="Ask something..." />)
|
||||
expect(screen.getByText('Ask something...')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask something...')
|
||||
})
|
||||
|
||||
it('calls onChange when typing', () => {
|
||||
it('calls onChange when the draft changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="" onChange={onChange} onSend={vi.fn()} />,
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'hello' } })
|
||||
|
||||
updateEditorText('hello')
|
||||
expect(onChange).toHaveBeenCalledWith('hello')
|
||||
})
|
||||
|
||||
it('shows wikilink menu when [[ is typed', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('shows wikilink suggestions after typing [[', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('[[a')
|
||||
|
||||
it('filters suggestions matching query', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Alpha')
|
||||
expect(menu.textContent).not.toContain('Beta')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('excludes archived entries from suggestions', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[arch')
|
||||
const menu = screen.queryByTestId('wikilink-menu')
|
||||
if (menu) {
|
||||
expect(menu.textContent).not.toContain('Archived')
|
||||
}
|
||||
vi.useRealTimers()
|
||||
it('matches suggestions by alias', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('[[BLT')
|
||||
|
||||
expect(screen.getByTestId('wikilink-menu').textContent).toContain('Beta')
|
||||
})
|
||||
|
||||
it('selects suggestion on click and creates pill', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
it('renders selected wikilinks inline instead of in a separate pill strip', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('edit my [[alp')
|
||||
clickFirstSuggestion()
|
||||
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
const items = menu.querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
fireEvent.click(items[0])
|
||||
|
||||
const pills = screen.queryAllByTestId('reference-pill')
|
||||
expect(pills.length).toBe(1)
|
||||
vi.useRealTimers()
|
||||
expect(screen.queryByTestId('reference-pill')).toBeNull()
|
||||
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-input').textContent).toContain('Alpha')
|
||||
})
|
||||
|
||||
it('does not duplicate pills for same note', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
|
||||
// First selection
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
// Second selection of same note
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // No duplicate
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('removes pill when x button is clicked', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
const pill = screen.getByTestId('reference-pill')
|
||||
fireEvent.click(pill.querySelector('button')!)
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('navigates suggestions with keyboard', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
|
||||
const input = screen.getByTestId('agent-input')
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'ArrowUp' })
|
||||
// Escape closes menu
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('selects suggestion with Enter key', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
it('selects a suggestion with Enter before sending the draft', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
updateEditorText('edit my [[alp')
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSend with text and references on Enter without menu', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled entries={entries} onSend={onSend} />)
|
||||
it('deletes an inline chip with a single Backspace', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('edit my [[alp')
|
||||
clickFirstSuggestion()
|
||||
|
||||
// Type non-wikilink text
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: 'hello', selectionStart: 5 },
|
||||
})
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
fireEvent.keyDown(editor, { key: 'Backspace' })
|
||||
|
||||
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
|
||||
})
|
||||
|
||||
it('submits serialized wikilink text and resolved references', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
updateEditorText('edit my [[alpha]] essay')
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(onSend).toHaveBeenCalledWith('hello', [])
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('edit my [[alpha]] essay', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not send on Enter+Shift', () => {
|
||||
it('does not send on Shift+Enter', () => {
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="hello" onChange={vi.fn()} onSend={onSend} />,
|
||||
)
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
updateEditorText('hello')
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not send on Enter when input is empty', () => {
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="" onChange={vi.fn()} onSend={onSend} />,
|
||||
)
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
it('marks the editor disabled when disabled is true', () => {
|
||||
render(<Controlled disabled />)
|
||||
|
||||
it('disables input when disabled prop is true', () => {
|
||||
render(<Controlled entries={entries} disabled />)
|
||||
expect((screen.getByTestId('agent-input') as HTMLInputElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes menu when ]] is typed', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
|
||||
// Type ]] to close
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: '[[alp]]', selectionStart: 7 },
|
||||
})
|
||||
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows type badge for non-Note types in suggestions', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Project')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('matches by alias', async () => {
|
||||
vi.useFakeTimers()
|
||||
const entriesWithAlias = [
|
||||
...entries,
|
||||
makeEntry({ path: '/vault/delta.md', title: 'Delta', filename: 'delta.md', aliases: ['DLT'] }),
|
||||
]
|
||||
render(<Controlled entries={entriesWithAlias} />)
|
||||
await typeAndWait('[[DLT')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Delta')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends references with onSend after selecting pills', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled entries={entries} onSend={onSend} />)
|
||||
|
||||
// Select a pill first
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
// Type a message and send
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: 'tell me about it', selectionStart: 16 },
|
||||
})
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
|
||||
expect(onSend).toHaveBeenCalledOnce()
|
||||
const [text, refs] = onSend.mock.calls[0]
|
||||
expect(text).toBe('tell me about it')
|
||||
expect(refs).toHaveLength(1)
|
||||
expect(refs[0].title).toBe('Alpha')
|
||||
expect(refs[0].path).toBe('/vault/alpha.md')
|
||||
vi.useRealTimers()
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
expect(editor).toHaveAttribute('contenteditable', 'false')
|
||||
expect(editor).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
/**
|
||||
* 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
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
|
||||
interface WikilinkChatInputProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -21,248 +9,27 @@ interface WikilinkChatInputProps {
|
||||
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.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,
|
||||
}
|
||||
})
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function WikilinkChatInput({
|
||||
entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef,
|
||||
entries,
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputRef,
|
||||
}: 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>
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSend}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
131
src/components/inlineWikilinkDom.ts
Normal file
131
src/components/inlineWikilinkDom.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
chipToken,
|
||||
normalizeInlineWikilinkValue,
|
||||
} from './inlineWikilinkTokens'
|
||||
|
||||
export function serializeInlineNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return normalizeInlineWikilinkValue(node.textContent ?? '')
|
||||
}
|
||||
|
||||
if (node instanceof HTMLElement) {
|
||||
if (node.dataset.chipTarget) {
|
||||
return chipToken(node.dataset.chipTarget)
|
||||
}
|
||||
|
||||
if (node.tagName === 'BR') return ''
|
||||
}
|
||||
|
||||
return Array.from(node.childNodes).map(serializeInlineNode).join('')
|
||||
}
|
||||
|
||||
function selectionFallsOutsideEditor(
|
||||
selection: Selection | null,
|
||||
root: HTMLDivElement,
|
||||
): boolean {
|
||||
return (
|
||||
!selection ||
|
||||
selection.rangeCount === 0 ||
|
||||
!selection.anchorNode ||
|
||||
!root.contains(selection.anchorNode)
|
||||
)
|
||||
}
|
||||
|
||||
export function readSelectionIndex(root: HTMLDivElement): number {
|
||||
const selection = window.getSelection()
|
||||
if (selectionFallsOutsideEditor(selection, root)) {
|
||||
return serializeInlineNode(root).length
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0).cloneRange()
|
||||
range.setStart(root, 0)
|
||||
return serializeInlineNode(range.cloneContents()).length
|
||||
}
|
||||
|
||||
export function applySelectionIndex(
|
||||
root: HTMLDivElement,
|
||||
selectionIndex: number,
|
||||
) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const range = document.createRange()
|
||||
const clampedIndex = Math.max(0, selectionIndex)
|
||||
const found = placeRangeAtIndex(root, clampedIndex, range)
|
||||
|
||||
if (!found) {
|
||||
range.selectNodeContents(root)
|
||||
range.collapse(false)
|
||||
}
|
||||
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function placeRangeAtIndex(
|
||||
node: Node,
|
||||
selectionIndex: number,
|
||||
range: Range,
|
||||
): boolean {
|
||||
let remaining = selectionIndex
|
||||
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const textPlacement = placeRangeInTextNode(child, remaining, range)
|
||||
if (textPlacement.placed) return true
|
||||
remaining = textPlacement.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (!(child instanceof HTMLElement)) continue
|
||||
|
||||
if (child.dataset.chipTarget) {
|
||||
const chipPlacement = placeRangeAroundChip(child, remaining, range)
|
||||
if (chipPlacement.placed) return true
|
||||
remaining = chipPlacement.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (placeRangeAtIndex(child, remaining, range)) return true
|
||||
remaining -= serializeInlineNode(child).length
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function placeRangeInTextNode(
|
||||
node: Node,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
) {
|
||||
const textLength = normalizeInlineWikilinkValue(node.textContent ?? '').length
|
||||
if (remaining <= textLength) {
|
||||
range.setStart(node, remaining)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { placed: false, remaining: remaining - textLength }
|
||||
}
|
||||
|
||||
function placeRangeAroundChip(
|
||||
node: HTMLElement,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
) {
|
||||
const tokenLength = chipToken(node.dataset.chipTarget ?? '').length
|
||||
|
||||
if (remaining <= 0) {
|
||||
range.setStartBefore(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
if (remaining <= tokenLength) {
|
||||
range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { placed: false, remaining: remaining - tokenLength }
|
||||
}
|
||||
117
src/components/inlineWikilinkKeydown.ts
Normal file
117
src/components/inlineWikilinkKeydown.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type React from 'react'
|
||||
|
||||
interface HandleSuggestionKeysArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
suggestionsOpen: boolean
|
||||
onCycleSuggestions: (direction: 1 | -1) => void
|
||||
onSelectSuggestion: () => void
|
||||
}
|
||||
|
||||
function handleSuggestionKeys({
|
||||
event,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
}: HandleSuggestionKeysArgs): boolean {
|
||||
if (!suggestionsOpen) return false
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
onCycleSuggestions(1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
onCycleSuggestions(-1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onSelectSuggestion()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
interface HandleDeleteKeysArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
}
|
||||
|
||||
function handleDeleteKeys({
|
||||
event,
|
||||
onDeleteAdjacentChip,
|
||||
}: HandleDeleteKeysArgs): boolean {
|
||||
if (event.key === 'Backspace' && onDeleteAdjacentChip('backward')) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' && onDeleteAdjacentChip('forward')) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
interface HandleSubmitKeyArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
canSubmit: boolean
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
function handleSubmitKey({
|
||||
event,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
}: HandleSubmitKeyArgs): boolean {
|
||||
if (!canSubmit) return false
|
||||
if (event.key !== 'Enter' || event.shiftKey) return false
|
||||
|
||||
event.preventDefault()
|
||||
onSubmit()
|
||||
return true
|
||||
}
|
||||
|
||||
interface HandleInlineWikilinkKeyDownArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
disabled: boolean
|
||||
suggestionsOpen: boolean
|
||||
onCycleSuggestions: (direction: 1 | -1) => void
|
||||
onSelectSuggestion: () => void
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
canSubmit: boolean
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export function handleInlineWikilinkKeyDown({
|
||||
event,
|
||||
disabled,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
onDeleteAdjacentChip,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
}: HandleInlineWikilinkKeyDownArgs) {
|
||||
if (disabled) return
|
||||
|
||||
if (handleSuggestionKeys({
|
||||
event,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleDeleteKeys({ event, onDeleteAdjacentChip })) {
|
||||
return
|
||||
}
|
||||
|
||||
handleSubmitKey({ event, canSubmit, onSubmit })
|
||||
}
|
||||
78
src/components/inlineWikilinkSuggestions.ts
Normal file
78
src/components/inlineWikilinkSuggestions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
MAX_RESULTS,
|
||||
deduplicateByPath,
|
||||
disambiguateTitles,
|
||||
preFilterWikilinks,
|
||||
type WikilinkBaseItem,
|
||||
} from '../utils/wikilinkSuggestions'
|
||||
import { toInlineWikilinkTarget } from './inlineWikilinkTokens'
|
||||
|
||||
export interface InlineWikilinkSuggestion {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SuggestionItem extends WikilinkBaseItem {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
}
|
||||
|
||||
function toSuggestionItems(entries: VaultEntry[]): SuggestionItem[] {
|
||||
return entries
|
||||
.filter((entry) => !entry.archived)
|
||||
.map((entry) => ({
|
||||
entry,
|
||||
target: toInlineWikilinkTarget(entry),
|
||||
title: entry.title,
|
||||
aliases: entry.aliases,
|
||||
group: entry.isA ?? 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))
|
||||
}
|
||||
|
||||
function toSuggestion(item: SuggestionItem): InlineWikilinkSuggestion {
|
||||
return {
|
||||
entry: item.entry,
|
||||
target: item.target,
|
||||
title: item.title,
|
||||
}
|
||||
}
|
||||
|
||||
function matchSingleCharacterQuery(
|
||||
items: SuggestionItem[],
|
||||
query: string,
|
||||
): SuggestionItem[] {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return items.filter((item) =>
|
||||
item.title.toLowerCase().includes(lowerQuery) ||
|
||||
item.aliases.some((alias) => alias.toLowerCase().includes(lowerQuery)) ||
|
||||
item.group.toLowerCase().includes(lowerQuery) ||
|
||||
item.path.toLowerCase().includes(lowerQuery),
|
||||
)
|
||||
}
|
||||
|
||||
function buildTopSuggestions(items: SuggestionItem[]): InlineWikilinkSuggestion[] {
|
||||
return [...items]
|
||||
.sort((left, right) => left.title.localeCompare(right.title))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map(toSuggestion)
|
||||
}
|
||||
|
||||
export function buildInlineWikilinkSuggestions(
|
||||
entries: VaultEntry[],
|
||||
query: string,
|
||||
): InlineWikilinkSuggestion[] {
|
||||
const items = toSuggestionItems(entries)
|
||||
if (query.length === 0) return buildTopSuggestions(items)
|
||||
|
||||
const matchedItems = query.length === 1
|
||||
? matchSingleCharacterQuery(items, query)
|
||||
: preFilterWikilinks(items, query)
|
||||
|
||||
return disambiguateTitles(deduplicateByPath(matchedItems))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map(toSuggestion)
|
||||
}
|
||||
149
src/components/inlineWikilinkText.ts
Normal file
149
src/components/inlineWikilinkText.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import {
|
||||
chipToken,
|
||||
normalizeInlineWikilinkValue,
|
||||
toInlineWikilinkTarget,
|
||||
} from './inlineWikilinkTokens'
|
||||
|
||||
export interface InlineWikilinkChip {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
}
|
||||
|
||||
export type InlineWikilinkSegment =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'chip'; chip: InlineWikilinkChip }
|
||||
|
||||
export interface ActiveWikilinkQuery {
|
||||
start: number
|
||||
query: string
|
||||
}
|
||||
|
||||
const INLINE_WIKILINK_PATTERN = /\[\[([^[\]\r\n]+?)\]\]/g
|
||||
|
||||
export function buildInlineWikilinkSegments(
|
||||
value: string,
|
||||
entries: VaultEntry[],
|
||||
): InlineWikilinkSegment[] {
|
||||
const normalizedValue = normalizeInlineWikilinkValue(value)
|
||||
const segments: InlineWikilinkSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
INLINE_WIKILINK_PATTERN.lastIndex = 0
|
||||
for (const match of normalizedValue.matchAll(INLINE_WIKILINK_PATTERN)) {
|
||||
const fullMatch = match[0]
|
||||
const target = match[1]
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
segments.push({ kind: 'text', text: normalizedValue.slice(cursor, start) })
|
||||
}
|
||||
|
||||
const entry = resolveEntry(entries, target)
|
||||
if (!entry) {
|
||||
segments.push({ kind: 'text', text: fullMatch })
|
||||
} else {
|
||||
segments.push({
|
||||
kind: 'chip',
|
||||
chip: { entry, target: toInlineWikilinkTarget(entry) },
|
||||
})
|
||||
}
|
||||
cursor = start + fullMatch.length
|
||||
}
|
||||
|
||||
if (cursor < normalizedValue.length) {
|
||||
segments.push({ kind: 'text', text: normalizedValue.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ kind: 'text', text: '' }]
|
||||
}
|
||||
|
||||
export function extractInlineWikilinkReferences(
|
||||
value: string,
|
||||
entries: VaultEntry[],
|
||||
): NoteReference[] {
|
||||
const references: NoteReference[] = []
|
||||
const seenPaths = new Set<string>()
|
||||
|
||||
for (const segment of buildInlineWikilinkSegments(value, entries)) {
|
||||
if (segment.kind !== 'chip') continue
|
||||
if (seenPaths.has(segment.chip.entry.path)) continue
|
||||
|
||||
seenPaths.add(segment.chip.entry.path)
|
||||
references.push({
|
||||
title: segment.chip.entry.title,
|
||||
path: segment.chip.entry.path,
|
||||
type: segment.chip.entry.isA,
|
||||
})
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
function hasClosedQuery(openText: string): boolean {
|
||||
return openText.includes(']]') || /[\r\n]/.test(openText)
|
||||
}
|
||||
|
||||
export function findActiveWikilinkQuery(
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
): ActiveWikilinkQuery | null {
|
||||
const clampedIndex = Math.max(0, Math.min(selectionIndex, value.length))
|
||||
const textBeforeCursor = value.slice(0, clampedIndex)
|
||||
const triggerStart = textBeforeCursor.lastIndexOf('[[')
|
||||
|
||||
if (triggerStart < 0) return null
|
||||
|
||||
const openText = textBeforeCursor.slice(triggerStart + 2)
|
||||
if (hasClosedQuery(openText)) return null
|
||||
|
||||
return { start: triggerStart, query: openText }
|
||||
}
|
||||
|
||||
export function replaceActiveWikilinkQuery(
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
target: string,
|
||||
): { value: string; nextSelectionIndex: number } | null {
|
||||
const activeQuery = findActiveWikilinkQuery(value, selectionIndex)
|
||||
if (!activeQuery) return null
|
||||
|
||||
const token = chipToken(target)
|
||||
return {
|
||||
value: value.slice(0, activeQuery.start) + token + value.slice(selectionIndex),
|
||||
nextSelectionIndex: activeQuery.start + token.length,
|
||||
}
|
||||
}
|
||||
|
||||
function segmentLength(segment: InlineWikilinkSegment): number {
|
||||
return segment.kind === 'text'
|
||||
? segment.text.length
|
||||
: chipToken(segment.chip.target).length
|
||||
}
|
||||
|
||||
export function findInlineChipDeletionRange(
|
||||
segments: InlineWikilinkSegment[],
|
||||
selectionIndex: number,
|
||||
direction: 'backward' | 'forward',
|
||||
): { start: number; end: number } | null {
|
||||
let cursor = 0
|
||||
|
||||
for (const segment of segments) {
|
||||
const nextCursor = cursor + segmentLength(segment)
|
||||
|
||||
if (segment.kind === 'chip') {
|
||||
const removePreviousChip = direction === 'backward' && selectionIndex === nextCursor
|
||||
const removeNextChip = direction === 'forward' && selectionIndex === cursor
|
||||
|
||||
if (removePreviousChip || removeNextChip) {
|
||||
return { start: cursor, end: nextCursor }
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
16
src/components/inlineWikilinkTokens.ts
Normal file
16
src/components/inlineWikilinkTokens.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export function toInlineWikilinkTarget(entry: VaultEntry): string {
|
||||
return entry.filename.replace(/\.md$/i, '')
|
||||
}
|
||||
|
||||
export function chipToken(target: string): string {
|
||||
return `[[${target}]]`
|
||||
}
|
||||
|
||||
export function normalizeInlineWikilinkValue(value: string): string {
|
||||
return value
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/\u200B/g, '')
|
||||
.replace(/\r?\n/g, ' ')
|
||||
}
|
||||
53
src/components/useAiPanelContextSnapshot.ts
Normal file
53
src/components/useAiPanelContextSnapshot.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
buildContextSnapshot,
|
||||
collectLinkedEntries,
|
||||
type NoteListItem,
|
||||
} from '../utils/ai-context'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
|
||||
interface UseAiPanelContextSnapshotArgs {
|
||||
activeEntry?: VaultEntry | null
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
input: string
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
}
|
||||
|
||||
export function useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
input,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
}: UseAiPanelContextSnapshotArgs) {
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const draftReferences = useMemo(
|
||||
() => extractInlineWikilinkReferences(input, entries ?? []),
|
||||
[entries, input],
|
||||
)
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: draftReferences.length > 0 ? draftReferences : undefined,
|
||||
})
|
||||
}, [activeEntry, activeNoteContent, draftReferences, entries, noteList, noteListFilter, openTabs])
|
||||
|
||||
return { linkedEntries, contextPrompt }
|
||||
}
|
||||
78
src/components/useAiPanelController.ts
Normal file
78
src/components/useAiPanelController.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useAiAgent, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type NoteListItem,
|
||||
type NoteReference,
|
||||
} from '../utils/ai-context'
|
||||
import { useAiPanelContextSnapshot } from './useAiPanelContextSnapshot'
|
||||
|
||||
interface UseAiPanelControllerArgs {
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
export function useAiPanelController({
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: UseAiPanelControllerArgs) {
|
||||
const [input, setInput] = useState('')
|
||||
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
input,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
})
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
agent.sendMessage(text, references)
|
||||
setInput('')
|
||||
}, [agent, isActive])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
return {
|
||||
agent,
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
}
|
||||
}
|
||||
42
src/components/useAiPanelFocus.ts
Normal file
42
src/components/useAiPanelFocus.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
interface UseAiPanelFocusArgs {
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
panelRef: React.RefObject<HTMLElement | null>
|
||||
isActive: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function useAiPanelFocus({
|
||||
inputRef,
|
||||
panelRef,
|
||||
isActive,
|
||||
onClose,
|
||||
}: UseAiPanelFocusArgs) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
inputRef.current?.focus()
|
||||
}, [inputRef, isActive, panelRef])
|
||||
|
||||
const handleEscape = useCallback((event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (!panelRef.current?.contains(document.activeElement)) return
|
||||
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}, [onClose, panelRef])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
}
|
||||
44
src/components/useAiPanelPromptQueue.ts
Normal file
44
src/components/useAiPanelPromptQueue.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import type { QueuedAiPrompt } from '../utils/aiPromptBridge'
|
||||
import { useQueuedAiPrompt } from './useQueuedAiPrompt'
|
||||
|
||||
interface AiAgentBridge {
|
||||
clearConversation: () => void
|
||||
sendMessage: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
|
||||
interface UseAiPanelPromptQueueArgs {
|
||||
agent: AiAgentBridge
|
||||
input: string
|
||||
isActive: boolean
|
||||
setInput: (value: string) => void
|
||||
}
|
||||
|
||||
export function useAiPanelPromptQueue({
|
||||
agent,
|
||||
input,
|
||||
isActive,
|
||||
setInput,
|
||||
}: UseAiPanelPromptQueueArgs) {
|
||||
const [queuedPrompt, setQueuedPrompt] = useState<QueuedAiPrompt | null>(null)
|
||||
|
||||
const handleQueuedPrompt = useCallback((prompt: QueuedAiPrompt) => {
|
||||
setInput(prompt.text)
|
||||
setQueuedPrompt(prompt)
|
||||
agent.clearConversation()
|
||||
}, [agent, setInput])
|
||||
|
||||
useQueuedAiPrompt(handleQueuedPrompt)
|
||||
|
||||
useEffect(() => {
|
||||
if (!queuedPrompt || isActive) return
|
||||
if (input !== queuedPrompt.text) return
|
||||
|
||||
agent.sendMessage(queuedPrompt.text, queuedPrompt.references)
|
||||
startTransition(() => {
|
||||
setInput('')
|
||||
setQueuedPrompt(null)
|
||||
})
|
||||
}, [agent, input, isActive, queuedPrompt, setInput])
|
||||
}
|
||||
72
src/components/useInlineWikilinkSelection.ts
Normal file
72
src/components/useInlineWikilinkSelection.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
applySelectionIndex,
|
||||
readSelectionIndex,
|
||||
serializeInlineNode,
|
||||
} from './inlineWikilinkDom'
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
|
||||
interface UseInlineWikilinkSelectionArgs {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function useInlineWikilinkSelection({
|
||||
value,
|
||||
onChange,
|
||||
inputRef,
|
||||
}: UseInlineWikilinkSelectionArgs) {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const [selectionIndex, setSelectionIndex] = useState(value.length)
|
||||
|
||||
const setCombinedRef = useCallback((node: HTMLDivElement | null) => {
|
||||
editorRef.current = node
|
||||
if (inputRef) {
|
||||
inputRef.current = node
|
||||
}
|
||||
}, [inputRef])
|
||||
|
||||
const syncSelectionIndex = useCallback(() => {
|
||||
if (!editorRef.current) return
|
||||
setSelectionIndex(readSelectionIndex(editorRef.current))
|
||||
}, [])
|
||||
|
||||
const focusSelectionAt = useCallback((nextSelectionIndex: number) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
applySelectionIndex(editor, nextSelectionIndex)
|
||||
}, [])
|
||||
|
||||
const commitValueFromEditor = useCallback(() => {
|
||||
if (!editorRef.current) return
|
||||
|
||||
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editorRef.current))
|
||||
const nextSelectionIndex = readSelectionIndex(editorRef.current)
|
||||
|
||||
onChange(nextValue)
|
||||
setSelectionIndex(Math.min(nextSelectionIndex, nextValue.length))
|
||||
}, [onChange])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
if (document.activeElement !== editor) return
|
||||
applySelectionIndex(editor, selectionIndex)
|
||||
}, [selectionIndex, value])
|
||||
|
||||
return {
|
||||
selectionIndex,
|
||||
setSelectionIndex,
|
||||
setCombinedRef,
|
||||
syncSelectionIndex,
|
||||
focusSelectionAt,
|
||||
commitValueFromEditor,
|
||||
}
|
||||
}
|
||||
137
src/components/useInlineWikilinkSuggestionsState.ts
Normal file
137
src/components/useInlineWikilinkSuggestionsState.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
buildInlineWikilinkSuggestions,
|
||||
type InlineWikilinkSuggestion,
|
||||
} from './inlineWikilinkSuggestions'
|
||||
import { replaceActiveWikilinkQuery } from './inlineWikilinkText'
|
||||
|
||||
interface UseInlineWikilinkSuggestionsStateArgs {
|
||||
activeQueryKey: string
|
||||
entries: VaultEntry[]
|
||||
query: string | null
|
||||
value: string
|
||||
selectionIndex: number
|
||||
onChange: (value: string) => void
|
||||
onSelectionIndexChange: (selectionIndex: number) => void
|
||||
focusSelectionAt: (selectionIndex: number) => void
|
||||
}
|
||||
|
||||
function selectedIndexForQuery(
|
||||
queryKey: string,
|
||||
currentState: { queryKey: string; index: number },
|
||||
suggestionCount: number,
|
||||
) {
|
||||
if (currentState.queryKey !== queryKey) return 0
|
||||
return Math.min(currentState.index, Math.max(suggestionCount - 1, 0))
|
||||
}
|
||||
|
||||
function buildInitialSuggestionState(
|
||||
queryKey: string,
|
||||
direction: 1 | -1,
|
||||
suggestionCount: number,
|
||||
) {
|
||||
return {
|
||||
queryKey,
|
||||
index: direction > 0 ? 0 : suggestionCount - 1,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCycledSuggestionState(
|
||||
currentIndex: number,
|
||||
queryKey: string,
|
||||
direction: 1 | -1,
|
||||
suggestionCount: number,
|
||||
) {
|
||||
const nextIndex = direction > 0
|
||||
? (currentIndex + 1) % suggestionCount
|
||||
: (currentIndex <= 0 ? suggestionCount - 1 : currentIndex - 1)
|
||||
|
||||
return { queryKey, index: nextIndex }
|
||||
}
|
||||
|
||||
function replacementForSuggestion(
|
||||
suggestions: InlineWikilinkSuggestion[],
|
||||
index: number,
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
) {
|
||||
const suggestion = suggestions[index]
|
||||
if (!suggestion) return null
|
||||
return replaceActiveWikilinkQuery(value, selectionIndex, suggestion.target)
|
||||
}
|
||||
|
||||
export function useInlineWikilinkSuggestionsState({
|
||||
activeQueryKey,
|
||||
entries,
|
||||
query,
|
||||
value,
|
||||
selectionIndex,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
focusSelectionAt,
|
||||
}: UseInlineWikilinkSuggestionsStateArgs) {
|
||||
const [suggestionState, setSuggestionState] = useState({ queryKey: '', index: 0 })
|
||||
|
||||
const suggestions = useMemo<InlineWikilinkSuggestion[]>(
|
||||
() => (query === null ? [] : buildInlineWikilinkSuggestions(entries, query)),
|
||||
[entries, query],
|
||||
)
|
||||
|
||||
const selectedSuggestionIndex = selectedIndexForQuery(
|
||||
activeQueryKey,
|
||||
suggestionState,
|
||||
suggestions.length,
|
||||
)
|
||||
|
||||
const setSuggestionIndex = useCallback((index: number) => {
|
||||
setSuggestionState({ queryKey: activeQueryKey, index })
|
||||
}, [activeQueryKey])
|
||||
|
||||
const selectSuggestion = useCallback((index: number) => {
|
||||
const replacement = replacementForSuggestion(
|
||||
suggestions,
|
||||
index,
|
||||
value,
|
||||
selectionIndex,
|
||||
)
|
||||
if (!replacement) return
|
||||
|
||||
onChange(replacement.value)
|
||||
onSelectionIndexChange(replacement.nextSelectionIndex)
|
||||
setSuggestionState({ queryKey: '', index: 0 })
|
||||
window.setTimeout(() => focusSelectionAt(replacement.nextSelectionIndex), 0)
|
||||
}, [
|
||||
focusSelectionAt,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
selectionIndex,
|
||||
suggestions,
|
||||
value,
|
||||
])
|
||||
|
||||
const cycleSuggestions = useCallback((direction: 1 | -1) => {
|
||||
if (suggestions.length === 0) return
|
||||
|
||||
setSuggestionState((current) => {
|
||||
if (current.queryKey !== activeQueryKey) {
|
||||
return buildInitialSuggestionState(activeQueryKey, direction, suggestions.length)
|
||||
}
|
||||
|
||||
return buildCycledSuggestionState(
|
||||
current.index,
|
||||
activeQueryKey,
|
||||
direction,
|
||||
suggestions.length,
|
||||
)
|
||||
})
|
||||
}, [activeQueryKey, suggestions.length])
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
setSuggestionIndex,
|
||||
selectSuggestion,
|
||||
cycleSuggestions,
|
||||
}
|
||||
}
|
||||
21
src/components/useQueuedAiPrompt.ts
Normal file
21
src/components/useQueuedAiPrompt.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
AI_PROMPT_QUEUED_EVENT,
|
||||
takeQueuedAiPrompt,
|
||||
type QueuedAiPrompt,
|
||||
} from '../utils/aiPromptBridge'
|
||||
|
||||
export function useQueuedAiPrompt(
|
||||
onPrompt: (prompt: QueuedAiPrompt) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const consumePrompt = () => {
|
||||
const queuedPrompt = takeQueuedAiPrompt()
|
||||
if (queuedPrompt) onPrompt(queuedPrompt)
|
||||
}
|
||||
|
||||
consumePrompt()
|
||||
window.addEventListener(AI_PROMPT_QUEUED_EVENT, consumePrompt)
|
||||
return () => window.removeEventListener(AI_PROMPT_QUEUED_EVENT, consumePrompt)
|
||||
}, [onPrompt])
|
||||
}
|
||||
34
src/utils/aiPromptBridge.ts
Normal file
34
src/utils/aiPromptBridge.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { NoteReference } from './ai-context'
|
||||
|
||||
export const OPEN_AI_CHAT_EVENT = 'tolaria:open-ai-chat'
|
||||
export const AI_PROMPT_QUEUED_EVENT = 'tolaria:ai-prompt-queued'
|
||||
|
||||
export interface QueuedAiPrompt {
|
||||
id: number
|
||||
text: string
|
||||
references: NoteReference[]
|
||||
}
|
||||
|
||||
let nextQueuedPromptId = 1
|
||||
let pendingPrompt: QueuedAiPrompt | null = null
|
||||
|
||||
export function queueAiPrompt(text: string, references: NoteReference[]): QueuedAiPrompt {
|
||||
const queuedPrompt = {
|
||||
id: nextQueuedPromptId++,
|
||||
text,
|
||||
references,
|
||||
}
|
||||
pendingPrompt = queuedPrompt
|
||||
window.dispatchEvent(new Event(AI_PROMPT_QUEUED_EVENT))
|
||||
return queuedPrompt
|
||||
}
|
||||
|
||||
export function takeQueuedAiPrompt(): QueuedAiPrompt | null {
|
||||
const queuedPrompt = pendingPrompt
|
||||
pendingPrompt = null
|
||||
return queuedPrompt
|
||||
}
|
||||
|
||||
export function requestOpenAiChat() {
|
||||
window.dispatchEvent(new Event(OPEN_AI_CHAT_EVENT))
|
||||
}
|
||||
Reference in New Issue
Block a user