Nyk commited on
Commit
200c64b
·
1 Parent(s): 90c8b4e

feat: add validated @mentions for tasks and comments

Browse files
src/app/api/mentions/route.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { requireRole } from '@/lib/auth'
3
+ import { getDatabase } from '@/lib/db'
4
+ import { getMentionTargets } from '@/lib/mentions'
5
+ import { logger } from '@/lib/logger'
6
+
7
+ /**
8
+ * GET /api/mentions - autocomplete source for @mentions (users + agents)
9
+ * Query: q?, limit?, type?
10
+ */
11
+ export async function GET(request: NextRequest) {
12
+ const auth = requireRole(request, 'viewer')
13
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
14
+
15
+ try {
16
+ const db = getDatabase()
17
+ const workspaceId = auth.user.workspace_id ?? 1
18
+ const { searchParams } = new URL(request.url)
19
+
20
+ const q = String(searchParams.get('q') || '').trim().toLowerCase()
21
+ const typeFilter = String(searchParams.get('type') || '').trim().toLowerCase()
22
+ const limitRaw = Number.parseInt(searchParams.get('limit') || '25', 10)
23
+ const limit = Math.max(1, Math.min(Number.isFinite(limitRaw) ? limitRaw : 25, 200))
24
+
25
+ let targets = getMentionTargets(db, workspaceId)
26
+
27
+ if (typeFilter === 'user' || typeFilter === 'agent') {
28
+ targets = targets.filter((target) => target.type === typeFilter)
29
+ }
30
+
31
+ if (q) {
32
+ targets = targets.filter((target) => (
33
+ target.handle.includes(q) ||
34
+ target.recipient.toLowerCase().includes(q) ||
35
+ target.display.toLowerCase().includes(q)
36
+ ))
37
+ }
38
+
39
+ targets = targets.slice(0, limit)
40
+
41
+ return NextResponse.json({
42
+ mentions: targets,
43
+ total: targets.length,
44
+ q,
45
+ })
46
+ } catch (error) {
47
+ logger.error({ err: error }, 'GET /api/mentions error')
48
+ return NextResponse.json({ error: 'Failed to fetch mention targets' }, { status: 500 })
49
+ }
50
+ }
src/app/api/tasks/[id]/comments/route.ts CHANGED
@@ -4,6 +4,7 @@ import { requireRole } from '@/lib/auth';
4
  import { validateBody, createCommentSchema } from '@/lib/validation';
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
 
7
 
8
  /**
9
  * GET /api/tasks/[id]/comments - Get all comments for a task
@@ -128,8 +129,13 @@ export async function POST(
128
  }
129
  }
130
 
131
- // Parse @mentions from content
132
- const mentions = db_helpers.parseMentions(content);
 
 
 
 
 
133
 
134
  const now = Math.floor(Date.now() / 1000);
135
 
@@ -145,7 +151,7 @@ export async function POST(
145
  content,
146
  now,
147
  parent_id || null,
148
- mentions.length > 0 ? JSON.stringify(mentions) : null,
149
  workspaceId
150
  );
151
 
@@ -166,7 +172,7 @@ export async function POST(
166
  task_id: taskId,
167
  task_title: task.title,
168
  parent_id,
169
- mentions,
170
  content_preview: content.substring(0, 100)
171
  },
172
  workspaceId
@@ -174,9 +180,9 @@ export async function POST(
174
 
175
  // Ensure subscriptions for author, mentions, and assignee
176
  db_helpers.ensureTaskSubscription(taskId, author, workspaceId);
177
- const uniqueMentions = Array.from(new Set(mentions));
178
- uniqueMentions.forEach((mentionedAgent) => {
179
- db_helpers.ensureTaskSubscription(taskId, mentionedAgent, workspaceId);
180
  });
181
  if (task.assigned_to) {
182
  db_helpers.ensureTaskSubscription(taskId, task.assigned_to, workspaceId);
@@ -185,7 +191,7 @@ export async function POST(
185
  // Notify subscribers
186
  const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId));
187
  subscribers.delete(author);
188
- const mentionSet = new Set(uniqueMentions);
189
 
190
  for (const subscriber of subscribers) {
191
  const isMention = mentionSet.has(subscriber);
 
4
  import { validateBody, createCommentSchema } from '@/lib/validation';
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
+ import { resolveMentionRecipients } from '@/lib/mentions';
8
 
9
  /**
10
  * GET /api/tasks/[id]/comments - Get all comments for a task
 
129
  }
130
  }
131
 
132
+ const mentionResolution = resolveMentionRecipients(content, db, workspaceId);
133
+ if (mentionResolution.unresolved.length > 0) {
134
+ return NextResponse.json({
135
+ error: `Unknown mentions: ${mentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`,
136
+ missing_mentions: mentionResolution.unresolved
137
+ }, { status: 400 });
138
+ }
139
 
140
  const now = Math.floor(Date.now() / 1000);
141
 
 
151
  content,
152
  now,
153
  parent_id || null,
154
+ mentionResolution.tokens.length > 0 ? JSON.stringify(mentionResolution.tokens) : null,
155
  workspaceId
156
  );
157
 
 
172
  task_id: taskId,
173
  task_title: task.title,
174
  parent_id,
175
+ mentions: mentionResolution.tokens,
176
  content_preview: content.substring(0, 100)
177
  },
178
  workspaceId
 
180
 
181
  // Ensure subscriptions for author, mentions, and assignee
182
  db_helpers.ensureTaskSubscription(taskId, author, workspaceId);
183
+ const mentionRecipients = mentionResolution.recipients;
184
+ mentionRecipients.forEach((mentionedRecipient) => {
185
+ db_helpers.ensureTaskSubscription(taskId, mentionedRecipient, workspaceId);
186
  });
187
  if (task.assigned_to) {
188
  db_helpers.ensureTaskSubscription(taskId, task.assigned_to, workspaceId);
 
191
  // Notify subscribers
192
  const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId));
193
  subscribers.delete(author);
194
+ const mentionSet = new Set(mentionRecipients);
195
 
196
  for (const subscriber of subscribers) {
197
  const isMention = mentionSet.has(subscriber);
src/app/api/tasks/[id]/route.ts CHANGED
@@ -5,6 +5,7 @@ import { requireRole } from '@/lib/auth';
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
 
8
 
9
  function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
10
  if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@@ -126,6 +127,17 @@ export async function PUT(
126
  } = body;
127
 
128
  const now = Math.floor(Date.now() / 1000);
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  // Build dynamic update query
131
  const fieldsToUpdate = [];
@@ -274,6 +286,25 @@ export async function PUT(
274
  if (project_id !== undefined && project_id !== currentTask.project_id) {
275
  changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`);
276
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
  // Log activity if there were meaningful changes
279
  if (changes.length > 0) {
 
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
8
+ import { resolveMentionRecipients } from '@/lib/mentions';
9
 
10
  function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
11
  if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
 
127
  } = body;
128
 
129
  const now = Math.floor(Date.now() / 1000);
130
+ const descriptionMentionResolution = description !== undefined
131
+ ? resolveMentionRecipients(description || '', db, workspaceId)
132
+ : null;
133
+ if (descriptionMentionResolution && descriptionMentionResolution.unresolved.length > 0) {
134
+ return NextResponse.json({
135
+ error: `Unknown mentions: ${descriptionMentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`,
136
+ missing_mentions: descriptionMentionResolution.unresolved
137
+ }, { status: 400 });
138
+ }
139
+
140
+ const previousDescriptionMentionRecipients = resolveMentionRecipients(currentTask.description || '', db, workspaceId).recipients;
141
 
142
  // Build dynamic update query
143
  const fieldsToUpdate = [];
 
286
  if (project_id !== undefined && project_id !== currentTask.project_id) {
287
  changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`);
288
  }
289
+
290
+ if (descriptionMentionResolution) {
291
+ const newMentionRecipients = new Set(descriptionMentionResolution.recipients);
292
+ const previousRecipients = new Set(previousDescriptionMentionRecipients);
293
+ for (const recipient of newMentionRecipients) {
294
+ if (previousRecipients.has(recipient)) continue;
295
+ db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId);
296
+ if (recipient === auth.user.username) continue;
297
+ db_helpers.createNotification(
298
+ recipient,
299
+ 'mention',
300
+ 'You were mentioned in a task description',
301
+ `${auth.user.username} mentioned you in task "${title || currentTask.title}"`,
302
+ 'task',
303
+ taskId,
304
+ workspaceId
305
+ );
306
+ }
307
+ }
308
 
309
  // Log activity if there were meaningful changes
310
  if (changes.length > 0) {
src/app/api/tasks/route.ts CHANGED
@@ -5,6 +5,7 @@ import { requireRole } from '@/lib/auth';
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
 
8
 
9
  function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
10
  if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@@ -180,6 +181,14 @@ export async function POST(request: NextRequest) {
180
  }
181
 
182
  const now = Math.floor(Date.now() / 1000);
 
 
 
 
 
 
 
 
183
  const createTaskTx = db.transaction(() => {
184
  const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
185
  db.prepare(`
@@ -234,6 +243,20 @@ export async function POST(request: NextRequest) {
234
  db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId)
235
  }
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  // Create notification if assigned
238
  if (assigned_to) {
239
  db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId)
 
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
+ import { resolveMentionRecipients } from '@/lib/mentions';
9
 
10
  function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
11
  if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
 
181
  }
182
 
183
  const now = Math.floor(Date.now() / 1000);
184
+ const mentionResolution = resolveMentionRecipients(description || '', db, workspaceId);
185
+ if (mentionResolution.unresolved.length > 0) {
186
+ return NextResponse.json({
187
+ error: `Unknown mentions: ${mentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`,
188
+ missing_mentions: mentionResolution.unresolved
189
+ }, { status: 400 });
190
+ }
191
+
192
  const createTaskTx = db.transaction(() => {
193
  const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
194
  db.prepare(`
 
243
  db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId)
244
  }
245
 
246
+ for (const recipient of mentionResolution.recipients) {
247
+ db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId);
248
+ if (recipient === created_by) continue;
249
+ db_helpers.createNotification(
250
+ recipient,
251
+ 'mention',
252
+ 'You were mentioned in a task description',
253
+ `${created_by} mentioned you in task "${title}"`,
254
+ 'task',
255
+ taskId,
256
+ workspaceId
257
+ );
258
+ }
259
+
260
  // Create notification if assigned
261
  if (assigned_to) {
262
  db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId)
src/components/panels/task-board-panel.tsx CHANGED
@@ -69,6 +69,14 @@ interface Project {
69
  status: 'active' | 'archived'
70
  }
71
 
 
 
 
 
 
 
 
 
72
  const statusColumns = [
73
  { key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
74
  { key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' },
@@ -85,6 +93,155 @@ const priorityColors: Record<string, string> = {
85
  critical: 'border-red-500',
86
  }
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  export function TaskBoardPanel() {
89
  const { tasks: storeTasks, setTasks: storeSetTasks, selectedTask, setSelectedTask } = useMissionControl()
90
  const router = useRouter()
@@ -627,6 +784,7 @@ function TaskDetailModal({
627
  const [reviewStatus, setReviewStatus] = useState<'approved' | 'rejected'>('approved')
628
  const [reviewNotes, setReviewNotes] = useState('')
629
  const [reviewError, setReviewError] = useState<string | null>(null)
 
630
  const [activeTab, setActiveTab] = useState<'details' | 'comments' | 'quality'>('details')
631
  const [reviewer, setReviewer] = useState('aegis')
632
 
@@ -879,12 +1037,14 @@ function TaskDetailModal({
879
  </div>
880
  <div>
881
  <label className="block text-xs text-muted-foreground mb-1">New Comment</label>
882
- <textarea
883
  value={commentText}
884
- onChange={(e) => setCommentText(e.target.value)}
885
  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"
886
  rows={3}
 
887
  />
 
888
  </div>
889
  <div className="flex justify-end">
890
  <button
@@ -1003,6 +1163,7 @@ function CreateTaskModal({
1003
  assigned_to: '',
1004
  tags: '',
1005
  })
 
1006
 
1007
  const handleSubmit = async (e: React.FormEvent) => {
1008
  e.preventDefault()
@@ -1057,13 +1218,15 @@ function CreateTaskModal({
1057
 
1058
  <div>
1059
  <label htmlFor="create-description" className="block text-sm text-muted-foreground mb-1">Description</label>
1060
- <textarea
1061
  id="create-description"
1062
  value={formData.description}
1063
- onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
1064
  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"
1065
  rows={3}
 
1066
  />
 
1067
  </div>
1068
 
1069
  <div className="grid grid-cols-2 gap-4">
@@ -1173,6 +1336,7 @@ function EditTaskModal({
1173
  assigned_to: task.assigned_to || '',
1174
  tags: task.tags ? task.tags.join(', ') : '',
1175
  })
 
1176
 
1177
  const handleSubmit = async (e: React.FormEvent) => {
1178
  e.preventDefault()
@@ -1226,13 +1390,15 @@ function EditTaskModal({
1226
 
1227
  <div>
1228
  <label htmlFor="edit-description" className="block text-sm text-muted-foreground mb-1">Description</label>
1229
- <textarea
1230
  id="edit-description"
1231
  value={formData.description}
1232
- onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
1233
  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"
1234
  rows={3}
 
1235
  />
 
1236
  </div>
1237
 
1238
  <div className="grid grid-cols-2 gap-4">
 
69
  status: 'active' | 'archived'
70
  }
71
 
72
+ interface MentionOption {
73
+ handle: string
74
+ recipient: string
75
+ type: 'user' | 'agent'
76
+ display: string
77
+ role?: string
78
+ }
79
+
80
  const statusColumns = [
81
  { key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
82
  { key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' },
 
93
  critical: 'border-red-500',
94
  }
95
 
96
+ function useMentionTargets() {
97
+ const [mentionTargets, setMentionTargets] = useState<MentionOption[]>([])
98
+
99
+ useEffect(() => {
100
+ let cancelled = false
101
+ const run = async () => {
102
+ try {
103
+ const response = await fetch('/api/mentions?limit=200')
104
+ if (!response.ok) return
105
+ const data = await response.json()
106
+ if (!cancelled) setMentionTargets(data.mentions || [])
107
+ } catch {
108
+ // mention autocomplete is non-critical
109
+ }
110
+ }
111
+ run()
112
+ return () => { cancelled = true }
113
+ }, [])
114
+
115
+ return mentionTargets
116
+ }
117
+
118
+ function MentionTextarea({
119
+ id,
120
+ value,
121
+ onChange,
122
+ rows = 3,
123
+ placeholder,
124
+ className,
125
+ mentionTargets,
126
+ }: {
127
+ id?: string
128
+ value: string
129
+ onChange: (next: string) => void
130
+ rows?: number
131
+ placeholder?: string
132
+ className?: string
133
+ mentionTargets: MentionOption[]
134
+ }) {
135
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null)
136
+ const [open, setOpen] = useState(false)
137
+ const [activeIndex, setActiveIndex] = useState(0)
138
+ const [query, setQuery] = useState('')
139
+ const [range, setRange] = useState<{ start: number; end: number } | null>(null)
140
+
141
+ const filtered = mentionTargets
142
+ .filter((target) => {
143
+ if (!query) return true
144
+ const q = query.toLowerCase()
145
+ return target.handle.includes(q) || target.display.toLowerCase().includes(q)
146
+ })
147
+ .slice(0, 8)
148
+
149
+ const detectMentionQuery = (nextValue: string, caret: number) => {
150
+ const left = nextValue.slice(0, caret)
151
+ const match = left.match(/(?:^|[^\w.-])@([A-Za-z0-9._-]{0,63})$/)
152
+ if (!match) {
153
+ setOpen(false)
154
+ setQuery('')
155
+ setRange(null)
156
+ return
157
+ }
158
+ const matched = match[1] || ''
159
+ const start = caret - matched.length - 1
160
+ setQuery(matched)
161
+ setRange({ start, end: caret })
162
+ setActiveIndex(0)
163
+ setOpen(true)
164
+ }
165
+
166
+ const insertMention = (option: MentionOption) => {
167
+ if (!range) return
168
+ const next = `${value.slice(0, range.start)}@${option.handle} ${value.slice(range.end)}`
169
+ onChange(next)
170
+ setOpen(false)
171
+ setQuery('')
172
+ const cursor = range.start + option.handle.length + 2
173
+ requestAnimationFrame(() => {
174
+ const node = textareaRef.current
175
+ if (!node) return
176
+ node.focus()
177
+ node.setSelectionRange(cursor, cursor)
178
+ })
179
+ }
180
+
181
+ return (
182
+ <div className="relative">
183
+ <textarea
184
+ id={id}
185
+ ref={textareaRef}
186
+ value={value}
187
+ onChange={(e) => {
188
+ const nextValue = e.target.value
189
+ onChange(nextValue)
190
+ detectMentionQuery(nextValue, e.target.selectionStart || 0)
191
+ }}
192
+ onClick={(e) => detectMentionQuery(value, (e.target as HTMLTextAreaElement).selectionStart || 0)}
193
+ onKeyUp={(e) => detectMentionQuery(value, (e.target as HTMLTextAreaElement).selectionStart || 0)}
194
+ onKeyDown={(e) => {
195
+ if (!open || filtered.length === 0) return
196
+ if (e.key === 'ArrowDown') {
197
+ e.preventDefault()
198
+ setActiveIndex((prev) => (prev + 1) % filtered.length)
199
+ return
200
+ }
201
+ if (e.key === 'ArrowUp') {
202
+ e.preventDefault()
203
+ setActiveIndex((prev) => (prev - 1 + filtered.length) % filtered.length)
204
+ return
205
+ }
206
+ if (e.key === 'Enter' || e.key === 'Tab') {
207
+ e.preventDefault()
208
+ insertMention(filtered[activeIndex])
209
+ return
210
+ }
211
+ if (e.key === 'Escape') {
212
+ setOpen(false)
213
+ }
214
+ }}
215
+ rows={rows}
216
+ placeholder={placeholder}
217
+ className={className}
218
+ />
219
+ {open && filtered.length > 0 && (
220
+ <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">
221
+ {filtered.map((option, index) => (
222
+ <button
223
+ key={`${option.type}-${option.handle}-${option.recipient}`}
224
+ type="button"
225
+ onMouseDown={(e) => {
226
+ e.preventDefault()
227
+ insertMention(option)
228
+ }}
229
+ className={`w-full text-left px-3 py-2 text-xs border-b last:border-b-0 border-border/40 ${
230
+ index === activeIndex ? 'bg-primary/20 text-primary' : 'text-foreground hover:bg-surface-2'
231
+ }`}
232
+ >
233
+ <div className="font-mono">@{option.handle}</div>
234
+ <div className="text-muted-foreground">
235
+ {option.display} • {option.type}{option.role ? ` • ${option.role}` : ''}
236
+ </div>
237
+ </button>
238
+ ))}
239
+ </div>
240
+ )}
241
+ </div>
242
+ )
243
+ }
244
+
245
  export function TaskBoardPanel() {
246
  const { tasks: storeTasks, setTasks: storeSetTasks, selectedTask, setSelectedTask } = useMissionControl()
247
  const router = useRouter()
 
784
  const [reviewStatus, setReviewStatus] = useState<'approved' | 'rejected'>('approved')
785
  const [reviewNotes, setReviewNotes] = useState('')
786
  const [reviewError, setReviewError] = useState<string | null>(null)
787
+ const mentionTargets = useMentionTargets()
788
  const [activeTab, setActiveTab] = useState<'details' | 'comments' | 'quality'>('details')
789
  const [reviewer, setReviewer] = useState('aegis')
790
 
 
1037
  </div>
1038
  <div>
1039
  <label className="block text-xs text-muted-foreground mb-1">New Comment</label>
1040
+ <MentionTextarea
1041
  value={commentText}
1042
+ onChange={setCommentText}
1043
  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"
1044
  rows={3}
1045
+ mentionTargets={mentionTargets}
1046
  />
1047
+ <p className="text-[11px] text-muted-foreground mt-1">Use <span className="font-mono">@</span> to mention users and agents.</p>
1048
  </div>
1049
  <div className="flex justify-end">
1050
  <button
 
1163
  assigned_to: '',
1164
  tags: '',
1165
  })
1166
+ const mentionTargets = useMentionTargets()
1167
 
1168
  const handleSubmit = async (e: React.FormEvent) => {
1169
  e.preventDefault()
 
1218
 
1219
  <div>
1220
  <label htmlFor="create-description" className="block text-sm text-muted-foreground mb-1">Description</label>
1221
+ <MentionTextarea
1222
  id="create-description"
1223
  value={formData.description}
1224
+ onChange={(next) => setFormData(prev => ({ ...prev, description: next }))}
1225
  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"
1226
  rows={3}
1227
+ mentionTargets={mentionTargets}
1228
  />
1229
+ <p className="text-[11px] text-muted-foreground mt-1">Tip: type <span className="font-mono">@</span> for mention autocomplete.</p>
1230
  </div>
1231
 
1232
  <div className="grid grid-cols-2 gap-4">
 
1336
  assigned_to: task.assigned_to || '',
1337
  tags: task.tags ? task.tags.join(', ') : '',
1338
  })
1339
+ const mentionTargets = useMentionTargets()
1340
 
1341
  const handleSubmit = async (e: React.FormEvent) => {
1342
  e.preventDefault()
 
1390
 
1391
  <div>
1392
  <label htmlFor="edit-description" className="block text-sm text-muted-foreground mb-1">Description</label>
1393
+ <MentionTextarea
1394
  id="edit-description"
1395
  value={formData.description}
1396
+ onChange={(next) => setFormData(prev => ({ ...prev, description: next }))}
1397
  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"
1398
  rows={3}
1399
+ mentionTargets={mentionTargets}
1400
  />
1401
+ <p className="text-[11px] text-muted-foreground mt-1">Tip: type <span className="font-mono">@</span> for mention autocomplete.</p>
1402
  </div>
1403
 
1404
  <div className="grid grid-cols-2 gap-4">
src/lib/__tests__/db-helpers.test.ts CHANGED
@@ -72,6 +72,14 @@ describe('parseMentions', () => {
72
  expect(db_helpers.parseMentions('@start and @end')).toEqual(['start', 'end'])
73
  })
74
 
 
 
 
 
 
 
 
 
75
  it('returns empty array for empty string', () => {
76
  expect(db_helpers.parseMentions('')).toEqual([])
77
  })
 
72
  expect(db_helpers.parseMentions('@start and @end')).toEqual(['start', 'end'])
73
  })
74
 
75
+ it('supports hyphen, underscore, and dots in handles', () => {
76
+ expect(db_helpers.parseMentions('ping @agent-code_reviewer.v2 now')).toEqual(['agent-code_reviewer.v2'])
77
+ })
78
+
79
+ it('deduplicates repeated mentions case-insensitively', () => {
80
+ expect(db_helpers.parseMentions('@Alice please sync with @alice')).toEqual(['Alice'])
81
+ })
82
+
83
  it('returns empty array for empty string', () => {
84
  expect(db_helpers.parseMentions('')).toEqual([])
85
  })
src/lib/db.ts CHANGED
@@ -5,6 +5,7 @@ import { runMigrations } from './migrations';
5
  import { eventBus } from './event-bus';
6
  import { hashPassword } from './password';
7
  import { logger } from './logger';
 
8
 
9
  // Database file location
10
  const DB_PATH = config.dbPath;
@@ -368,15 +369,7 @@ export const db_helpers = {
368
  * Parse @mentions from text
369
  */
370
  parseMentions: (text: string): string[] => {
371
- const mentionRegex = /@(\w+)/g;
372
- const mentions: string[] = [];
373
- let match;
374
-
375
- while ((match = mentionRegex.exec(text)) !== null) {
376
- mentions.push(match[1]);
377
- }
378
-
379
- return mentions;
380
  },
381
 
382
  /**
 
5
  import { eventBus } from './event-bus';
6
  import { hashPassword } from './password';
7
  import { logger } from './logger';
8
+ import { parseMentions as parseMentionTokens } from './mentions';
9
 
10
  // Database file location
11
  const DB_PATH = config.dbPath;
 
369
  * Parse @mentions from text
370
  */
371
  parseMentions: (text: string): string[] => {
372
+ return parseMentionTokens(text);
 
 
 
 
 
 
 
 
373
  },
374
 
375
  /**
src/lib/mentions.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Database } from 'better-sqlite3'
2
+
3
+ export interface MentionTarget {
4
+ handle: string
5
+ recipient: string
6
+ type: 'user' | 'agent'
7
+ display: string
8
+ role?: string
9
+ }
10
+
11
+ export interface MentionResolution {
12
+ tokens: string[]
13
+ unresolved: string[]
14
+ recipients: string[]
15
+ resolved: MentionTarget[]
16
+ }
17
+
18
+ const MENTION_PATTERN = /(^|[^A-Za-z0-9._-])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})/g
19
+
20
+ function normalizeAgentHandle(name: string): string {
21
+ return name.trim().toLowerCase().replace(/\s+/g, '-')
22
+ }
23
+
24
+ export function parseMentions(text: string): string[] {
25
+ if (!text || typeof text !== 'string') return []
26
+
27
+ const found: string[] = []
28
+ const seen = new Set<string>()
29
+ let match: RegExpExecArray | null
30
+
31
+ while ((match = MENTION_PATTERN.exec(text)) !== null) {
32
+ const token = String(match[2] || '').trim()
33
+ if (!token) continue
34
+ const key = token.toLowerCase()
35
+ if (seen.has(key)) continue
36
+ seen.add(key)
37
+ found.push(token)
38
+ }
39
+
40
+ return found
41
+ }
42
+
43
+ export function getMentionTargets(db: Database, workspaceId: number): MentionTarget[] {
44
+ const targets: MentionTarget[] = []
45
+ const seenHandles = new Set<string>()
46
+
47
+ const users = db.prepare(`
48
+ SELECT username, display_name
49
+ FROM users
50
+ WHERE workspace_id = ?
51
+ ORDER BY username ASC
52
+ `).all(workspaceId) as Array<{ username: string; display_name?: string | null }>
53
+
54
+ for (const user of users) {
55
+ const username = String(user.username || '').trim()
56
+ if (!username) continue
57
+ const handle = username.toLowerCase()
58
+ if (seenHandles.has(handle)) continue
59
+ seenHandles.add(handle)
60
+ targets.push({
61
+ handle,
62
+ recipient: username,
63
+ type: 'user',
64
+ display: user.display_name?.trim() || username,
65
+ })
66
+ }
67
+
68
+ const agents = db.prepare(`
69
+ SELECT name, role, config
70
+ FROM agents
71
+ WHERE workspace_id = ?
72
+ ORDER BY name ASC
73
+ `).all(workspaceId) as Array<{ name: string; role?: string | null; config?: string | null }>
74
+
75
+ for (const agent of agents) {
76
+ const recipient = String(agent.name || '').trim()
77
+ if (!recipient) continue
78
+
79
+ let openclawId: string | null = null
80
+ try {
81
+ const parsed = agent.config ? JSON.parse(agent.config) : null
82
+ if (parsed && typeof parsed.openclawId === 'string' && parsed.openclawId.trim()) {
83
+ openclawId = parsed.openclawId.trim()
84
+ }
85
+ } catch {
86
+ // ignore invalid config JSON for mention indexing
87
+ }
88
+
89
+ const candidateHandles = [openclawId, normalizeAgentHandle(recipient), recipient.toLowerCase()]
90
+ .filter((value): value is string => Boolean(value))
91
+
92
+ for (const rawHandle of candidateHandles) {
93
+ const handle = rawHandle.toLowerCase()
94
+ if (!handle || seenHandles.has(handle)) continue
95
+ seenHandles.add(handle)
96
+ targets.push({
97
+ handle,
98
+ recipient,
99
+ type: 'agent',
100
+ display: recipient,
101
+ role: agent.role || undefined,
102
+ })
103
+ }
104
+ }
105
+
106
+ return targets
107
+ }
108
+
109
+ export function resolveMentionRecipients(text: string, db: Database, workspaceId: number): MentionResolution {
110
+ const tokens = parseMentions(text)
111
+ if (tokens.length === 0) {
112
+ return { tokens: [], unresolved: [], recipients: [], resolved: [] }
113
+ }
114
+
115
+ const targets = getMentionTargets(db, workspaceId)
116
+ const byHandle = new Map<string, MentionTarget>()
117
+ for (const target of targets) {
118
+ byHandle.set(target.handle.toLowerCase(), target)
119
+ }
120
+
121
+ const resolved: MentionTarget[] = []
122
+ const unresolved: string[] = []
123
+ const recipientSeen = new Set<string>()
124
+
125
+ for (const token of tokens) {
126
+ const key = token.toLowerCase()
127
+ const target = byHandle.get(key)
128
+ if (!target) {
129
+ unresolved.push(token)
130
+ continue
131
+ }
132
+ if (!recipientSeen.has(target.recipient)) {
133
+ recipientSeen.add(target.recipient)
134
+ resolved.push(target)
135
+ }
136
+ }
137
+
138
+ return {
139
+ tokens,
140
+ unresolved,
141
+ recipients: resolved.map((item) => item.recipient),
142
+ resolved,
143
+ }
144
+ }
tests/mentions.spec.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+ import { API_KEY_HEADER, createTestAgent, deleteTestAgent, createTestTask, deleteTestTask, createTestUser, deleteTestUser } from './helpers'
3
+
4
+ test.describe('Mentions (@users + @agents)', () => {
5
+ const createdTaskIds: number[] = []
6
+ const createdAgentIds: number[] = []
7
+ const createdUserIds: number[] = []
8
+
9
+ test.afterEach(async ({ request }) => {
10
+ for (const taskId of createdTaskIds.splice(0)) {
11
+ await deleteTestTask(request, taskId)
12
+ }
13
+ for (const agentId of createdAgentIds.splice(0)) {
14
+ await deleteTestAgent(request, agentId)
15
+ }
16
+ for (const userId of createdUserIds.splice(0)) {
17
+ await deleteTestUser(request, userId)
18
+ }
19
+ })
20
+
21
+ test('task description mentions notify both user and agent', async ({ request }) => {
22
+ const { id: agentId, name: agentName } = await createTestAgent(request)
23
+ createdAgentIds.push(agentId)
24
+
25
+ const { id: userId, username } = await createTestUser(request)
26
+ createdUserIds.push(userId)
27
+
28
+ const taskRes = await request.post('/api/tasks', {
29
+ headers: API_KEY_HEADER,
30
+ data: {
31
+ title: `e2e-mention-task-${Date.now()}`,
32
+ description: `Please review @${username} and @${agentName}`,
33
+ },
34
+ })
35
+
36
+ expect(taskRes.status()).toBe(201)
37
+ const taskBody = await taskRes.json()
38
+ const taskId = Number(taskBody.task?.id)
39
+ createdTaskIds.push(taskId)
40
+
41
+ const userNotifsRes = await request.get(`/api/notifications?recipient=${encodeURIComponent(username)}`, {
42
+ headers: API_KEY_HEADER,
43
+ })
44
+ expect(userNotifsRes.status()).toBe(200)
45
+ const userNotifsBody = await userNotifsRes.json()
46
+ expect(userNotifsBody.notifications.some((n: any) => n.type === 'mention' && n.source_type === 'task' && n.source_id === taskId)).toBe(true)
47
+
48
+ const agentNotifsRes = await request.get(`/api/notifications?recipient=${encodeURIComponent(agentName)}`, {
49
+ headers: API_KEY_HEADER,
50
+ })
51
+ expect(agentNotifsRes.status()).toBe(200)
52
+ const agentNotifsBody = await agentNotifsRes.json()
53
+ expect(agentNotifsBody.notifications.some((n: any) => n.type === 'mention' && n.source_type === 'task' && n.source_id === taskId)).toBe(true)
54
+ })
55
+
56
+ test('rejects unknown mention in task description', async ({ request }) => {
57
+ const res = await request.post('/api/tasks', {
58
+ headers: API_KEY_HEADER,
59
+ data: {
60
+ title: `e2e-mention-invalid-${Date.now()}`,
61
+ description: 'invalid mention @does-not-exist-xyz',
62
+ },
63
+ })
64
+
65
+ expect(res.status()).toBe(400)
66
+ const body = await res.json()
67
+ expect(String(body.error || '')).toContain('Unknown mentions')
68
+ })
69
+
70
+ test('rejects unknown mention in comments', async ({ request }) => {
71
+ const { id: taskId } = await createTestTask(request)
72
+ createdTaskIds.push(taskId)
73
+
74
+ const res = await request.post(`/api/tasks/${taskId}/comments`, {
75
+ headers: API_KEY_HEADER,
76
+ data: {
77
+ author: 'system',
78
+ content: 'hello @not-a-real-target-zz',
79
+ },
80
+ })
81
+
82
+ expect(res.status()).toBe(400)
83
+ const body = await res.json()
84
+ expect(String(body.error || '')).toContain('Unknown mentions')
85
+ })
86
+ })