Spaces:
Sleeping
Sleeping
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 +50 -0
- src/app/api/tasks/[id]/comments/route.ts +14 -8
- src/app/api/tasks/[id]/route.ts +31 -0
- src/app/api/tasks/route.ts +23 -0
- src/components/panels/task-board-panel.tsx +172 -6
- src/lib/__tests__/db-helpers.test.ts +8 -0
- src/lib/db.ts +2 -9
- src/lib/mentions.ts +144 -0
- tests/mentions.spec.ts +86 -0
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 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 178 |
-
|
| 179 |
-
db_helpers.ensureTaskSubscription(taskId,
|
| 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(
|
| 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 |
-
<
|
| 883 |
value={commentText}
|
| 884 |
-
onChange={
|
| 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 |
-
<
|
| 1061 |
id="create-description"
|
| 1062 |
value={formData.description}
|
| 1063 |
-
onChange={(
|
| 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 |
-
<
|
| 1230 |
id="edit-description"
|
| 1231 |
value={formData.description}
|
| 1232 |
-
onChange={(
|
| 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 |
-
|
| 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 |
+
})
|