From cfb047cb228e0f558ebf2d65f406fcd07a13f89d Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 4 Mar 2026 19:53:46 +0100 Subject: [PATCH] feat: wikilink pills in message bubbles, noteList context injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 19 +++++++++++- src/components/AiMessage.test.tsx | 41 +++++++++++++++++++++++++ src/components/AiMessage.tsx | 47 +++++++++++++++++++++++++++-- src/components/AiPanel.tsx | 12 +++++--- src/components/Editor.tsx | 7 ++++- src/components/EditorRightPanel.tsx | 6 ++++ src/hooks/useAiAgent.ts | 10 ++++-- src/utils/ai-context.test.ts | 37 +++++++++++++++++++++++ src/utils/ai-context.ts | 20 +++++++++++- 9 files changed, 186 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 99892068..2bc5fe27 100644 --- a/src/App.tsx +++ b/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(() => { + 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} diff --git a/src/components/AiMessage.test.tsx b/src/components/AiMessage.test.tsx index 7137c34d..99e859c0 100644 --- a/src/components/AiMessage.test.tsx +++ b/src/components/AiMessage.test.tsx @@ -102,6 +102,47 @@ describe('AiMessage', () => { expect(screen.queryByTestId('ai-action-card')).toBeNull() }) + it('renders reference pills in user bubble', () => { + render( + , + ) + 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() + expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0) + }) + + it('does not render pills when references array is empty', () => { + render() + expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0) + }) + + it('calls onOpenNote when a reference pill is clicked', () => { + const onOpenNote = vi.fn() + render( + , + ) + fireEvent.click(screen.getByTestId('message-reference-pill')) + expect(onOpenNote).toHaveBeenCalledWith('note/alpha.md') + }) + it('expands and collapses action cards independently', () => { render( 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 ( + + ) +} + +function UserBubble({ content, references, onOpenNote }: { + content: string + references?: NoteReference[] + onOpenNote?: (path: string) => void +}) { return (
+ {references && references.length > 0 && ( +
+ {references.map(ref => ( + + ))} +
+ )} {content}
@@ -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>(new Set()) @@ -155,7 +196,7 @@ export function AiMessage({ userMessage, reasoning, reasoningDone, actions, resp return (
- + {reasoning && ( 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([]) const inputRef = useRef(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]) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 78932c42..203a37ec 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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} diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index 79507196..02bb535c 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -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} />
) diff --git a/src/hooks/useAiAgent.ts b/src/hooks/useAiAgent.ts index 9f182625..e9a274de 100644 --- a/src/hooks/useAiAgent.ts +++ b/src/hooks/useAiAgent.ts @@ -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') diff --git a/src/utils/ai-context.test.ts b/src/utils/ai-context.test.ts index d23cffc5..73e28e9d 100644 --- a/src/utils/ai-context.test.ts +++ b/src/utils/ai-context.test.ts @@ -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', diff --git a/src/utils/ai-context.ts b/src/utils/ai-context.ts index 55caebf3..7b830987 100644 --- a/src/utils/ai-context.ts +++ b/src/utils/ai-context.ts @@ -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 openTabs?: VaultEntry[] + noteList?: NoteListItem[] noteListFilter?: { type: string | null; query: string } entries: VaultEntry[] references?: NoteReference[] @@ -82,9 +90,11 @@ function entryFrontmatter(e: VaultEntry): Record { 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 = { 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 }