Merge pull request #162 from builderz-labs/feat/156-task-mentions

feat: add validated @mentions for task descriptions and comments
This commit is contained in:
nyk 2026-03-04 23:56:22 +07:00 committed by GitHub
commit e8623ac7f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 530 additions and 23 deletions

View File

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth'
import { getDatabase } from '@/lib/db'
import { getMentionTargets } from '@/lib/mentions'
import { logger } from '@/lib/logger'
/**
* GET /api/mentions - autocomplete source for @mentions (users + agents)
* Query: q?, limit?, type?
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const { searchParams } = new URL(request.url)
const q = String(searchParams.get('q') || '').trim().toLowerCase()
const typeFilter = String(searchParams.get('type') || '').trim().toLowerCase()
const limitRaw = Number.parseInt(searchParams.get('limit') || '25', 10)
const limit = Math.max(1, Math.min(Number.isFinite(limitRaw) ? limitRaw : 25, 200))
let targets = getMentionTargets(db, workspaceId)
if (typeFilter === 'user' || typeFilter === 'agent') {
targets = targets.filter((target) => target.type === typeFilter)
}
if (q) {
targets = targets.filter((target) => (
target.handle.includes(q) ||
target.recipient.toLowerCase().includes(q) ||
target.display.toLowerCase().includes(q)
))
}
targets = targets.slice(0, limit)
return NextResponse.json({
mentions: targets,
total: targets.length,
q,
})
} catch (error) {
logger.error({ err: error }, 'GET /api/mentions error')
return NextResponse.json({ error: 'Failed to fetch mention targets' }, { status: 500 })
}
}

View File

@ -4,6 +4,7 @@ import { requireRole } from '@/lib/auth';
import { validateBody, createCommentSchema } from '@/lib/validation';
import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { resolveMentionRecipients } from '@/lib/mentions';
/**
* GET /api/tasks/[id]/comments - Get all comments for a task
@ -128,8 +129,13 @@ export async function POST(
}
}
// Parse @mentions from content
const mentions = db_helpers.parseMentions(content);
const mentionResolution = resolveMentionRecipients(content, db, workspaceId);
if (mentionResolution.unresolved.length > 0) {
return NextResponse.json({
error: `Unknown mentions: ${mentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`,
missing_mentions: mentionResolution.unresolved
}, { status: 400 });
}
const now = Math.floor(Date.now() / 1000);
@ -145,7 +151,7 @@ export async function POST(
content,
now,
parent_id || null,
mentions.length > 0 ? JSON.stringify(mentions) : null,
mentionResolution.tokens.length > 0 ? JSON.stringify(mentionResolution.tokens) : null,
workspaceId
);
@ -166,7 +172,7 @@ export async function POST(
task_id: taskId,
task_title: task.title,
parent_id,
mentions,
mentions: mentionResolution.tokens,
content_preview: content.substring(0, 100)
},
workspaceId
@ -174,9 +180,9 @@ export async function POST(
// Ensure subscriptions for author, mentions, and assignee
db_helpers.ensureTaskSubscription(taskId, author, workspaceId);
const uniqueMentions = Array.from(new Set(mentions));
uniqueMentions.forEach((mentionedAgent) => {
db_helpers.ensureTaskSubscription(taskId, mentionedAgent, workspaceId);
const mentionRecipients = mentionResolution.recipients;
mentionRecipients.forEach((mentionedRecipient) => {
db_helpers.ensureTaskSubscription(taskId, mentionedRecipient, workspaceId);
});
if (task.assigned_to) {
db_helpers.ensureTaskSubscription(taskId, task.assigned_to, workspaceId);
@ -185,7 +191,7 @@ export async function POST(
// Notify subscribers
const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId));
subscribers.delete(author);
const mentionSet = new Set(uniqueMentions);
const mentionSet = new Set(mentionRecipients);
for (const subscriber of subscribers) {
const isMention = mentionSet.has(subscriber);

View File

@ -5,6 +5,7 @@ import { requireRole } from '@/lib/auth';
import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, updateTaskSchema } from '@/lib/validation';
import { resolveMentionRecipients } from '@/lib/mentions';
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@ -126,6 +127,17 @@ export async function PUT(
} = body;
const now = Math.floor(Date.now() / 1000);
const descriptionMentionResolution = description !== undefined
? resolveMentionRecipients(description || '', db, workspaceId)
: null;
if (descriptionMentionResolution && descriptionMentionResolution.unresolved.length > 0) {
return NextResponse.json({
error: `Unknown mentions: ${descriptionMentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`,
missing_mentions: descriptionMentionResolution.unresolved
}, { status: 400 });
}
const previousDescriptionMentionRecipients = resolveMentionRecipients(currentTask.description || '', db, workspaceId).recipients;
// Build dynamic update query
const fieldsToUpdate = [];
@ -274,6 +286,25 @@ export async function PUT(
if (project_id !== undefined && project_id !== currentTask.project_id) {
changes.push(`project: ${currentTask.project_id || 'none'}${project_id}`);
}
if (descriptionMentionResolution) {
const newMentionRecipients = new Set(descriptionMentionResolution.recipients);
const previousRecipients = new Set(previousDescriptionMentionRecipients);
for (const recipient of newMentionRecipients) {
if (previousRecipients.has(recipient)) continue;
db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId);
if (recipient === auth.user.username) continue;
db_helpers.createNotification(
recipient,
'mention',
'You were mentioned in a task description',
`${auth.user.username} mentioned you in task "${title || currentTask.title}"`,
'task',
taskId,
workspaceId
);
}
}
// Log activity if there were meaningful changes
if (changes.length > 0) {

View File

@ -5,6 +5,7 @@ import { requireRole } from '@/lib/auth';
import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
import { resolveMentionRecipients } from '@/lib/mentions';
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@ -180,6 +181,14 @@ export async function POST(request: NextRequest) {
}
const now = Math.floor(Date.now() / 1000);
const mentionResolution = resolveMentionRecipients(description || '', db, workspaceId);
if (mentionResolution.unresolved.length > 0) {
return NextResponse.json({
error: `Unknown mentions: ${mentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`,
missing_mentions: mentionResolution.unresolved
}, { status: 400 });
}
const createTaskTx = db.transaction(() => {
const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
db.prepare(`
@ -234,6 +243,20 @@ export async function POST(request: NextRequest) {
db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId)
}
for (const recipient of mentionResolution.recipients) {
db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId);
if (recipient === created_by) continue;
db_helpers.createNotification(
recipient,
'mention',
'You were mentioned in a task description',
`${created_by} mentioned you in task "${title}"`,
'task',
taskId,
workspaceId
);
}
// Create notification if assigned
if (assigned_to) {
db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId)

View File

@ -69,6 +69,14 @@ interface Project {
status: 'active' | 'archived'
}
interface MentionOption {
handle: string
recipient: string
type: 'user' | 'agent'
display: string
role?: string
}
const statusColumns = [
{ key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
{ key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' },
@ -85,6 +93,155 @@ const priorityColors: Record<string, string> = {
critical: 'border-red-500',
}
function useMentionTargets() {
const [mentionTargets, setMentionTargets] = useState<MentionOption[]>([])
useEffect(() => {
let cancelled = false
const run = async () => {
try {
const response = await fetch('/api/mentions?limit=200')
if (!response.ok) return
const data = await response.json()
if (!cancelled) setMentionTargets(data.mentions || [])
} catch {
// mention autocomplete is non-critical
}
}
run()
return () => { cancelled = true }
}, [])
return mentionTargets
}
function MentionTextarea({
id,
value,
onChange,
rows = 3,
placeholder,
className,
mentionTargets,
}: {
id?: string
value: string
onChange: (next: string) => void
rows?: number
placeholder?: string
className?: string
mentionTargets: MentionOption[]
}) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const [query, setQuery] = useState('')
const [range, setRange] = useState<{ start: number; end: number } | null>(null)
const filtered = mentionTargets
.filter((target) => {
if (!query) return true
const q = query.toLowerCase()
return target.handle.includes(q) || target.display.toLowerCase().includes(q)
})
.slice(0, 8)
const detectMentionQuery = (nextValue: string, caret: number) => {
const left = nextValue.slice(0, caret)
const match = left.match(/(?:^|[^\w.-])@([A-Za-z0-9._-]{0,63})$/)
if (!match) {
setOpen(false)
setQuery('')
setRange(null)
return
}
const matched = match[1] || ''
const start = caret - matched.length - 1
setQuery(matched)
setRange({ start, end: caret })
setActiveIndex(0)
setOpen(true)
}
const insertMention = (option: MentionOption) => {
if (!range) return
const next = `${value.slice(0, range.start)}@${option.handle} ${value.slice(range.end)}`
onChange(next)
setOpen(false)
setQuery('')
const cursor = range.start + option.handle.length + 2
requestAnimationFrame(() => {
const node = textareaRef.current
if (!node) return
node.focus()
node.setSelectionRange(cursor, cursor)
})
}
return (
<div className="relative">
<textarea
id={id}
ref={textareaRef}
value={value}
onChange={(e) => {
const nextValue = e.target.value
onChange(nextValue)
detectMentionQuery(nextValue, e.target.selectionStart || 0)
}}
onClick={(e) => detectMentionQuery(value, (e.target as HTMLTextAreaElement).selectionStart || 0)}
onKeyUp={(e) => detectMentionQuery(value, (e.target as HTMLTextAreaElement).selectionStart || 0)}
onKeyDown={(e) => {
if (!open || filtered.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((prev) => (prev + 1) % filtered.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((prev) => (prev - 1 + filtered.length) % filtered.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
insertMention(filtered[activeIndex])
return
}
if (e.key === 'Escape') {
setOpen(false)
}
}}
rows={rows}
placeholder={placeholder}
className={className}
/>
{open && filtered.length > 0 && (
<div className="absolute z-20 mt-1 w-full bg-surface-1 border border-border rounded-md shadow-xl max-h-56 overflow-y-auto">
{filtered.map((option, index) => (
<button
key={`${option.type}-${option.handle}-${option.recipient}`}
type="button"
onMouseDown={(e) => {
e.preventDefault()
insertMention(option)
}}
className={`w-full text-left px-3 py-2 text-xs border-b last:border-b-0 border-border/40 ${
index === activeIndex ? 'bg-primary/20 text-primary' : 'text-foreground hover:bg-surface-2'
}`}
>
<div className="font-mono">@{option.handle}</div>
<div className="text-muted-foreground">
{option.display} {option.type}{option.role ? `${option.role}` : ''}
</div>
</button>
))}
</div>
)}
</div>
)
}
export function TaskBoardPanel() {
const { tasks: storeTasks, setTasks: storeSetTasks, selectedTask, setSelectedTask } = useMissionControl()
const router = useRouter()
@ -627,6 +784,7 @@ function TaskDetailModal({
const [reviewStatus, setReviewStatus] = useState<'approved' | 'rejected'>('approved')
const [reviewNotes, setReviewNotes] = useState('')
const [reviewError, setReviewError] = useState<string | null>(null)
const mentionTargets = useMentionTargets()
const [activeTab, setActiveTab] = useState<'details' | 'comments' | 'quality'>('details')
const [reviewer, setReviewer] = useState('aegis')
@ -879,12 +1037,14 @@ function TaskDetailModal({
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">New Comment</label>
<textarea
<MentionTextarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onChange={setCommentText}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
rows={3}
mentionTargets={mentionTargets}
/>
<p className="text-[11px] text-muted-foreground mt-1">Use <span className="font-mono">@</span> to mention users and agents.</p>
</div>
<div className="flex justify-end">
<button
@ -1003,6 +1163,7 @@ function CreateTaskModal({
assigned_to: '',
tags: '',
})
const mentionTargets = useMentionTargets()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@ -1057,13 +1218,15 @@ function CreateTaskModal({
<div>
<label htmlFor="create-description" className="block text-sm text-muted-foreground mb-1">Description</label>
<textarea
<MentionTextarea
id="create-description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
onChange={(next) => setFormData(prev => ({ ...prev, description: next }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
rows={3}
mentionTargets={mentionTargets}
/>
<p className="text-[11px] text-muted-foreground mt-1">Tip: type <span className="font-mono">@</span> for mention autocomplete.</p>
</div>
<div className="grid grid-cols-2 gap-4">
@ -1173,6 +1336,7 @@ function EditTaskModal({
assigned_to: task.assigned_to || '',
tags: task.tags ? task.tags.join(', ') : '',
})
const mentionTargets = useMentionTargets()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@ -1226,13 +1390,15 @@ function EditTaskModal({
<div>
<label htmlFor="edit-description" className="block text-sm text-muted-foreground mb-1">Description</label>
<textarea
<MentionTextarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
onChange={(next) => setFormData(prev => ({ ...prev, description: next }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
rows={3}
mentionTargets={mentionTargets}
/>
<p className="text-[11px] text-muted-foreground mt-1">Tip: type <span className="font-mono">@</span> for mention autocomplete.</p>
</div>
<div className="grid grid-cols-2 gap-4">

View File

@ -72,6 +72,14 @@ describe('parseMentions', () => {
expect(db_helpers.parseMentions('@start and @end')).toEqual(['start', 'end'])
})
it('supports hyphen, underscore, and dots in handles', () => {
expect(db_helpers.parseMentions('ping @agent-code_reviewer.v2 now')).toEqual(['agent-code_reviewer.v2'])
})
it('deduplicates repeated mentions case-insensitively', () => {
expect(db_helpers.parseMentions('@Alice please sync with @alice')).toEqual(['Alice'])
})
it('returns empty array for empty string', () => {
expect(db_helpers.parseMentions('')).toEqual([])
})

View File

@ -5,6 +5,7 @@ import { runMigrations } from './migrations';
import { eventBus } from './event-bus';
import { hashPassword } from './password';
import { logger } from './logger';
import { parseMentions as parseMentionTokens } from './mentions';
// Database file location
const DB_PATH = config.dbPath;
@ -368,15 +369,7 @@ export const db_helpers = {
* Parse @mentions from text
*/
parseMentions: (text: string): string[] => {
const mentionRegex = /@(\w+)/g;
const mentions: string[] = [];
let match;
while ((match = mentionRegex.exec(text)) !== null) {
mentions.push(match[1]);
}
return mentions;
return parseMentionTokens(text);
},
/**

144
src/lib/mentions.ts Normal file
View File

@ -0,0 +1,144 @@
import type { Database } from 'better-sqlite3'
export interface MentionTarget {
handle: string
recipient: string
type: 'user' | 'agent'
display: string
role?: string
}
export interface MentionResolution {
tokens: string[]
unresolved: string[]
recipients: string[]
resolved: MentionTarget[]
}
const MENTION_PATTERN = /(^|[^A-Za-z0-9._-])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})/g
function normalizeAgentHandle(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, '-')
}
export function parseMentions(text: string): string[] {
if (!text || typeof text !== 'string') return []
const found: string[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
while ((match = MENTION_PATTERN.exec(text)) !== null) {
const token = String(match[2] || '').trim()
if (!token) continue
const key = token.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
found.push(token)
}
return found
}
export function getMentionTargets(db: Database, workspaceId: number): MentionTarget[] {
const targets: MentionTarget[] = []
const seenHandles = new Set<string>()
const users = db.prepare(`
SELECT username, display_name
FROM users
WHERE workspace_id = ?
ORDER BY username ASC
`).all(workspaceId) as Array<{ username: string; display_name?: string | null }>
for (const user of users) {
const username = String(user.username || '').trim()
if (!username) continue
const handle = username.toLowerCase()
if (seenHandles.has(handle)) continue
seenHandles.add(handle)
targets.push({
handle,
recipient: username,
type: 'user',
display: user.display_name?.trim() || username,
})
}
const agents = db.prepare(`
SELECT name, role, config
FROM agents
WHERE workspace_id = ?
ORDER BY name ASC
`).all(workspaceId) as Array<{ name: string; role?: string | null; config?: string | null }>
for (const agent of agents) {
const recipient = String(agent.name || '').trim()
if (!recipient) continue
let openclawId: string | null = null
try {
const parsed = agent.config ? JSON.parse(agent.config) : null
if (parsed && typeof parsed.openclawId === 'string' && parsed.openclawId.trim()) {
openclawId = parsed.openclawId.trim()
}
} catch {
// ignore invalid config JSON for mention indexing
}
const candidateHandles = [openclawId, normalizeAgentHandle(recipient), recipient.toLowerCase()]
.filter((value): value is string => Boolean(value))
for (const rawHandle of candidateHandles) {
const handle = rawHandle.toLowerCase()
if (!handle || seenHandles.has(handle)) continue
seenHandles.add(handle)
targets.push({
handle,
recipient,
type: 'agent',
display: recipient,
role: agent.role || undefined,
})
}
}
return targets
}
export function resolveMentionRecipients(text: string, db: Database, workspaceId: number): MentionResolution {
const tokens = parseMentions(text)
if (tokens.length === 0) {
return { tokens: [], unresolved: [], recipients: [], resolved: [] }
}
const targets = getMentionTargets(db, workspaceId)
const byHandle = new Map<string, MentionTarget>()
for (const target of targets) {
byHandle.set(target.handle.toLowerCase(), target)
}
const resolved: MentionTarget[] = []
const unresolved: string[] = []
const recipientSeen = new Set<string>()
for (const token of tokens) {
const key = token.toLowerCase()
const target = byHandle.get(key)
if (!target) {
unresolved.push(token)
continue
}
if (!recipientSeen.has(target.recipient)) {
recipientSeen.add(target.recipient)
resolved.push(target)
}
}
return {
tokens,
unresolved,
recipients: resolved.map((item) => item.recipient),
resolved,
}
}

86
tests/mentions.spec.ts Normal file
View File

@ -0,0 +1,86 @@
import { test, expect } from '@playwright/test'
import { API_KEY_HEADER, createTestAgent, deleteTestAgent, createTestTask, deleteTestTask, createTestUser, deleteTestUser } from './helpers'
test.describe('Mentions (@users + @agents)', () => {
const createdTaskIds: number[] = []
const createdAgentIds: number[] = []
const createdUserIds: number[] = []
test.afterEach(async ({ request }) => {
for (const taskId of createdTaskIds.splice(0)) {
await deleteTestTask(request, taskId)
}
for (const agentId of createdAgentIds.splice(0)) {
await deleteTestAgent(request, agentId)
}
for (const userId of createdUserIds.splice(0)) {
await deleteTestUser(request, userId)
}
})
test('task description mentions notify both user and agent', async ({ request }) => {
const { id: agentId, name: agentName } = await createTestAgent(request)
createdAgentIds.push(agentId)
const { id: userId, username } = await createTestUser(request)
createdUserIds.push(userId)
const taskRes = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: {
title: `e2e-mention-task-${Date.now()}`,
description: `Please review @${username} and @${agentName}`,
},
})
expect(taskRes.status()).toBe(201)
const taskBody = await taskRes.json()
const taskId = Number(taskBody.task?.id)
createdTaskIds.push(taskId)
const userNotifsRes = await request.get(`/api/notifications?recipient=${encodeURIComponent(username)}`, {
headers: API_KEY_HEADER,
})
expect(userNotifsRes.status()).toBe(200)
const userNotifsBody = await userNotifsRes.json()
expect(userNotifsBody.notifications.some((n: any) => n.type === 'mention' && n.source_type === 'task' && n.source_id === taskId)).toBe(true)
const agentNotifsRes = await request.get(`/api/notifications?recipient=${encodeURIComponent(agentName)}`, {
headers: API_KEY_HEADER,
})
expect(agentNotifsRes.status()).toBe(200)
const agentNotifsBody = await agentNotifsRes.json()
expect(agentNotifsBody.notifications.some((n: any) => n.type === 'mention' && n.source_type === 'task' && n.source_id === taskId)).toBe(true)
})
test('rejects unknown mention in task description', async ({ request }) => {
const res = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: {
title: `e2e-mention-invalid-${Date.now()}`,
description: 'invalid mention @does-not-exist-xyz',
},
})
expect(res.status()).toBe(400)
const body = await res.json()
expect(String(body.error || '')).toContain('Unknown mentions')
})
test('rejects unknown mention in comments', async ({ request }) => {
const { id: taskId } = await createTestTask(request)
createdTaskIds.push(taskId)
const res = await request.post(`/api/tasks/${taskId}/comments`, {
headers: API_KEY_HEADER,
data: {
author: 'system',
content: 'hello @not-a-real-target-zz',
},
})
expect(res.status()).toBe(400)
const body = await res.json()
expect(String(body.error || '')).toContain('Unknown mentions')
})
})