From d2cb395f65f5196631f35825c2f74af21a2d9e3d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 25 Feb 2026 13:36:28 +0100 Subject: [PATCH 1/3] design: person-mention wireframes Three UI states for @mention autocomplete: - Autocomplete open with person suggestions - No results state - After selection showing inserted wikilink Co-Authored-By: Claude Opus 4.6 --- design/person-mention.pen | 309 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 design/person-mention.pen diff --git a/design/person-mention.pen b/design/person-mention.pen new file mode 100644 index 00000000..c29207b0 --- /dev/null +++ b/design/person-mention.pen @@ -0,0 +1,309 @@ +{ + "children": [ + { + "type": "frame", + "id": "pm-auto-open", + "x": 0, + "y": 0, + "name": "Person Mention — Autocomplete Open", + "clip": true, + "width": 600, + "height": 340, + "fill": "$--background", + "layout": "vertical", + "gap": 0, + "children": [ + { + "type": "text", + "id": "pm-desc1", + "content": "User types @ in the editor. Autocomplete popup appears filtered to Person entries only.", + "fontSize": 11, + "color": "$--muted-foreground", + "width": "fill_container", + "padding": [8, 12] + }, + { + "type": "frame", + "id": "pm-editor-area1", + "name": "Editor Area", + "width": "fill_container", + "height": "fill_container", + "fill": "$--background", + "padding": [24, 40], + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "pm-line1", + "content": "Meeting notes from today. Discussed with @mat", + "fontSize": 15, + "color": "$--foreground" + }, + { + "type": "frame", + "id": "pm-popup1", + "name": "Mention Popup", + "width": 280, + "fill": "$--popover", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "cornerRadius": 6, + "layout": "vertical", + "padding": [4, 4], + "gap": 0, + "children": [ + { + "type": "frame", + "id": "pm-item1", + "name": "Selected Item", + "width": "fill_container", + "fill": "$--accent", + "cornerRadius": 4, + "padding": [6, 10], + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pm-item1-icon", + "content": "@", + "fontSize": 13, + "fontWeight": "600", + "color": "$--accent-blue" + }, + { + "type": "text", + "id": "pm-item1-title", + "content": "Matteo Cellini", + "fontSize": 13, + "color": "$--foreground" + }, + { + "type": "text", + "id": "pm-item1-type", + "content": "Person", + "fontSize": 11, + "fontWeight": "500", + "color": "$--accent-blue" + } + ] + }, + { + "type": "frame", + "id": "pm-item2", + "name": "Unselected Item", + "width": "fill_container", + "cornerRadius": 4, + "padding": [6, 10], + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pm-item2-icon", + "content": "@", + "fontSize": 13, + "fontWeight": "600", + "color": "$--muted-foreground" + }, + { + "type": "text", + "id": "pm-item2-title", + "content": "Maria Bianchi", + "fontSize": 13, + "color": "$--foreground" + }, + { + "type": "text", + "id": "pm-item2-type", + "content": "Person", + "fontSize": 11, + "fontWeight": "500", + "color": "$--accent-blue" + } + ] + }, + { + "type": "frame", + "id": "pm-item3", + "name": "Unselected Item 2", + "width": "fill_container", + "cornerRadius": 4, + "padding": [6, 10], + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pm-item3-icon", + "content": "@", + "fontSize": 13, + "fontWeight": "600", + "color": "$--muted-foreground" + }, + { + "type": "text", + "id": "pm-item3-title", + "content": "Marco Verdi", + "fontSize": 13, + "color": "$--foreground" + }, + { + "type": "text", + "id": "pm-item3-type", + "content": "Person", + "fontSize": 11, + "fontWeight": "500", + "color": "$--accent-blue" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pm-no-results", + "x": 620, + "y": 0, + "name": "Person Mention — No Results", + "clip": true, + "width": 600, + "height": 220, + "fill": "$--background", + "layout": "vertical", + "gap": 0, + "children": [ + { + "type": "text", + "id": "pm-desc2", + "content": "User types @xyz — no matching Person entries found.", + "fontSize": 11, + "color": "$--muted-foreground", + "width": "fill_container", + "padding": [8, 12] + }, + { + "type": "frame", + "id": "pm-editor-area2", + "name": "Editor Area", + "width": "fill_container", + "height": "fill_container", + "fill": "$--background", + "padding": [24, 40], + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "pm-line2", + "content": "Need to follow up with @xyz", + "fontSize": 15, + "color": "$--foreground" + }, + { + "type": "frame", + "id": "pm-popup2", + "name": "Empty Popup", + "width": 280, + "fill": "$--popover", + "stroke": { "align": "inside", "thickness": 1, "fill": "$--border" }, + "cornerRadius": 6, + "padding": [8, 12], + "children": [ + { + "type": "text", + "id": "pm-empty-text", + "content": "No results", + "fontSize": 13, + "color": "$--muted-foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pm-inserted", + "x": 0, + "y": 360, + "name": "Person Mention — After Selection (Wikilink Inserted)", + "clip": true, + "width": 600, + "height": 180, + "fill": "$--background", + "layout": "vertical", + "gap": 0, + "children": [ + { + "type": "text", + "id": "pm-desc3", + "content": "After selecting a person, a wikilink [[Person Name]] is inserted. Renders as clickable link.", + "fontSize": 11, + "color": "$--muted-foreground", + "width": "fill_container", + "padding": [8, 12] + }, + { + "type": "frame", + "id": "pm-editor-area3", + "name": "Editor Area", + "width": "fill_container", + "height": "fill_container", + "fill": "$--background", + "padding": [24, 40], + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "pm-line3-wrap", + "name": "Text Line", + "width": "fill_container", + "gap": 0, + "alignItems": "baseline", + "children": [ + { + "type": "text", + "id": "pm-line3a", + "content": "Meeting notes from today. Discussed with ", + "fontSize": 15, + "color": "$--foreground" + }, + { + "type": "text", + "id": "pm-wikilink", + "content": "Matteo Cellini", + "fontSize": 15, + "fontWeight": "500", + "color": "$--accent-blue" + }, + { + "type": "text", + "id": "pm-line3b", + "content": " about the sponsorship deal.", + "fontSize": 15, + "color": "$--foreground" + } + ] + }, + { + "type": "text", + "id": "pm-line3-note", + "content": "Stored in markdown as: ...Discussed with [[Matteo Cellini]] about...", + "fontSize": 11, + "color": "$--muted-foreground", + "padding": [8, 0, 0, 0] + } + ] + } + ] + } + ], + "variables": {} +} From f047f8e3c9fdf64cc6d3138de98f4207f32e8e6e Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 25 Feb 2026 13:39:10 +0100 Subject: [PATCH 2/3] feat: add @mention autocomplete for Person entries - Type @ in editor to trigger person-specific autocomplete - Filters vault entries to show only Person type notes - Inserts standard [[Person Name]] wikilink on selection - Lower query threshold (1 char vs 2) since person list is smaller - Add 3 more Person entries to mock data for testing - Reuses existing WikilinkSuggestionMenu component Co-Authored-By: Claude Opus 4.6 --- src/components/Editor.test.tsx | 8 ++- src/components/Editor.tsx | 32 ++++++++++++ src/mock-tauri/mock-content.ts | 43 +++++++++++++++ src/mock-tauri/mock-entries.ts | 75 +++++++++++++++++++++++++++ src/utils/personMentionSuggestions.ts | 22 ++++++++ 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/utils/personMentionSuggestions.ts diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 996409fd..d0fe66a5 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -28,13 +28,19 @@ vi.mock('@blocknote/core/extensions', () => ({ filterSuggestionItems: (...args: unknown[]) => mockFilterSuggestionItems(...args), })) +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock +const capturedGetItemsByTrigger: Record Promise> = {} // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock let capturedGetItems: ((query: string) => Promise) | null = null vi.mock('@blocknote/react', () => ({ createReactInlineContentSpec: () => ({ render: () => null }), useCreateBlockNote: () => mockEditor, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock - SuggestionMenuController: (props: any) => { capturedGetItems = props.getItems; return null }, + SuggestionMenuController: (props: any) => { + capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems + if (props.triggerCharacter === '[[') capturedGetItems = props.getItems + return null + }, })) vi.mock('@blocknote/mantine', () => ({ diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 1c70f872..366d2124 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -15,6 +15,7 @@ import { BreadcrumbBar } from './BreadcrumbBar' import { useEditorTheme } from '../hooks/useTheme' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks' import { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' +import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions' import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors' import { getTypeColor } from '../utils/typeColors' import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu' @@ -169,6 +170,31 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e })) }, [baseItems, editor]) + const getPersonMentionItems = useCallback(async (query: string): Promise => { + if (query.length < PERSON_MENTION_MIN_QUERY) return [] + + const candidates = filterPersonMentions(baseItems, query) + const items = candidates.map(item => ({ + ...item, + onItemClick: () => { + editor.insertInlineContent([ + { + type: 'wikilink' as const, + props: { target: item.entryTitle }, + }, + " ", + ]) + }, + })) + const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS) + const final = disambiguateTitles(deduplicateByPath(filtered)) + return final.map(({ group, ...rest }) => ({ + ...rest, + noteType: group, + typeColor: getTypeColor(group), + })) + }, [baseItems, editor]) + return (
{isDragOver && ( @@ -187,6 +213,12 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e suggestionMenuComponent={WikilinkSuggestionMenu} onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()} /> + item.onItemClick()} + />
) diff --git a/src/mock-tauri/mock-content.ts b/src/mock-tauri/mock-content.ts index 02fe0dd3..24e4a038 100644 --- a/src/mock-tauri/mock-content.ts +++ b/src/mock-tauri/mock-content.ts @@ -432,6 +432,49 @@ Belongs to: # AI Agents Primer AI agents are autonomous systems that can plan, execute, and adapt to achieve goals. +`, + '/Users/luca/Laputa/person/maria-bianchi.md': `--- +title: Maria Bianchi +type: Person +aliases: + - Maria +--- + +# Maria Bianchi + +## Role +Product designer — leads UX research and design sprints for the app. + +## Contact +- Email: maria@example.com +- Slack: @maria +`, + '/Users/luca/Laputa/person/marco-verdi.md': `--- +title: Marco Verdi +type: Person +aliases: + - Marco +--- + +# Marco Verdi + +## Role +Frontend engineer — focuses on React performance and accessibility. + +## Contact +- Email: marco@example.com +`, + '/Users/luca/Laputa/person/elena-russo.md': `--- +title: Elena Russo +type: Person +aliases: + - Elena +--- + +# Elena Russo + +## Role +Content strategist — plans newsletter topics and manages the editorial calendar. `, '/Users/luca/Laputa/type/project.md': `--- type: Type diff --git a/src/mock-tauri/mock-entries.ts b/src/mock-tauri/mock-entries.ts index 5a07f134..4ad72f0a 100644 --- a/src/mock-tauri/mock-entries.ts +++ b/src/mock-tauri/mock-entries.ts @@ -250,6 +250,81 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, }, + { + path: '/Users/luca/Laputa/person/maria-bianchi.md', + filename: 'maria-bianchi.md', + title: 'Maria Bianchi', + isA: 'Person', + aliases: ['Maria'], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: now - 86400 * 3, + createdAt: now - 86400 * 150, + fileSize: 280, + snippet: 'Product designer — leads UX research and design sprints for the app.', + relationships: { + 'Type': ['[[type/person]]'], + }, + icon: null, + color: null, + order: null, + }, + { + path: '/Users/luca/Laputa/person/marco-verdi.md', + filename: 'marco-verdi.md', + title: 'Marco Verdi', + isA: 'Person', + aliases: ['Marco'], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: now - 86400 * 5, + createdAt: now - 86400 * 120, + fileSize: 240, + snippet: 'Frontend engineer — focuses on React performance and accessibility.', + relationships: { + 'Type': ['[[type/person]]'], + }, + icon: null, + color: null, + order: null, + }, + { + path: '/Users/luca/Laputa/person/elena-russo.md', + filename: 'elena-russo.md', + title: 'Elena Russo', + isA: 'Person', + aliases: ['Elena'], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: now - 86400 * 10, + createdAt: now - 86400 * 90, + fileSize: 200, + snippet: 'Content strategist — plans newsletter topics and manages the editorial calendar.', + relationships: { + 'Type': ['[[type/person]]'], + }, + icon: null, + color: null, + order: null, + }, { path: '/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md', filename: '2026-02-14-laputa-app-kickoff.md', diff --git a/src/utils/personMentionSuggestions.ts b/src/utils/personMentionSuggestions.ts new file mode 100644 index 00000000..4b2abd60 --- /dev/null +++ b/src/utils/personMentionSuggestions.ts @@ -0,0 +1,22 @@ +import type { WikilinkBaseItem } from './wikilinkSuggestions' + +/** Person mentions need only 1 character to start suggesting (smaller candidate set). */ +export const PERSON_MENTION_MIN_QUERY = 1 + +/** + * Pre-filter person mention candidates: only items whose group is 'Person', + * matched by case-insensitive substring on title or aliases. + */ +export function filterPersonMentions( + items: T[], + query: string, +): T[] { + if (query.length < PERSON_MENTION_MIN_QUERY) return [] + const lowerQuery = query.toLowerCase() + return items.filter(item => + item.group === 'Person' && ( + item.title.toLowerCase().includes(lowerQuery) || + item.aliases.some(a => a.toLowerCase().includes(lowerQuery)) + ) + ) +} From af2932c918db1ddafe6855cae21dfad73d873236 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 25 Feb 2026 13:40:18 +0100 Subject: [PATCH 3/3] test: add tests for @mention autocomplete - 8 unit tests for filterPersonMentions utility - 6 integration tests for @ trigger in Editor component - Verifies Person-only filtering, wikilink insertion, type badges Co-Authored-By: Claude Opus 4.6 --- src/components/Editor.test.tsx | 80 ++++++++++++++++++++++ src/utils/personMentionSuggestions.test.ts | 60 ++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/utils/personMentionSuggestions.test.ts diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index d0fe66a5..1d58acb5 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -413,3 +413,83 @@ describe('wikilink autocomplete', () => { mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) }) }) + +describe('person @mention autocomplete', () => { + const personEntry: VaultEntry = { + ...mockEntry, + title: 'Matteo Cellini', + filename: 'matteo-cellini.md', + path: '/vault/person/matteo-cellini.md', + isA: 'Person', + aliases: ['Matteo'], + } + const nonPersonEntry: VaultEntry = { + ...mockEntry, + title: 'Build Laputa App', + filename: 'laputa-app.md', + path: '/vault/project/laputa-app.md', + isA: 'Project', + aliases: [], + } + const entries = [personEntry, nonPersonEntry] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock + let getPersonItems: ((query: string) => Promise) | null = null + + function renderForMention() { + mockFilterSuggestionItems.mockClear() + mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) + render( + + ) + getPersonItems = capturedGetItemsByTrigger['@'] ?? null + } + + it('registers a SuggestionMenuController with @ trigger', () => { + renderForMention() + expect(getPersonItems).toBeTruthy() + }) + + it('returns only Person entries for matching query', async () => { + renderForMention() + const items = await getPersonItems!('Mat') + expect(items.length).toBe(1) + expect(items[0].title).toBe('Matteo Cellini') + }) + + it('excludes non-Person entries', async () => { + renderForMention() + const items = await getPersonItems!('Lap') + expect(items).toHaveLength(0) + }) + + it('works with single-character query', async () => { + renderForMention() + const items = await getPersonItems!('M') + expect(items.length).toBeGreaterThan(0) + }) + + it('inserts a wikilink when person item is clicked', async () => { + renderForMention() + mockEditor.insertInlineContent.mockClear() + const items = await getPersonItems!('Matteo') + expect(items.length).toBeGreaterThan(0) + items[0].onItemClick() + expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([ + { type: 'wikilink', props: { target: 'Matteo Cellini' } }, + ' ', + ]) + }) + + it('shows Person type badge on results', async () => { + renderForMention() + const items = await getPersonItems!('Matteo') + expect(items[0].noteType).toBe('Person') + expect(items[0].typeColor).toBeTruthy() + }) +}) diff --git a/src/utils/personMentionSuggestions.test.ts b/src/utils/personMentionSuggestions.test.ts new file mode 100644 index 00000000..7e189aa0 --- /dev/null +++ b/src/utils/personMentionSuggestions.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from './personMentionSuggestions' +import type { WikilinkBaseItem } from './wikilinkSuggestions' + +const items: WikilinkBaseItem[] = [ + { title: 'Matteo Cellini', aliases: ['Matteo'], group: 'Person', entryTitle: 'Matteo Cellini', path: '/person/matteo.md' }, + { title: 'Maria Bianchi', aliases: ['Maria'], group: 'Person', entryTitle: 'Maria Bianchi', path: '/person/maria.md' }, + { title: 'Build Laputa App', aliases: ['Laputa'], group: 'Project', entryTitle: 'Build Laputa App', path: '/project/laputa.md' }, + { title: 'Grow Newsletter', aliases: [], group: 'Responsibility', entryTitle: 'Grow Newsletter', path: '/resp/newsletter.md' }, + { title: 'Elena Russo', aliases: ['Elena'], group: 'Person', entryTitle: 'Elena Russo', path: '/person/elena.md' }, +] + +describe('filterPersonMentions', () => { + it('returns only Person entries matching query on title', () => { + const result = filterPersonMentions(items, 'mat') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Matteo Cellini') + }) + + it('matches on aliases', () => { + const result = filterPersonMentions(items, 'Elena') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Elena Russo') + }) + + it('excludes non-Person entries even if they match the query', () => { + const result = filterPersonMentions(items, 'Lap') + expect(result).toHaveLength(0) + }) + + it('is case-insensitive', () => { + const result = filterPersonMentions(items, 'MARIA') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Maria Bianchi') + }) + + it('returns multiple matches', () => { + const result = filterPersonMentions(items, 'ma') + expect(result).toHaveLength(2) + const titles = result.map(r => r.title) + expect(titles).toContain('Matteo Cellini') + expect(titles).toContain('Maria Bianchi') + }) + + it('returns empty for query shorter than minimum', () => { + const result = filterPersonMentions(items, '') + expect(result).toHaveLength(0) + }) + + it('works with single-character query (min query is 1)', () => { + expect(PERSON_MENTION_MIN_QUERY).toBe(1) + const result = filterPersonMentions(items, 'e') + expect(result).toHaveLength(2) // Matteo (alias) + Elena + }) + + it('returns empty when no persons match', () => { + const result = filterPersonMentions(items, 'zzz') + expect(result).toHaveLength(0) + }) +})