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:
19
src/App.tsx
19
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user