Merge pull request #66 from refactoringhq/task/person-mention

feat: person mention — @mention autocomplete linking to Person entries
This commit is contained in:
Luca Rossi
2026-02-25 16:39:21 +01:00
committed by GitHub
7 changed files with 628 additions and 1 deletions

309
design/person-mention.pen Normal file
View File

@@ -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": {}
}

View File

@@ -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<string, (query: string) => Promise<any[]>> = {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
let capturedGetItems: ((query: string) => Promise<any[]>) | 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', () => ({
@@ -407,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<any[]>) | null = null
function renderForMention() {
mockFilterSuggestionItems.mockClear()
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
render(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
entries={entries}
/>
)
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()
})
})

View File

@@ -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<WikilinkSuggestionItem[]> => {
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 (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties}>
{isDragOver && (
@@ -187,6 +213,12 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={getPersonMentionItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
/>
</BlockNoteView>
</div>
)

View File

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

View File

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

View File

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

View File

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