Compare commits

...

6 Commits

Author SHA1 Message Date
lucaronin
a2c84d3ce8 test: align AI smoke selector with inline input 2026-04-12 13:20:26 +02:00
lucaronin
afbea1367c fix: narrow inline wikilink selection lookup 2026-04-12 13:15:51 +02:00
lucaronin
7933fe1bfb feat: add inline AI prompts to command palette 2026-04-12 13:14:18 +02:00
lucaronin
d16aa29d18 docs: test notes must be deleted from disk after QA; update QA script paths to tolaria-qa 2026-04-12 12:31:35 +02:00
lucaronin
6188395141 fix: align desktop test bridge types 2026-04-12 12:29:13 +02:00
lucaronin
1bf5a7d6e4 fix: preserve resized tables across raw mode toggles 2026-04-12 11:45:59 +02:00
37 changed files with 2488 additions and 778 deletions

View File

@@ -41,8 +41,8 @@ BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
@@ -107,7 +107,10 @@ After any Tauri command, new component/hook, data model change, or new integrati
### User vault (`~/Laputa/`)
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
- **Never commit or push** any test notes to the remote vault
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
### UI design
@@ -145,9 +148,9 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
### QA scripts
```bash
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
```
### Diagrams

View File

@@ -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} />

View File

@@ -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()
})
})

View File

@@ -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>
)
}

View File

@@ -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' }),

View File

@@ -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>
)

View 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>
)}
/>
)
}

View File

@@ -52,6 +52,7 @@ vi.mock('@blocknote/mantine', () => ({
vi.mock('@blocknote/mantine/style.css', () => ({}))
import { Editor } from './Editor'
import { applyPendingRawExitContent } from './editorRawModeSync'
import type { VaultEntry } from '../types'
const mockEntry: VaultEntry = {
@@ -98,6 +99,13 @@ This is a test note with some words to count.
`
const mockTab = { entry: mockEntry, content: mockContent }
const otherEntry: VaultEntry = {
...mockEntry,
path: '/vault/other.md',
filename: 'other.md',
title: 'Other Note',
}
const otherTab = { entry: otherEntry, content: '# Other\n' }
const defaultProps = {
tabs: [] as { entry: VaultEntry; content: string }[],
@@ -270,6 +278,27 @@ describe('Editor', () => {
})
})
describe('applyPendingRawExitContent', () => {
it('overrides only the matching tab when raw content is newer than tab state', () => {
const pending = {
path: mockEntry.path,
content: '---\ntype: Note\nstatus: Active\n---\n| Head 1 | Head 2 | Head 3 |\n| --- | --- | --- |\n| A | B | C |\n',
}
const result = applyPendingRawExitContent([mockTab, otherTab], pending)
expect(result[0]).toEqual({ ...mockTab, content: pending.content })
expect(result[1]).toBe(otherTab)
})
it('returns the original tabs array when the pending raw content is already synced', () => {
const tabs = [mockTab, otherTab]
const pending = { path: mockEntry.path, content: mockContent }
expect(applyPendingRawExitContent(tabs, pending)).toBe(tabs)
})
})
describe('click empty editor space', () => {
it('focuses editor at end of last block when clicking empty space below content', () => {
mockEditor.focus.mockClear()

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback, memo } from 'react'
import { useRef, useEffect, useCallback, useState, memo } from 'react'
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
@@ -13,6 +13,16 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { schema } from './editorSchema'
import { clearTableResizeState } from './tableResizeState'
import {
type PendingRawExitContent,
applyPendingRawExitContent,
buildPendingRawExitContent,
rememberPendingRawExitContent,
resolvePendingRawExitContent,
resolveRawModeContent,
syncActiveTabIntoRawBuffer,
} from './editorRawModeSync'
import './Editor.css'
import './EditorTheme.css'
@@ -129,23 +139,45 @@ interface EditorSetupParams {
}
function useRawModeWithFlush(
editor: ReturnType<typeof useCreateBlockNote>,
activeTabPath: string | null,
activeTabContent: string | null,
onContentChange?: (path: string, content: string) => void,
) {
const rawLatestContentRef = useRef<string | null>(null)
const [pendingRawExitContent, setPendingRawExitContent] = useState<PendingRawExitContent | null>(null)
const [rawModeContentOverride, setRawModeContentOverride] = useState<PendingRawExitContent | null>(null)
const handleFlushPending = useCallback(async () => {
const syncedContent = syncActiveTabIntoRawBuffer({
editor,
activeTabPath,
activeTabContent,
rawLatestContentRef,
onContentChange,
})
setRawModeContentOverride(buildPendingRawExitContent(activeTabPath, syncedContent))
clearTableResizeState(editor)
return true
}, [activeTabContent, activeTabPath, editor, onContentChange])
const handleBeforeRawEnd = useCallback(() => {
if (rawLatestContentRef.current != null && activeTabPath) {
onContentChange?.(activeTabPath, rawLatestContentRef.current)
}
setPendingRawExitContent(rememberPendingRawExitContent({
activeTabPath,
rawLatestContentRef,
onContentChange,
}))
setRawModeContentOverride(null)
rawLatestContentRef.current = null
}, [activeTabPath, onContentChange])
const { rawMode, handleToggleRaw } = useRawMode({
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
activeTabPath,
onFlushPending: handleFlushPending,
onBeforeRawEnd: handleBeforeRawEnd,
})
return { rawMode, handleToggleRaw, rawLatestContentRef }
return { rawMode, handleToggleRaw, rawLatestContentRef, pendingRawExitContent, setPendingRawExitContent, rawModeContentOverride }
}
function useEditorSetup({
@@ -160,15 +192,33 @@ function useEditorSetup({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
})
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
const { rawMode, handleToggleRaw, rawLatestContentRef } = useRawModeWithFlush(
activeTabPath, onContentChange,
const {
rawMode,
handleToggleRaw,
rawLatestContentRef,
pendingRawExitContent,
setPendingRawExitContent,
rawModeContentOverride,
} = useRawModeWithFlush(
editor,
activeTabPath,
activeTab?.content ?? null,
onContentChange,
)
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
useEffect(() => {
setPendingRawExitContent((current) => resolvePendingRawExitContent({
activeTabPath,
tabs,
pendingRawExitContent: current,
}))
}, [activeTabPath, setPendingRawExitContent, tabs])
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
tabs, activeTabPath, editor, onContentChange, rawMode,
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode,
})
useEditorFocus(editor, editorMountedRef)
@@ -185,7 +235,7 @@ function useEditorSetup({
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
return {
editor, activeTab, rawLatestContentRef,
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
@@ -209,7 +259,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
} = props
const {
editor, activeTab, rawLatestContentRef,
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
@@ -253,6 +303,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}

View 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>
}

View 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}
</>
)
}

View File

@@ -13,6 +13,72 @@ import type { VaultEntry } from '../types'
import { _wikilinkEntriesRef } from './editorSchema'
import { useEditorLinkActivation } from './useEditorLinkActivation'
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
| --- | --- | --- |
| A | B | C |
| D | E | F |
`
type TestTableBlock = {
type?: string
content?: { type?: string; columnWidths?: Array<number | null> }
}
function applySeededColumnWidths(
parsedBlocks: Array<TestTableBlock>,
columnWidths?: Array<number | null>,
) {
const tableBlock = parsedBlocks[0]
const tableContent = tableBlock?.content
if (
!columnWidths ||
tableBlock?.type !== 'table' ||
tableContent?.type !== 'tableContent'
) {
return
}
tableContent.columnWidths = [...columnWidths]
}
async function seedEditorWithTestTable(
editor: ReturnType<typeof useCreateBlockNote>,
columnWidths?: Array<number | null>,
) {
const parsedBlocks = await Promise.resolve(
editor.tryParseMarkdownToBlocks(TEST_TABLE_MARKDOWN),
) as Array<TestTableBlock>
applySeededColumnWidths(parsedBlocks, columnWidths)
const tableHtml = editor.blocksToHTMLLossy([
...parsedBlocks,
{ type: 'paragraph', content: [], children: [] },
] as typeof editor.document)
editor._tiptapEditor.commands.setContent(tableHtml)
editor.focus()
}
function useSeedBlockNoteTableBridge(editor: ReturnType<typeof useCreateBlockNote>) {
useEffect(() => {
const seedBlockNoteTable = (columnWidths?: Array<number | null>) => (
seedEditorWithTestTable(editor, columnWidths)
)
window.__laputaTest = {
...window.__laputaTest,
seedBlockNoteTable,
}
return () => {
if (window.__laputaTest?.seedBlockNoteTable === seedBlockNoteTable) {
delete window.__laputaTest.seedBlockNoteTable
}
}
}, [editor])
}
/** Insert an image block after the current cursor position. */
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
const editorRef = useRef(editor)
@@ -54,6 +120,8 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
_wikilinkEntriesRef.current = entries
}, [entries])
useSeedBlockNoteTableBridge(editor)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(

View File

@@ -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')
})
})

View File

@@ -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}
/>
)
}

View File

@@ -62,13 +62,14 @@ function RawModeEditorSection({
activeTab,
entries,
rawMode,
rawModeContent,
onRawContentChange,
onSave,
rawLatestContentRef,
vaultPath,
}: Pick<
EditorContentModel,
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'vaultPath'
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath'
> & {
rawMode: boolean
}) {
@@ -77,7 +78,7 @@ function RawModeEditorSection({
return (
<RawEditorView
key={activeTab.entry.path}
content={activeTab.content}
content={rawModeContent ?? activeTab.content}
path={activeTab.entry.path}
entries={entries}
onContentChange={onRawContentChange ?? (() => {})}
@@ -226,6 +227,7 @@ export function EditorContentLayout(model: EditorContentModel) {
onEditorChange,
isDeletedPreview,
rawLatestContentRef,
rawModeContent,
} = model
if (!activeTab) {
@@ -278,6 +280,7 @@ export function EditorContentLayout(model: EditorContentModel) {
activeTab={activeTab}
entries={entries}
rawMode={effectiveRawMode}
rawModeContent={rawModeContent}
onRawContentChange={onRawContentChange}
onSave={onSave}
rawLatestContentRef={rawLatestContentRef}

View File

@@ -37,6 +37,7 @@ export interface EditorContentProps {
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawModeContent?: string | null
rawLatestContentRef?: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean

View File

@@ -0,0 +1,116 @@
import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
interface Tab {
entry: VaultEntry
content: string
}
export interface PendingRawExitContent {
path: string
content: string
}
export function buildPendingRawExitContent(
path: string | null,
content: string | null,
): PendingRawExitContent | null {
if (!path || content === null) return null
return { path, content }
}
export function serializeEditorDocumentToMarkdown(
editor: ReturnType<typeof useCreateBlockNote>,
tabContent: string,
): string {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const [frontmatter] = splitFrontmatter(tabContent)
return `${frontmatter}${bodyMarkdown}`
}
export function applyPendingRawExitContent(
tabs: Tab[],
pendingRawExitContent: PendingRawExitContent | null,
): Tab[] {
if (!pendingRawExitContent) return tabs
let changed = false
const nextTabs = tabs.map((tab) => {
if (tab.entry.path !== pendingRawExitContent.path || tab.content === pendingRawExitContent.content) {
return tab
}
changed = true
return {
...tab,
content: pendingRawExitContent.content,
}
})
return changed ? nextTabs : tabs
}
export function syncActiveTabIntoRawBuffer(options: {
editor: ReturnType<typeof useCreateBlockNote>
activeTabPath: string | null
activeTabContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onContentChange?: (path: string, content: string) => void
}) {
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, onContentChange } = options
if (!activeTabPath || activeTabContent === null) return null
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent)
rawLatestContentRef.current = syncedContent
onContentChange?.(activeTabPath, syncedContent)
return syncedContent
}
export function rememberPendingRawExitContent(options: {
activeTabPath: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onContentChange?: (path: string, content: string) => void
}) {
const { activeTabPath, rawLatestContentRef, onContentChange } = options
const pendingRawExitContent = buildPendingRawExitContent(activeTabPath, rawLatestContentRef.current)
if (!pendingRawExitContent) return null
onContentChange?.(pendingRawExitContent.path, pendingRawExitContent.content)
return pendingRawExitContent
}
export function resolvePendingRawExitContent(options: {
activeTabPath: string | null
tabs: Tab[]
pendingRawExitContent: PendingRawExitContent | null
}) {
const { activeTabPath, tabs, pendingRawExitContent } = options
if (!pendingRawExitContent) return null
if (activeTabPath !== pendingRawExitContent.path) {
return null
}
const syncedTab = tabs.find((tab) => tab.entry.path === pendingRawExitContent.path)
if (syncedTab?.content === pendingRawExitContent.content) {
return null
}
return pendingRawExitContent
}
export function resolveRawModeContent(options: {
activeTab: Tab | null
rawModeContentOverride: PendingRawExitContent | null
}) {
const { activeTab, rawModeContentOverride } = options
if (!activeTab) return null
if (rawModeContentOverride?.path === activeTab.entry.path) {
return rawModeContentOverride.content
}
return activeTab.content
}

View File

@@ -0,0 +1,134 @@
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 currentSelection = window.getSelection()
if (
!currentSelection ||
selectionFallsOutsideEditor(currentSelection, root)
) {
return serializeInlineNode(root).length
}
const range = currentSelection.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 }
}

View 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 })
}

View 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)
}

View 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
}

View 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, ' ')
}

View File

@@ -0,0 +1,22 @@
import type { useCreateBlockNote } from '@blocknote/react'
export function clearTableResizeState(editor: ReturnType<typeof useCreateBlockNote>) {
const view = editor._tiptapEditor?.view
if (!view || view.isDestroyed) return
const resizePluginKey = view.state.plugins.find((plugin: { key?: unknown }) => (
typeof plugin.key === 'string' && plugin.key.startsWith('tableColumnResizing')
))?.key
if (!resizePluginKey) return
try {
view.dispatch(
view.state.tr.setMeta(resizePluginKey, {
setHandle: -1,
setDragging: null,
}),
)
} catch (error) {
console.warn('Failed to clear table resize state before raw mode toggle:', error)
}
}

View 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 }
}

View 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,
}
}

View 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])
}

View 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])
}

View 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,
}
}

View 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,
}
}

View 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])
}

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
import {
APP_COMMAND_EVENT_NAME,
executeAppCommand,
@@ -8,18 +7,6 @@ import {
type AppCommandHandlers,
} from './appCommandDispatcher'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}
export interface MenuEventHandlers extends AppCommandHandlers {
activeTabPath: string | null
modifiedCount?: number

View File

@@ -14,18 +14,6 @@ import {
type AppCommandShortcutEventOptions,
} from './hooks/appCommandCatalog'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
// at native level before React's synthetic events can call preventDefault).
// Capture phase fires first → prevents native menu; React bubble phase still fires

View File

@@ -0,0 +1,21 @@
import type {
AppCommandShortcutEventInit,
AppCommandShortcutEventOptions,
} from '../hooks/appCommandCatalog'
export interface LaputaTestBridge {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
seedBlockNoteTable?: (columnWidths?: Array<number | null>) => Promise<void> | void
}
declare global {
interface Window {
__laputaTest?: LaputaTestBridge
}
}
export {}

View 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))
}

View File

@@ -21,7 +21,7 @@ test.describe('AI chat empty body fix — no regression', () => {
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
// Send a message
const input = page.locator('input[placeholder*="Ask"]')
const input = page.getByTestId('agent-input')
await expect(input).toBeVisible()
await input.fill('What does this note contain?')
await page.getByTestId('agent-send').click()

View File

@@ -1,6 +1,6 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
let tempVaultDir: string
@@ -24,46 +24,15 @@ function trackUnexpectedErrors(page: Page): string[] {
async function createUntitledNote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}), {
timeout: 5_000,
}).toBe(true)
}
async function insertTable(page: Page): Promise<void> {
const editorSurface = page.locator('.bn-editor')
await expect(editorSurface).toBeVisible({ timeout: 5_000 })
await editorSurface.click()
await page.keyboard.type('/table')
const tableCommand = page.getByText('Table with editable cells', { exact: true })
await expect(tableCommand).toBeVisible({ timeout: 5_000 })
await tableCommand.click()
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
}
async function dragFirstColumn(page: Page, deltaX: number): Promise<void> {
const firstCell = page.locator('table td, table th').first()
await expect(firstCell).toBeVisible({ timeout: 5_000 })
const box = await firstCell.boundingBox()
if (!box) throw new Error('Could not measure first table cell')
const handleX = box.x + box.width - 1
const handleY = box.y + (box.height / 2)
await page.mouse.move(handleX, handleY)
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
await page.mouse.down()
await page.mouse.move(handleX + deltaX, handleY, { steps: 8 })
await page.mouse.up()
async function seedResizedTable(page: Page): Promise<void> {
await seedBlockNoteTable(page, [180, 120, 120])
}
test.describe('table resize crash fix', () => {
test.beforeEach((_, testInfo) => {
test.beforeEach(({ page }, testInfo) => {
void page
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
})
@@ -72,50 +41,20 @@ test.describe('table resize crash fix', () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('resizing a table column keeps the editor stable and changes the cell width', async ({ page }) => {
test('switching to raw mode and back after a seeded resized table does not lose the table', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await insertTable(page)
await seedResizedTable(page)
const firstCell = page.locator('table td, table th').first()
const before = await firstCell.boundingBox()
if (!before) throw new Error('Could not measure first table cell before resize')
await dragFirstColumn(page, 80)
const after = await firstCell.boundingBox()
if (!after) throw new Error('Could not measure first table cell after resize')
expect(after.width).toBeGreaterThan(before.width)
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
test('unmounting the editor mid-resize does not surface stale ProseMirror view errors', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await insertTable(page)
const firstCell = page.locator('table td, table th').first()
await expect(firstCell).toBeVisible({ timeout: 5_000 })
const box = await firstCell.boundingBox()
if (!box) throw new Error('Could not measure first table cell before resize')
const handleX = box.x + box.width - 1
const handleY = box.y + (box.height / 2)
await page.mouse.move(handleX, handleY)
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
await page.mouse.down()
await page.keyboard.press('Meta+Backslash')
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await page.mouse.move(handleX + 80, handleY, { steps: 8 })
await page.mouse.up()
await expect(page.locator('.cm-content')).toContainText('| Head 1 | Head 2 | Head 3 |')
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
expect(errors).toEqual([])
})

View File

@@ -38,6 +38,19 @@ export async function triggerMenuCommand(page: Page, id: string): Promise<void>
}, id)
}
export async function seedBlockNoteTable(
page: Page,
columnWidths?: Array<number | null>,
): Promise<void> {
await page.evaluate((widths) => {
const bridge = window.__laputaTest?.seedBlockNoteTable
if (typeof bridge !== 'function') {
throw new Error('Tolaria test bridge is missing seedBlockNoteTable')
}
return bridge(widths ?? undefined)
}, columnWidths)
}
export async function dispatchShortcutEvent(
page: Page,
init: AppCommandShortcutEventInit,