feat: add validated @mentions for tasks and comments
This commit is contained in:
parent
dc4a3c539c
commit
36d5891d85
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { requireRole } from '@/lib/auth';
|
||||||
import { validateBody, createCommentSchema } from '@/lib/validation';
|
import { validateBody, createCommentSchema } from '@/lib/validation';
|
||||||
import { mutationLimiter } from '@/lib/rate-limit';
|
import { mutationLimiter } from '@/lib/rate-limit';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { resolveMentionRecipients } from '@/lib/mentions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/tasks/[id]/comments - Get all comments for a task
|
* GET /api/tasks/[id]/comments - Get all comments for a task
|
||||||
|
|
@ -128,8 +129,13 @@ export async function POST(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse @mentions from content
|
const mentionResolution = resolveMentionRecipients(content, db, workspaceId);
|
||||||
const mentions = db_helpers.parseMentions(content);
|
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);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
|
@ -145,7 +151,7 @@ export async function POST(
|
||||||
content,
|
content,
|
||||||
now,
|
now,
|
||||||
parent_id || null,
|
parent_id || null,
|
||||||
mentions.length > 0 ? JSON.stringify(mentions) : null,
|
mentionResolution.tokens.length > 0 ? JSON.stringify(mentionResolution.tokens) : null,
|
||||||
workspaceId
|
workspaceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -166,7 +172,7 @@ export async function POST(
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
task_title: task.title,
|
task_title: task.title,
|
||||||
parent_id,
|
parent_id,
|
||||||
mentions,
|
mentions: mentionResolution.tokens,
|
||||||
content_preview: content.substring(0, 100)
|
content_preview: content.substring(0, 100)
|
||||||
},
|
},
|
||||||
workspaceId
|
workspaceId
|
||||||
|
|
@ -174,9 +180,9 @@ export async function POST(
|
||||||
|
|
||||||
// Ensure subscriptions for author, mentions, and assignee
|
// Ensure subscriptions for author, mentions, and assignee
|
||||||
db_helpers.ensureTaskSubscription(taskId, author, workspaceId);
|
db_helpers.ensureTaskSubscription(taskId, author, workspaceId);
|
||||||
const uniqueMentions = Array.from(new Set(mentions));
|
const mentionRecipients = mentionResolution.recipients;
|
||||||
uniqueMentions.forEach((mentionedAgent) => {
|
mentionRecipients.forEach((mentionedRecipient) => {
|
||||||
db_helpers.ensureTaskSubscription(taskId, mentionedAgent, workspaceId);
|
db_helpers.ensureTaskSubscription(taskId, mentionedRecipient, workspaceId);
|
||||||
});
|
});
|
||||||
if (task.assigned_to) {
|
if (task.assigned_to) {
|
||||||
db_helpers.ensureTaskSubscription(taskId, task.assigned_to, workspaceId);
|
db_helpers.ensureTaskSubscription(taskId, task.assigned_to, workspaceId);
|
||||||
|
|
@ -185,7 +191,7 @@ export async function POST(
|
||||||
// Notify subscribers
|
// Notify subscribers
|
||||||
const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId));
|
const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId));
|
||||||
subscribers.delete(author);
|
subscribers.delete(author);
|
||||||
const mentionSet = new Set(uniqueMentions);
|
const mentionSet = new Set(mentionRecipients);
|
||||||
|
|
||||||
for (const subscriber of subscribers) {
|
for (const subscriber of subscribers) {
|
||||||
const isMention = mentionSet.has(subscriber);
|
const isMention = mentionSet.has(subscriber);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { requireRole } from '@/lib/auth';
|
||||||
import { mutationLimiter } from '@/lib/rate-limit';
|
import { mutationLimiter } from '@/lib/rate-limit';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { validateBody, updateTaskSchema } from '@/lib/validation';
|
import { validateBody, updateTaskSchema } from '@/lib/validation';
|
||||||
|
import { resolveMentionRecipients } from '@/lib/mentions';
|
||||||
|
|
||||||
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
||||||
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
||||||
|
|
@ -126,6 +127,17 @@ export async function PUT(
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
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
|
// Build dynamic update query
|
||||||
const fieldsToUpdate = [];
|
const fieldsToUpdate = [];
|
||||||
|
|
@ -275,6 +287,25 @@ export async function PUT(
|
||||||
changes.push(`project: ${currentTask.project_id || 'none'} → ${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
|
// Log activity if there were meaningful changes
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
db_helpers.logActivity(
|
db_helpers.logActivity(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { requireRole } from '@/lib/auth';
|
||||||
import { mutationLimiter } from '@/lib/rate-limit';
|
import { mutationLimiter } from '@/lib/rate-limit';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
|
import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
|
||||||
|
import { resolveMentionRecipients } from '@/lib/mentions';
|
||||||
|
|
||||||
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
||||||
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return 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 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 createTaskTx = db.transaction(() => {
|
||||||
const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
|
const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
|
|
@ -234,6 +243,20 @@ export async function POST(request: NextRequest) {
|
||||||
db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId)
|
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
|
// Create notification if assigned
|
||||||
if (assigned_to) {
|
if (assigned_to) {
|
||||||
db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId)
|
db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId)
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,14 @@ interface Project {
|
||||||
status: 'active' | 'archived'
|
status: 'active' | 'archived'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MentionOption {
|
||||||
|
handle: string
|
||||||
|
recipient: string
|
||||||
|
type: 'user' | 'agent'
|
||||||
|
display: string
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
|
||||||
const statusColumns = [
|
const statusColumns = [
|
||||||
{ key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
|
{ key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
|
||||||
{ key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' },
|
{ 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',
|
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() {
|
export function TaskBoardPanel() {
|
||||||
const { tasks: storeTasks, setTasks: storeSetTasks, selectedTask, setSelectedTask } = useMissionControl()
|
const { tasks: storeTasks, setTasks: storeSetTasks, selectedTask, setSelectedTask } = useMissionControl()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -627,6 +784,7 @@ function TaskDetailModal({
|
||||||
const [reviewStatus, setReviewStatus] = useState<'approved' | 'rejected'>('approved')
|
const [reviewStatus, setReviewStatus] = useState<'approved' | 'rejected'>('approved')
|
||||||
const [reviewNotes, setReviewNotes] = useState('')
|
const [reviewNotes, setReviewNotes] = useState('')
|
||||||
const [reviewError, setReviewError] = useState<string | null>(null)
|
const [reviewError, setReviewError] = useState<string | null>(null)
|
||||||
|
const mentionTargets = useMentionTargets()
|
||||||
const [activeTab, setActiveTab] = useState<'details' | 'comments' | 'quality'>('details')
|
const [activeTab, setActiveTab] = useState<'details' | 'comments' | 'quality'>('details')
|
||||||
const [reviewer, setReviewer] = useState('aegis')
|
const [reviewer, setReviewer] = useState('aegis')
|
||||||
|
|
||||||
|
|
@ -879,12 +1037,14 @@ function TaskDetailModal({
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-muted-foreground mb-1">New Comment</label>
|
<label className="block text-xs text-muted-foreground mb-1">New Comment</label>
|
||||||
<textarea
|
<MentionTextarea
|
||||||
value={commentText}
|
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"
|
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}
|
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>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
|
|
@ -1003,6 +1163,7 @@ function CreateTaskModal({
|
||||||
assigned_to: '',
|
assigned_to: '',
|
||||||
tags: '',
|
tags: '',
|
||||||
})
|
})
|
||||||
|
const mentionTargets = useMentionTargets()
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -1057,13 +1218,15 @@ function CreateTaskModal({
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="create-description" className="block text-sm text-muted-foreground mb-1">Description</label>
|
<label htmlFor="create-description" className="block text-sm text-muted-foreground mb-1">Description</label>
|
||||||
<textarea
|
<MentionTextarea
|
||||||
id="create-description"
|
id="create-description"
|
||||||
value={formData.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"
|
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}
|
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
@ -1173,6 +1336,7 @@ function EditTaskModal({
|
||||||
assigned_to: task.assigned_to || '',
|
assigned_to: task.assigned_to || '',
|
||||||
tags: task.tags ? task.tags.join(', ') : '',
|
tags: task.tags ? task.tags.join(', ') : '',
|
||||||
})
|
})
|
||||||
|
const mentionTargets = useMentionTargets()
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -1226,13 +1390,15 @@ function EditTaskModal({
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="edit-description" className="block text-sm text-muted-foreground mb-1">Description</label>
|
<label htmlFor="edit-description" className="block text-sm text-muted-foreground mb-1">Description</label>
|
||||||
<textarea
|
<MentionTextarea
|
||||||
id="edit-description"
|
id="edit-description"
|
||||||
value={formData.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"
|
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}
|
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,14 @@ describe('parseMentions', () => {
|
||||||
expect(db_helpers.parseMentions('@start and @end')).toEqual(['start', 'end'])
|
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', () => {
|
it('returns empty array for empty string', () => {
|
||||||
expect(db_helpers.parseMentions('')).toEqual([])
|
expect(db_helpers.parseMentions('')).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { runMigrations } from './migrations';
|
||||||
import { eventBus } from './event-bus';
|
import { eventBus } from './event-bus';
|
||||||
import { hashPassword } from './password';
|
import { hashPassword } from './password';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { parseMentions as parseMentionTokens } from './mentions';
|
||||||
|
|
||||||
// Database file location
|
// Database file location
|
||||||
const DB_PATH = config.dbPath;
|
const DB_PATH = config.dbPath;
|
||||||
|
|
@ -368,15 +369,7 @@ export const db_helpers = {
|
||||||
* Parse @mentions from text
|
* Parse @mentions from text
|
||||||
*/
|
*/
|
||||||
parseMentions: (text: string): string[] => {
|
parseMentions: (text: string): string[] => {
|
||||||
const mentionRegex = /@(\w+)/g;
|
return parseMentionTokens(text);
|
||||||
const mentions: string[] = [];
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = mentionRegex.exec(text)) !== null) {
|
|
||||||
mentions.push(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return mentions;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue