feat: wikilink pills in message bubbles, noteList context injection

- Render [[wikilink]] reference pills inside sent message bubbles
  with type-colored badges; clicking a pill opens the note
- Add noteList (filtered note list titles, max 100) and noteListFilter
  to the structured context snapshot sent to the AI
- Thread noteList/noteListFilter from App → Editor → EditorRightPanel → AiPanel
- Store references in AiAgentMessage for display in chat history
- Add tests for reference pill rendering and noteList context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-04 19:53:46 +01:00
parent 55ff9e6f5d
commit cfb047cb22
9 changed files with 186 additions and 13 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import { Editor } from './components/Editor'
@@ -41,6 +41,8 @@ import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries } from './utils/noteListHelpers'
import './App.css'
// Type declaration for mock content storage
@@ -390,6 +392,19 @@ function App() {
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
const aiNoteList = useMemo<NoteListItem[]>(() => {
return filterEntries(vault.entries, selection).map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note',
}))
}, [vault.entries, selection])
const aiNoteListFilter = useMemo(() => {
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
if (selection.kind === 'topic') return { type: null, query: selection.entry.title }
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
return { type: null, query: '' }
}, [selection])
// Show welcome/onboarding screen when vault doesn't exist
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
const defaultPath = onboarding.state.defaultPath
@@ -465,6 +480,8 @@ function App() {
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}

View File

@@ -102,6 +102,47 @@ describe('AiMessage', () => {
expect(screen.queryByTestId('ai-action-card')).toBeNull()
})
it('renders reference pills in user bubble', () => {
render(
<AiMessage
userMessage="Tell me about this"
references={[
{ title: 'Marco', path: 'person/marco.md', type: 'Person' },
{ title: 'Project X', path: 'project/x.md', type: 'Project' },
]}
actions={[]}
/>,
)
const pills = screen.getAllByTestId('message-reference-pill')
expect(pills).toHaveLength(2)
expect(pills[0].textContent).toBe('Marco')
expect(pills[1].textContent).toBe('Project X')
})
it('does not render pills when no references', () => {
render(<AiMessage userMessage="Hello" actions={[]} />)
expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0)
})
it('does not render pills when references array is empty', () => {
render(<AiMessage userMessage="Hello" references={[]} actions={[]} />)
expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0)
})
it('calls onOpenNote when a reference pill is clicked', () => {
const onOpenNote = vi.fn()
render(
<AiMessage
userMessage="Check this"
references={[{ title: 'Alpha', path: 'note/alpha.md', type: 'Note' }]}
actions={[]}
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('message-reference-pill'))
expect(onOpenNote).toHaveBeenCalledWith('note/alpha.md')
})
it('expands and collapses action cards independently', () => {
render(
<AiMessage

View File

@@ -2,6 +2,8 @@ import { useState, useCallback, useEffect, useRef } from 'react'
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
import { AiActionCard, type AiActionStatus } from './AiActionCard'
import { MarkdownContent } from './MarkdownContent'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
export interface AiAction {
tool: string
@@ -15,6 +17,7 @@ export interface AiAction {
export interface AiMessageProps {
userMessage: string
references?: NoteReference[]
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
@@ -23,7 +26,38 @@ export interface AiMessageProps {
onOpenNote?: (path: string) => void
}
function UserBubble({ content }: { content: string }) {
function ReferencePill({ reference, onClick }: {
reference: NoteReference
onClick?: (path: string) => void
}) {
const color = getTypeColor(reference.type)
const lightColor = getTypeLightColor(reference.type)
return (
<button
className="inline-flex items-center border-none cursor-pointer transition-opacity hover:opacity-80"
style={{
background: lightColor,
color,
borderRadius: 9999,
padding: '1px 8px',
fontSize: 11,
fontWeight: 500,
fontFamily: 'inherit',
lineHeight: 1.4,
}}
onClick={() => onClick?.(reference.path)}
data-testid="message-reference-pill"
>
{reference.title}
</button>
)
}
function UserBubble({ content, references, onOpenNote }: {
content: string
references?: NoteReference[]
onOpenNote?: (path: string) => void
}) {
return (
<div className="flex justify-end" style={{ marginBottom: 8 }}>
<div
@@ -37,6 +71,13 @@ function UserBubble({ content }: { content: string }) {
lineHeight: 1.5,
}}
>
{references && references.length > 0 && (
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
{references.map(ref => (
<ReferencePill key={ref.path} reference={ref} onClick={onOpenNote} />
))}
</div>
)}
{content}
</div>
</div>
@@ -134,7 +175,7 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
@@ -155,7 +196,7 @@ export function AiMessage({ userMessage, reasoning, reasoningDone, actions, resp
return (
<div data-testid="ai-message" style={{ marginBottom: 16 }}>
<UserBubble content={userMessage} />
<UserBubble content={userMessage} references={references} onOpenNote={onOpenNote} />
{reasoning && (
<ReasoningBlock
text={reasoning}

View File

@@ -3,7 +3,7 @@ import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference } from '../utils/ai-context'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -16,6 +16,8 @@ interface AiPanelProps {
entries?: VaultEntry[]
allContent?: Record<string, string>
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
}
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
@@ -105,7 +107,7 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
)
}
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent, openTabs }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
@@ -122,10 +124,12 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
activeEntry,
allContent,
openTabs,
noteList,
noteListFilter,
entries,
references: pendingRefs.length > 0 ? pendingRefs : undefined,
})
}, [activeEntry, allContent, openTabs, entries, pendingRefs])
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
const agent = useAiAgent(vaultPath, contextPrompt)
const hasContext = !!activeEntry
@@ -159,7 +163,7 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return
setPendingRefs(references)
agent.sendMessage(text)
agent.sendMessage(text, references)
setInput('')
}, [isActive, agent])

View File

@@ -5,6 +5,7 @@ import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
import { uploadImageFile } from '../hooks/useImageDrop'
import type { VaultEntry, GitCommit, NoteStatus } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import type { FrontmatterValue } from './Inspector'
import { ResizeHandle } from './ResizeHandle'
import { TabBar } from './TabBar'
@@ -48,6 +49,8 @@ interface EditorProps {
showAIChat?: boolean
onToggleAIChat?: () => void
vaultPath?: string
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onDeleteNote?: (path: string) => void
@@ -117,7 +120,7 @@ export const Editor = memo(function Editor({
inspectorEntry, inspectorContent, allContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
showAIChat, onToggleAIChat,
vaultPath,
vaultPath, noteList, noteListFilter,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
@@ -229,6 +232,8 @@ export const Editor = memo(function Editor({
gitHistory={gitHistory}
vaultPath={vaultPath ?? ''}
openTabs={tabs.map(t => t.entry)}
noteList={noteList}
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -1,4 +1,5 @@
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import { Inspector, type FrontmatterValue } from './Inspector'
import { AiPanel } from './AiPanel'
@@ -13,6 +14,8 @@ interface EditorRightPanelProps {
gitHistory: GitCommit[]
vaultPath: string
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onNavigateWikilink: (target: string) => void
@@ -26,6 +29,7 @@ interface EditorRightPanelProps {
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath, openTabs,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
}: EditorRightPanelProps) {
@@ -43,6 +47,8 @@ export function EditorRightPanel({
entries={entries}
allContent={allContent}
openTabs={openTabs}
noteList={noteList}
noteListFilter={noteListFilter}
/>
</div>
)

View File

@@ -9,6 +9,7 @@
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { AiAction } from '../components/AiMessage'
import type { NoteReference } from '../utils/ai-context'
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
import { nextMessageId } from '../utils/ai-chat'
@@ -16,6 +17,7 @@ export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'err
export interface AiAgentMessage {
userMessage: string
references?: NoteReference[]
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
@@ -34,12 +36,14 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
contextRef.current = contextPrompt
}, [contextPrompt])
const sendMessage = useCallback(async (text: string) => {
const sendMessage = useCallback(async (text: string, references?: NoteReference[]) => {
if (!text.trim() || status === 'thinking' || status === 'tool-executing') return
const refs = references && references.length > 0 ? references : undefined
if (!vaultPath) {
setMessages(prev => [...prev, {
userMessage: text.trim(), actions: [],
userMessage: text.trim(), references: refs, actions: [],
response: 'No vault loaded. Open a vault first.',
id: nextMessageId(),
}])
@@ -51,7 +55,7 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
const messageId = nextMessageId()
setMessages(prev => [...prev, {
userMessage: text.trim(), actions: [], isStreaming: true, id: messageId,
userMessage: text.trim(), references: refs, actions: [], isStreaming: true, id: messageId,
}])
setStatus('thinking')

View File

@@ -294,6 +294,43 @@ describe('buildContextSnapshot', () => {
expect(json.referencedNotes).toBeUndefined()
})
it('includes noteList when provided', () => {
const noteList = [
{ path: '/vault/a.md', title: 'Alpha', type: 'Project' },
{ path: '/vault/b.md', title: 'Beta', type: 'Person' },
]
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
noteList,
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.noteList).toHaveLength(2)
expect(json.noteList[0].title).toBe('Alpha')
expect(json.noteList[1].type).toBe('Person')
})
it('truncates noteList at 100 items', () => {
const noteList = Array.from({ length: 150 }, (_, i) => ({
path: `/vault/note-${i}.md`, title: `Note ${i}`, type: 'Note',
}))
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
noteList,
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.noteList).toHaveLength(100)
expect(json.noteListTruncated).toEqual({ shown: 100, total: 150 })
})
it('omits noteList when empty', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
noteList: [],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.noteList).toBeUndefined()
})
it('includes belongsTo and relatedTo in frontmatter', () => {
const entryWithRels = makeEntry({
path: '/vault/a.md', title: 'Alpha',

View File

@@ -61,11 +61,19 @@ export interface NoteReference {
type: string | null
}
/** Lightweight note summary for the context snapshot. */
export interface NoteListItem {
path: string
title: string
type: string
}
/** Parameters for building the structured context snapshot. */
export interface ContextSnapshotParams {
activeEntry: VaultEntry
allContent: Record<string, string>
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
entries: VaultEntry[]
references?: NoteReference[]
@@ -82,9 +90,11 @@ function entryFrontmatter(e: VaultEntry): Record<string, unknown> {
return fm
}
const MAX_NOTE_LIST_ITEMS = 100
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const { activeEntry, allContent, openTabs, noteListFilter, entries, references } = params
const { activeEntry, allContent, openTabs, noteList, noteListFilter, entries, references } = params
const snapshot: Record<string, unknown> = {
activeNote: {
@@ -106,6 +116,14 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
}))
}
if (noteList && noteList.length > 0) {
const items = noteList.slice(0, MAX_NOTE_LIST_ITEMS)
snapshot.noteList = items
if (noteList.length > MAX_NOTE_LIST_ITEMS) {
snapshot.noteListTruncated = { shown: MAX_NOTE_LIST_ITEMS, total: noteList.length }
}
}
if (noteListFilter && (noteListFilter.type || noteListFilter.query)) {
snapshot.noteListFilter = noteListFilter
}