diff --git a/src/App.tsx b/src/App.tsx index 3e9169dc..328052cb 100644 --- a/src/App.tsx +++ b/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([]) const [toastMessage, setToastMessage] = useState(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() { 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} /> setToastMessage(null)} /> - + diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx index ecea89bc..f230d866 100644 --- a/src/components/AiPanel.test.tsx +++ b/src/components/AiPanel.test.tsx @@ -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['messages'] = [] let mockStatus: ReturnType['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() 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( ) - 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() - 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() + + 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() + }) }) diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index d7629493..e54c2752 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -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 + isActive: boolean + onChange: (value: string) => void + onSend: (text: string, references: NoteReference[]) => void +}) { + return ( +
+
+
+ +
+ +
+
+ ) +} + export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) { - const [input, setInput] = useState('') - const [pendingRefs, setPendingRefs] = useState([]) - const inputRef = useRef(null) + const inputRef = useRef(null) const panelRef = useRef(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(() => ({ + 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 ( ) } diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx index ff7ad71f..263686be 100644 --- a/src/components/CommandPalette.test.tsx +++ b/src/components/CommandPalette.test.tsx @@ -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 => ({ 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 => ({ + 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() + 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( + , + ) + + 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( + , + ) + + 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() + + 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' }), diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index d10984e0..b02eb6c3 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -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() - 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() + + 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(null) - const listRef = useRef(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 + query: string + onChange: (value: string) => void +}) { + return ( + onChange(event.target.value)} + /> + ) +} + +function CommandPaletteResults({ + groups, + selectedIndex, + listRef, + onHover, + onSelect, +}: { + groups: { group: CommandGroup; items: CommandAction[] }[] + selectedIndex: number + listRef: React.RefObject + onHover: (index: number) => void + onSelect: (command: CommandAction) => void +}) { + const flatList = groups.flatMap((group) => group.items) + + if (flatList.length === 0) { + return ( +
+
+ No matching commands +
+
+ ) + } + + const sections = groups.reduce>( + (acc, group) => { + const previous = acc.at(-1) + acc.push({ + ...group, + startIndex: previous ? previous.startIndex + previous.items.length : 0, + }) + return acc + }, + [], + ) + + return ( +
+ {sections.map(({ group, items, startIndex }) => { + return ( +
+
+ {group} +
+ {items.map((command, index) => { + const globalIndex = startIndex + index + return ( + onHover(globalIndex)} + onSelect={() => onSelect(command)} + /> + ) + })} +
+ ) + })} +
+ ) +} + +function CommandPaletteFooter({ + aiMode, +}: { + aiMode: boolean +}) { + return ( +
+ {aiMode ? 'Claude mode' : '↑↓ navigate'} + {aiMode ? '↵ send' : '↵ select'} + esc close +
+ ) +} + +export function CommandPalette({ open, ...props }: CommandPaletteProps) { + if (!open) return null + return +} + +function OpenCommandPalette({ + commands, + entries = [], + claudeCodeReady = true, + onClose, +}: Omit) { + const [query, setQuery] = useState('') + const [aiValue, setAiValue] = useState('') + const [selectedIndex, setSelectedIndex] = useState(0) + const inputRef = useRef(null) + const aiInputRef = useRef(null) + const listRef = useRef(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 (
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()} > - setQuery(e.target.value)} - /> -
- {flatList.length === 0 ? ( -
- No matching commands -
- ) : ( - groups.map(({ group, items }) => { - const startIndex = runningIndex - runningIndex += items.length - return ( -
-
- {group} -
- {items.map((cmd, i) => { - const globalIdx = startIndex + i - return ( - setSelectedIndex(globalIdx)} - onSelect={() => { onClose(); cmd.execute() }} - /> - ) - })} -
- ) - }) - )} -
-
- ↑↓ navigate - ↵ select - esc close -
+ {aiMode ? ( + + ) : ( + <> + + + + + )}
) diff --git a/src/components/CommandPaletteAiMode.tsx b/src/components/CommandPaletteAiMode.tsx new file mode 100644 index 00000000..ddc3977a --- /dev/null +++ b/src/components/CommandPaletteAiMode.tsx @@ -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 ( + 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={( +
+ + Ask Claude Code +
+ )} + paletteEmptyState={( +
+ {!claudeCodeReady ? ( + 'Claude Code is not available on this machine.' + ) : ( + <> +
Ask Claude Code
+
+ {value.trim().length === 0 + ? 'Type your prompt after the leading space.' + : 'Type [[ to insert a note reference inline.'} +
+ + )} +
+ )} + paletteFooter={( +
+ Claude mode + ↵ send + esc close +
+ )} + /> + ) +} diff --git a/src/components/InlineWikilinkInput.tsx b/src/components/InlineWikilinkInput.tsx new file mode 100644 index 00000000..a6fdb887 --- /dev/null +++ b/src/components/InlineWikilinkInput.tsx @@ -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 + 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 + 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) => + handleInlineWikilinkKeyDown({ + event, + disabled, + suggestionsOpen: suggestions.length > 0, + onCycleSuggestions: cycleSuggestions, + onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex), + onDeleteAdjacentChip: deleteAdjacentChip, + canSubmit: onSubmit !== undefined, + onSubmit: submitValue, + }) + const editor = ( + + ) + const suggestionList = suggestions.length > 0 ? ( + + ) : null + if (suggestionListVariant === 'palette') { + return ( + + ) + } + return
{editor}{suggestionList}
+} diff --git a/src/components/InlineWikilinkParts.tsx b/src/components/InlineWikilinkParts.tsx new file mode 100644 index 00000000..d26a1802 --- /dev/null +++ b/src/components/InlineWikilinkParts.tsx @@ -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 +}) { + 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 ( + + {chip.entry.icon ? ( + + ) : ( + createElement(typeIcon, { + 'aria-hidden': true, + width: 11, + height: 11, + className: 'shrink-0', + }) + )} + {chip.entry.title} + + ) +} + +function InlineSuggestionRow({ + suggestion, + selected, + onHover, + onSelect, + typeEntryMap, +}: { + suggestion: InlineWikilinkSuggestion + selected: boolean + onHover: () => void + onSelect: () => void + typeEntryMap: Record +}) { + 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 ( +
event.preventDefault()} + onClick={onSelect} + onMouseEnter={onHover} + > +
+ + {suggestion.entry.icon ? ( + + ) : ( + createElement(typeIcon, { + 'aria-hidden': true, + width: 11, + height: 11, + className: 'shrink-0', + }) + )} + + {suggestion.title} +
+ + {suggestion.entry.isA ?? 'Note'} + +
+ ) +} + +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 + variant?: 'floating' | 'palette' + emptyLabel?: string +}) { + if (suggestions.length === 0) { + return ( +
+ {emptyLabel} +
+ ) + } + + return ( +
+ {suggestions.map((suggestion, index) => ( + onHover(index)} + onSelect={() => onSelect(index)} + typeEntryMap={typeEntryMap} + /> + ))} +
+ ) +} + +export function InlineWikilinkEditorField({ + value, + placeholder, + disabled, + inputRef, + dataTestId, + editorClassName, + onInput, + onKeyDown, + onSelectionChange, + segments, + typeEntryMap, +}: { + value: string + placeholder?: string + disabled: boolean + inputRef: React.Ref + dataTestId: string + editorClassName?: string + onInput: () => void + onKeyDown: (event: React.KeyboardEvent) => void + onSelectionChange: () => void + segments: InlineWikilinkSegment[] + typeEntryMap: Record +}) { + return ( +
+ {value.length === 0 && placeholder && ( +
+ {placeholder} +
+ )} +
+ {segments.map((segment, index) => ( + segment.kind === 'text' + ? {segment.text} + : ( + + ) + ))} +
+
+ ) +} + +export function InlineWikilinkPaletteLayout({ + header, + editor, + suggestionList, + emptyState, + footer, +}: { + header?: React.ReactNode + editor: React.ReactNode + suggestionList: React.ReactNode + emptyState?: React.ReactNode + footer?: React.ReactNode +}) { + return ( + <> +
+ {header} + {editor} +
+
+ {suggestionList ?? emptyState} +
+ {footer} + + ) +} diff --git a/src/components/WikilinkChatInput.test.tsx b/src/components/WikilinkChatInput.test.tsx index 69543907..842d7f08 100644 --- a/src/components/WikilinkChatInput.test.tsx +++ b/src/components/WikilinkChatInput.test.tsx @@ -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 => ({ 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 ( ) } -/** 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() - const input = screen.getByTestId('agent-input') as HTMLInputElement - expect(input.placeholder).toBe('Ask something...') + it('renders the placeholder overlay for an empty draft', () => { + render() + 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( , ) - 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() - await typeAndWait('[[a') - expect(screen.getByTestId('wikilink-menu')).toBeTruthy() - vi.useRealTimers() - }) + it('shows wikilink suggestions after typing [[', () => { + render() + updateEditorText('[[a') - it('filters suggestions matching query', async () => { - vi.useFakeTimers() - render() - 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() - 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() + updateEditorText('[[BLT') + + expect(screen.getByTestId('wikilink-menu').textContent).toContain('Beta') }) - it('selects suggestion on click and creates pill', async () => { - vi.useFakeTimers() - render() - await typeAndWait('[[a') + it('renders selected wikilinks inline instead of in a separate pill strip', () => { + render() + 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() - - // 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() - - 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() - 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() - await typeAndWait('[[alp') - expect(screen.getByTestId('wikilink-menu')).toBeTruthy() + it('selects a suggestion with Enter before sending the draft', () => { + const onSend = vi.fn() + render() + 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() + it('deletes an inline chip with a single Backspace', () => { + render() + 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() + + 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( - , - ) + render() + + 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( - , - ) - fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' }) - expect(onSend).not.toHaveBeenCalled() - }) + it('marks the editor disabled when disabled is true', () => { + render() - it('disables input when disabled prop is true', () => { - render() - expect((screen.getByTestId('agent-input') as HTMLInputElement).disabled).toBe(true) - }) - - it('closes menu when ]] is typed', async () => { - vi.useFakeTimers() - render() - 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() - 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() - 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() - - // 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') }) }) diff --git a/src/components/WikilinkChatInput.tsx b/src/components/WikilinkChatInput.tsx index ad4ff906..01f4a12e 100644 --- a/src/components/WikilinkChatInput.tsx +++ b/src/components/WikilinkChatInput.tsx @@ -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 -} - -interface Pill { - title: string - path: string - type: string | null - color?: string - lightColor?: string -} - -interface SuggestionEntry { - title: string - path: string - isA: string | null - color?: string - lightColor?: string -} - -function matchEntries( - entries: VaultEntry[], - query: string, - typeEntryMap: Record, -): SuggestionEntry[] { - if (query.length < MIN_QUERY_LENGTH) return [] - const lower = query.toLowerCase() - const matches = entries - .filter(e => - !e.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 } export function WikilinkChatInput({ - entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef, + entries, + value, + onChange, + onSend, + disabled, + placeholder, + inputRef, }: WikilinkChatInputProps) { - const [pills, setPills] = useState([]) - const [showMenu, setShowMenu] = useState(false) - const [selectedIndex, setSelectedIndex] = useState(0) - const [debouncedQuery, setDebouncedQuery] = useState('') - const internalRef = useRef(null) - const inputRefToUse = externalRef ?? internalRef - const menuRef = useRef(null) - const debounceTimer = useRef>(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) { - const newValue = e.target.value - onChange(newValue) - - const cursor = e.target.selectionStart ?? newValue.length - const textBefore = newValue.slice(0, cursor) - const bracketIdx = textBefore.lastIndexOf('[[') - - if (bracketIdx >= 0 && !textBefore.slice(bracketIdx).includes(']]')) { - const query = textBefore.slice(bracketIdx + 2) - setShowMenu(true) - setSelectedIndex(0) - clearTimeout(debounceTimer.current) - debounceTimer.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS) - } else { - setShowMenu(false) - } - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (showMenu && suggestions.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault() - setSelectedIndex(i => (i + 1) % suggestions.length) - return - } - if (e.key === 'ArrowUp') { - e.preventDefault() - setSelectedIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1)) - return - } - if (e.key === 'Enter') { - e.preventDefault() - selectSuggestion(suggestions[clampedIndex]) - return - } - if (e.key === 'Escape') { - e.preventDefault() - setShowMenu(false) - return - } - } - - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - if (!value.trim() && pills.length === 0) return - const references: NoteReference[] = pills.map(p => ({ - title: p.title, - path: p.path, - type: p.type, - })) - onSend(value, references) - setPills([]) - } - } - return ( -
- {pills.length > 0 && ( -
- {pills.map(pill => ( - - {pill.title} - - - ))} -
- )} - - {showMenu && suggestions.length > 0 && ( -
- {suggestions.map((s, i) => ( -
e.preventDefault()} - onClick={() => selectSuggestion(s)} - onMouseEnter={() => setSelectedIndex(i)} - > - {s.title} - {s.isA && s.isA !== 'Note' && ( - - {s.isA} - - )} -
- ))} -
- )} -
+ ) } diff --git a/src/components/inlineWikilinkDom.ts b/src/components/inlineWikilinkDom.ts new file mode 100644 index 00000000..2bab01a7 --- /dev/null +++ b/src/components/inlineWikilinkDom.ts @@ -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 } +} diff --git a/src/components/inlineWikilinkKeydown.ts b/src/components/inlineWikilinkKeydown.ts new file mode 100644 index 00000000..122849c5 --- /dev/null +++ b/src/components/inlineWikilinkKeydown.ts @@ -0,0 +1,117 @@ +import type React from 'react' + +interface HandleSuggestionKeysArgs { + event: React.KeyboardEvent + 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 + 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 + 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 + 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 }) +} diff --git a/src/components/inlineWikilinkSuggestions.ts b/src/components/inlineWikilinkSuggestions.ts new file mode 100644 index 00000000..77865651 --- /dev/null +++ b/src/components/inlineWikilinkSuggestions.ts @@ -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) +} diff --git a/src/components/inlineWikilinkText.ts b/src/components/inlineWikilinkText.ts new file mode 100644 index 00000000..fad34c29 --- /dev/null +++ b/src/components/inlineWikilinkText.ts @@ -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() + + 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 +} diff --git a/src/components/inlineWikilinkTokens.ts b/src/components/inlineWikilinkTokens.ts new file mode 100644 index 00000000..7c73aa41 --- /dev/null +++ b/src/components/inlineWikilinkTokens.ts @@ -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, ' ') +} diff --git a/src/components/useAiPanelContextSnapshot.ts b/src/components/useAiPanelContextSnapshot.ts new file mode 100644 index 00000000..b4539ca6 --- /dev/null +++ b/src/components/useAiPanelContextSnapshot.ts @@ -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 } +} diff --git a/src/components/useAiPanelController.ts b/src/components/useAiPanelController.ts new file mode 100644 index 00000000..2583b428 --- /dev/null +++ b/src/components/useAiPanelController.ts @@ -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(() => ({ + 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, + } +} diff --git a/src/components/useAiPanelFocus.ts b/src/components/useAiPanelFocus.ts new file mode 100644 index 00000000..348d7836 --- /dev/null +++ b/src/components/useAiPanelFocus.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect } from 'react' + +interface UseAiPanelFocusArgs { + inputRef: React.RefObject + panelRef: React.RefObject + 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]) +} diff --git a/src/components/useAiPanelPromptQueue.ts b/src/components/useAiPanelPromptQueue.ts new file mode 100644 index 00000000..131b7a0b --- /dev/null +++ b/src/components/useAiPanelPromptQueue.ts @@ -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(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]) +} diff --git a/src/components/useInlineWikilinkSelection.ts b/src/components/useInlineWikilinkSelection.ts new file mode 100644 index 00000000..6e4eb178 --- /dev/null +++ b/src/components/useInlineWikilinkSelection.ts @@ -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 +} + +export function useInlineWikilinkSelection({ + value, + onChange, + inputRef, +}: UseInlineWikilinkSelectionArgs) { + const editorRef = useRef(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, + } +} diff --git a/src/components/useInlineWikilinkSuggestionsState.ts b/src/components/useInlineWikilinkSuggestionsState.ts new file mode 100644 index 00000000..be69c502 --- /dev/null +++ b/src/components/useInlineWikilinkSuggestionsState.ts @@ -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( + () => (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, + } +} diff --git a/src/components/useQueuedAiPrompt.ts b/src/components/useQueuedAiPrompt.ts new file mode 100644 index 00000000..211de54a --- /dev/null +++ b/src/components/useQueuedAiPrompt.ts @@ -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]) +} diff --git a/src/utils/aiPromptBridge.ts b/src/utils/aiPromptBridge.ts new file mode 100644 index 00000000..c0839a1d --- /dev/null +++ b/src/utils/aiPromptBridge.ts @@ -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)) +}