diff --git a/backend/__tests__/unit/models/PgMessage.test.js b/backend/__tests__/unit/models/PgMessage.test.js index 8b73915ba..df21900c3 100644 --- a/backend/__tests__/unit/models/PgMessage.test.js +++ b/backend/__tests__/unit/models/PgMessage.test.js @@ -83,6 +83,24 @@ describe('PG Message model', () => { ); }); + it('finds every substantive agent-active pod in the requested window', async () => { + const since = new Date('2026-08-26T00:00:00.000Z'); + pool.query.mockResolvedValueOnce({ + rows: [{ pod_id: 'busy-pod', message_count: '15', last_at: new Date('2026-08-26T14:00:00.000Z') }], + }); + + const result = await Message.findSubstantiveAgentPodActivity(['quiet-pod', 'busy-pod'], since); + + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('u.is_bot = TRUE'), + [['quiet-pod', 'busy-pod'], since], + ); + expect(pool.query.mock.calls[0][0]).toContain("NOT IN ('commonly-bot', 'commonly-ai-agent')"); + expect(result).toEqual([{ + podId: 'busy-pod', agentMessageCount: 15, lastAt: new Date('2026-08-26T14:00:00.000Z'), + }]); + }); + it('findById returns formatted message', async () => { pool.query.mockResolvedValueOnce({ rows: [ diff --git a/backend/__tests__/unit/services/activityService.recap.test.js b/backend/__tests__/unit/services/activityService.recap.test.js index dc103a287..f3738301c 100644 --- a/backend/__tests__/unit/services/activityService.recap.test.js +++ b/backend/__tests__/unit/services/activityService.recap.test.js @@ -1,8 +1,12 @@ jest.mock('../../../models/Pod', () => ({ find: jest.fn() })); jest.mock('../../../models/Task', () => ({ find: jest.fn() })); +jest.mock('../../../models/pg/Message', () => ({ + findSubstantiveAgentPodActivity: jest.fn().mockResolvedValue([]), +})); const Pod = require('../../../models/Pod'); const Task = require('../../../models/Task'); +const PGMessage = require('../../../models/pg/Message'); const Activity = require('../../../models/Activity'); const User = require('../../../models/User'); const ActivityService = require('../../../services/activityService'); @@ -30,6 +34,7 @@ describe('ActivityService.getRecap', () => { beforeEach(() => { jest.clearAllMocks(); + PGMessage.findSubstantiveAgentPodActivity.mockResolvedValue([]); Pod.find.mockReturnValue(podQuery([pod])); Task.find.mockReturnValue(taskQuery([{ _id: 'board-1', @@ -65,7 +70,9 @@ describe('ActivityService.getRecap', () => { test('projects existing agent activity, direct mentions, and board updates without writing new events', async () => { const result = await ActivityService.getRecap(ownerId, { window: 'today' }); - expect(result.pods).toEqual([expect.objectContaining({ id: 'pod-1', name: pod.name })]); + expect(result.pods).toEqual([expect.objectContaining({ + id: 'pod-1', name: pod.name, activeInWindow: true, agentMessageCount: 1, + })]); expect(result.needsYou).toEqual([expect.objectContaining({ kind: 'mention', podId: 'pod-1', title: 'sprint-impl mentioned you', })]); @@ -80,10 +87,103 @@ describe('ActivityService.getRecap', () => { expect(spy).toHaveBeenCalledWith(ownerId, { limit: 100 }); expect(Pod.find).toHaveBeenCalledWith(expect.objectContaining({ $or: expect.any(Array) })); expect(Task.find).toHaveBeenCalledWith(expect.objectContaining({ - podId: { $in: ['pod-1'] }, updatedAt: { $gte: expect.any(Date) }, + podId: { $in: ['pod-1'] }, $or: expect.any(Array), })); }); + test('surfaces durable board press and decision facts ahead of incidental mentions', async () => { + Task.find.mockReturnValue(taskQuery([ + { + _id: 'press-1', podId: 'pod-1', taskId: 'TASK-201', + title: 'Release the Activity recap', status: 'claimed', updatedAt: new Date(), + prUrl: 'https://github.com/Team-Commonly/commonly/pull/1274', + updates: [{ text: 'Gated #1274 — awaiting human press.', author: 'reviewer', createdAt: new Date() }], + }, + { + _id: 'decision-1', podId: 'pod-1', taskId: 'TASK-202', + title: 'DECIDE: retain unread activity state', status: 'blocked', updatedAt: new Date(), + updates: [{ text: 'A human decision unblocks the implementation.', author: 'architect', createdAt: new Date() }], + }, + { + _id: 'handoff-1', podId: 'pod-1', taskId: 'TASK-203', + title: 'Press the deployment after the smoke test', status: 'blocked', updatedAt: new Date(), + updates: [{ text: 'Waiting for a human to press the deployment.', author: 'operator', createdAt: new Date() }], + }, + ])); + + const result = await ActivityService.getRecap(ownerId, { window: 'today' }); + + expect(result.needsYou).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: 'press', taskId: 'TASK-201', podId: 'pod-1', + prUrl: 'https://github.com/Team-Commonly/commonly/pull/1274', + }), + expect.objectContaining({ kind: 'decide', taskId: 'TASK-202', podId: 'pod-1' }), + expect.objectContaining({ kind: 'handoff', taskId: 'TASK-203', podId: 'pod-1' }), + expect.objectContaining({ kind: 'mention', id: 'message-1' }), + ])); + expect(result.needsYou.map((item) => item.kind)).toEqual(['press', 'decide', 'handoff', 'mention']); + expect(Task.find.mock.results[0].value.select).toHaveBeenCalledWith( + expect.stringContaining('prUrl'), + ); + }); + + test('excludes system bot noise, ranks real seats by substantive updates, and only exposes active pods in the default scope', async () => { + const activePod = { _id: 'pod-2', name: 'Real work pod', type: 'team' }; + Pod.find.mockReturnValue(podQuery([pod, activePod])); + Task.find.mockReturnValue(taskQuery([])); + spy.mockResolvedValue({ + activities: [ + { + id: 'system-1', type: 'summary', actor: { id: 'system', name: 'commonly-bot', type: 'system' }, + action: 'summary', preview: 'Echoed a task update.', timestamp: new Date(), + pod: { id: 'pod-1', name: pod.name }, flags: { isAgentAction: true, isMention: false }, + }, + { + id: 'agent-a', type: 'message', actor: { id: 'agent-a', name: 'alpha', type: 'agent' }, + action: 'message', preview: 'Shipped one change.', timestamp: new Date(Date.now() - 60_000), + pod: { id: 'pod-2', name: activePod.name }, flags: { isAgentAction: true, isMention: false }, + }, + { + id: 'agent-b-1', type: 'message', actor: { id: 'agent-b', name: 'beta', type: 'agent' }, + action: 'message', preview: 'Reviewed a pull request.', timestamp: new Date(Date.now() - 120_000), + pod: { id: 'pod-2', name: activePod.name }, flags: { isAgentAction: true, isMention: false }, + }, + { + id: 'agent-b-2', type: 'message', actor: { id: 'agent-b', name: 'beta', type: 'agent' }, + action: 'message', preview: 'Posted the decision.', timestamp: new Date(Date.now() - 180_000), + pod: { id: 'pod-2', name: activePod.name }, flags: { isAgentAction: true, isMention: false }, + }, + ], + }); + + const result = await ActivityService.getRecap(ownerId, { window: 'today' }); + + expect(result.scope).toBe('active'); + expect(result.pods).toEqual(expect.arrayContaining([ + { id: 'pod-1', name: pod.name, activeInWindow: false, agentMessageCount: 0 }, + { id: 'pod-2', name: activePod.name, activeInWindow: true, agentMessageCount: 3 }, + ])); + expect(result.agents.map((agent) => agent.name)).toEqual(['beta', 'alpha']); + expect(result.agents.map((agent) => agent.name)).not.toContain('commonly-bot'); + }); + + test('derives the active pod selector from all substantive agent messages, not the recap feed page', async () => { + const busyPod = { _id: 'pod-busy', name: 'Busy but active', type: 'team' }; + Pod.find.mockReturnValue(podQuery([pod, busyPod])); + Task.find.mockReturnValue(taskQuery([])); + spy.mockResolvedValue({ activities: [] }); + PGMessage.findSubstantiveAgentPodActivity.mockResolvedValue([{ podId: 'pod-busy', agentMessageCount: 15 }]); + + const result = await ActivityService.getRecap(ownerId, { window: 'today' }); + + expect(PGMessage.findSubstantiveAgentPodActivity).toHaveBeenCalledWith(['pod-1', 'pod-busy'], expect.any(Date)); + expect(result.pods).toEqual(expect.arrayContaining([ + { id: 'pod-1', name: pod.name, activeInWindow: false, agentMessageCount: 0 }, + { id: 'pod-busy', name: busyPod.name, activeInWindow: true, agentMessageCount: 15 }, + ])); + }); + test('rejects a requested pod that is outside the viewer membership', async () => { await expect(ActivityService.getRecap(ownerId, { podId: 'not-a-member-pod' })) .rejects.toThrow('Access denied'); diff --git a/backend/models/pg/Message.ts b/backend/models/pg/Message.ts index 21dc52d86..5b48b67d1 100644 --- a/backend/models/pg/Message.ts +++ b/backend/models/pg/Message.ts @@ -49,6 +49,10 @@ interface PodActivityEntry { lastAt: unknown; } +interface AgentPodActivityEntry extends PodActivityEntry { + agentMessageCount: number; +} + function formatMessage(msg: MessageRow): FormattedMessage { const messageId = msg.id ? msg.id.toString() : ''; const userId = msg.user_id || ''; @@ -511,6 +515,46 @@ class Message { } } + // The Activity recap's default pod scope must describe real agent work in + // the selected window, not merely the first page of its mixed activity + // feed. This is deliberately a grouped database read: a busy pod can have + // more than the feed's display limit before the next active pod's message. + // System summaries are bot-authored too, so exclude their two known seats + // here as well as in the recap projection. + static async findSubstantiveAgentPodActivity( + podIds: unknown[], + since: unknown, + ): Promise { + if (!podIds || !podIds.length) return []; + try { + const podIdStrs = podIds.map((id) => (id as { toString(): string } | undefined)?.toString()).filter(Boolean); + if (!podIdStrs.length) return []; + const result = await (pool as PgPool).query( + `SELECT m.pod_id, COUNT(*) AS message_count, MAX(m.created_at) AS last_at + FROM messages m + JOIN users u ON u._id = m.user_id + WHERE m.pod_id = ANY($1) + AND m.created_at >= $2 + AND m.message_type != 'system' + AND m.content <> '' + AND u.is_bot = TRUE + AND LOWER(COALESCE(u.username, '')) NOT IN ('commonly-bot', 'commonly-ai-agent') + GROUP BY m.pod_id + ORDER BY last_at DESC`, + [podIdStrs, since], + ); + return (result.rows as Array<{ pod_id: string; message_count?: string | number; last_at: unknown }>).map((row) => ({ + podId: row.pod_id, + agentMessageCount: parseInt(String(row.message_count || 0), 10), + lastAt: row.last_at, + })); + } catch (error) { + const e = error as { message?: string }; + console.error('Error in findSubstantiveAgentPodActivity:', e.message); + return []; + } + } + // One row per pod: the given user's most-recent non-system message in each pod. // Powers the agent-profile "pods" list (their last message + when, per pod). static async findLastMessageByUserPerPod( diff --git a/backend/services/activityService.ts b/backend/services/activityService.ts index 85d69ebc4..1ee173c31 100644 --- a/backend/services/activityService.ts +++ b/backend/services/activityService.ts @@ -95,6 +95,61 @@ interface GetRecapOptions { podId?: string; } +type NeedsYouKind = 'mention' | 'approval' | 'press' | 'decide' | 'handoff'; + +interface RecapTask { + _id?: unknown; + podId?: unknown; + taskId?: string; + title?: string; + status?: string; + prUrl?: string | null; + notes?: string | null; + updatedAt?: Date | string | null; + updates?: Array>; +} + +const SYSTEM_ACTIVITY_ACTORS = new Set(['commonly-bot', 'commonly-ai-agent']); + +const isSystemActivity = (activity: ActivityItem): boolean => { + const name = String(activity.actor?.name || '').trim().toLowerCase(); + return activity.actor?.type === 'system' || SYSTEM_ACTIVITY_ACTORS.has(name); +}; + +// The recap is useful only when it tells the human what a real seat did. A +// system summary may be an activity event, but it is not evidence that an +// agent made progress and must not outrank the people doing the work. +const isSubstantiveAgentActivity = (activity: ActivityItem): boolean => ( + activity.type === 'message' + && activity.actor?.type === 'agent' + && !isSystemActivity(activity) + && Boolean(String(activity.preview || activity.content || '').trim()) +); + +const newestTaskUpdate = (updates: Array> = []): Record | null => ( + updates.slice().sort((left, right) => ( + new Date(right.createdAt as string || 0).getTime() - new Date(left.createdAt as string || 0).getTime() + ))[0] || null +); + +const HUMAN_PRESS_HANDOFF = /\b(?:awaiting|waiting\s+(?:on|for)|ready\s+for|safe\s+to|please|needs?)\s+(?:a\s+|the\s+)?(?:human\s+)?(?:to\s+)?(?:press|merge)\b|\bpress[-\s]?gate\b/i; +const PULL_REQUEST_REFERENCE = /(?:\bpr\b|#\d+)/i; +const GATED_PULL_REQUEST = /\b(?:gated|approved|press[-\s]?safe|ready to merge)\b/i; + +const taskAttentionKind = (task: RecapTask): NeedsYouKind | null => { + if (/^\s*DECIDE\b/i.test(String(task.title || ''))) return 'decide'; + + const text = [ + task.title, + task.notes, + ...(Array.isArray(task.updates) ? task.updates.map((update) => update.text) : []), + ].filter((value) => typeof value === 'string').join('\n'); + const isGatedPullRequest = PULL_REQUEST_REFERENCE.test(text) && GATED_PULL_REQUEST.test(text); + + if (isGatedPullRequest) return 'press'; + return HUMAN_PRESS_HANDOFF.test(text) ? 'handoff' : null; +}; + interface ComputeFlagsOptions { actor?: ActorInfo; type?: string; @@ -118,7 +173,7 @@ class ActivityService { ): Promise> { const window = options.window === '7d' ? '7d' : 'today'; const since = new Date(Date.now() - (window === '7d' ? 7 : 1) * 24 * 60 * 60 * 1000); - const pods: PodDoc[] = await Pod.find({ + const memberPods: PodDoc[] = await Pod.find({ $or: [ { createdBy: userId }, { 'members.userId': userId }, @@ -127,19 +182,67 @@ class ActivityService { }).select('_id name type').lean(); const requestedPodId = typeof options.podId === 'string' ? options.podId : ''; - const scopedPods = requestedPodId - ? pods.filter((pod) => String(pod._id) === requestedPodId) - : pods; - if (requestedPodId && scopedPods.length === 0) { + const requestsAllPods = requestedPodId === 'all'; + const requestedPods = requestedPodId && !requestsAllPods + ? memberPods.filter((pod) => String(pod._id) === requestedPodId) + : []; + if (requestedPodId && !requestsAllPods && requestedPods.length === 0) { throw new Error('Access denied'); } - - const scopedPodIds = new Set(scopedPods.map((pod) => String(pod._id))); const feed = await ActivityService.getUserFeed(userId, { limit: 100 }); - const activities = ((feed.activities as ActivityItem[] | undefined) || []).filter((activity) => { + const windowActivities = ((feed.activities as ActivityItem[] | undefined) || []).filter((activity) => { const timestamp = activity.timestamp ? new Date(activity.timestamp).getTime() : 0; - return timestamp >= since.getTime() - && (!requestedPodId || (activity.pod && scopedPodIds.has(activity.pod.id))); + return timestamp >= since.getTime(); + }); + const activePodIds = new Set( + windowActivities + .filter(isSubstantiveAgentActivity) + .map((activity) => activity.pod?.id) + .filter((podId): podId is string => Boolean(podId)), + ); + // getUserFeed is intentionally a display page. Do not let its limit turn + // "active in this window" into "happened to be in the latest 100 rows": + // the grouped PG read sees every substantive agent message in each member + // pod during the requested window. The feed contribution above remains a + // fallback when the grouped message read is unavailable. + const agentMessageCounts = new Map(); + const persistedPodIds = new Set(); + if (PGMessage && memberPods.length) { + const persistedActivity = await (PGMessage as { + findSubstantiveAgentPodActivity: (podIds: unknown[], sinceAt: Date) => Promise>; + }).findSubstantiveAgentPodActivity(memberPods.map((pod) => pod._id), since); + persistedActivity.forEach((entry) => activePodIds.add(String(entry.podId))); + persistedActivity.forEach((entry) => { + const podId = String(entry.podId); + persistedPodIds.add(podId); + agentMessageCounts.set(podId, Number(entry.agentMessageCount || 0)); + }); + windowActivities + .filter(isSubstantiveAgentActivity) + .forEach((activity) => { + if (!activity.pod?.id || persistedPodIds.has(activity.pod.id)) return; + agentMessageCounts.set( + activity.pod.id, + (agentMessageCounts.get(activity.pod.id) || 0) + 1, + ); + }); + } + const activePods = memberPods.filter((pod) => activePodIds.has(String(pod._id))); + const scopedPods = requestsAllPods ? memberPods : requestedPodId ? requestedPods : activePods; + const scopedPodIds = new Set(scopedPods.map((pod) => String(pod._id))); + const recapPods = memberPods.map((pod) => { + const id = String(pod._id); + const agentMessageCount = agentMessageCounts.get(id) || windowActivities.filter((activity) => ( + isSubstantiveAgentActivity(activity) && activity.pod?.id === id + )).length; + return { id, name: pod.name, activeInWindow: activePodIds.has(id), agentMessageCount }; + }); + const activities = windowActivities.filter((activity) => { + if (requestsAllPods) return true; + if (requestedPodId) return Boolean(activity.pod && scopedPodIds.has(activity.pod.id)); + return !activity.pod || scopedPodIds.has(activity.pod.id); }); const acknowledgedMentionIds = new Set( ((feed.acknowledgedMentionIds as unknown[] | undefined) || []).map((id) => String(id)), @@ -163,7 +266,7 @@ class ActivityService { const agents = new Map(); activities - .filter((activity) => activity.actor?.type === 'agent' || activity.flags?.isAgentAction) + .filter(isSubstantiveAgentActivity) .forEach((activity) => { const actorId = String(activity.actor?.id || activity.actor?.name || 'unknown-agent'); const name = activity.actor?.name || 'Agent'; @@ -207,13 +310,22 @@ class ActivityService { : `Posted ${agent.messageCount} updates${podNames[0] ? ` across ${podNames.slice(0, 2).join(' and ')}` : ''}.`, }; }) - .sort((a, b) => new Date(b.lastActiveAt || 0).getTime() - new Date(a.lastActiveAt || 0).getTime()); + .sort((a, b) => ( + b.messageCount - a.messageCount + || new Date(b.lastActiveAt || 0).getTime() - new Date(a.lastActiveAt || 0).getTime() + )); // Approvals are a decision queue, not an activity sample: query the // existing authoritative pending-approval reader separately so a busy // pod cannot push an older decision behind getUserFeed's display page. // Mentions remain a bounded recent interrupt list and are removed only by // their explicit acknowledgement, never by feed read-state. + // A pod selector narrows the recap and board. The default is deliberately + // quieter (only pods with substantive agent work), but it must not hide a + // human decision in another accessible pod: Needs you is the interrupt + // surface, not a feed filter. + const queuePods = requestedPodId ? scopedPods : memberPods; + const queuePodIds = new Set(queuePods.map((pod) => String(pod._id))); const pendingApprovals = await ActivityService.getPendingApprovals(userId) as Array<{ _id?: unknown; id?: unknown; @@ -228,10 +340,10 @@ class ActivityService { updatedAt?: Date | string; }>; const approvalItems: ActivityItem[] = pendingApprovals - .filter((approval) => !requestedPodId || scopedPodIds.has(String(approval.podId))) + .filter((approval) => queuePodIds.has(String(approval.podId))) .map((approval) => { const podId = approval.podId ? String(approval.podId) : ''; - const pod = scopedPods.find((candidate) => String(candidate._id) === podId); + const pod = queuePods.find((candidate) => String(candidate._id) === podId); return { id: String(approval._id || approval.id || ''), type: approval.type || 'approval_needed', @@ -257,8 +369,9 @@ class ActivityService { // an approval request; otherwise every message becomes a human action. return activity.type === 'approval_needed' && approval?.status === 'pending'; }; + const queueActivitySource = requestedPodId ? activities : windowActivities; const queueCandidates = new Map(); - [...activities, ...approvalItems].forEach((activity) => { + [...queueActivitySource, ...approvalItems].forEach((activity) => { if (!queueCandidates.has(activity.id)) queueCandidates.set(activity.id, activity); }); const newestFirst = (left: ActivityItem, right: ActivityItem) => ( @@ -269,39 +382,89 @@ class ActivityService { .sort(newestFirst); const mentionQueue = Array.from(queueCandidates.values()) .filter((activity) => activity.flags?.isMention && !acknowledgedMentionIds.has(String(activity.id))) - .sort(newestFirst) - .slice(0, Math.max(0, 12 - approvalQueue.length)); - const needsYou = [...approvalQueue, ...mentionQueue] - .sort(newestFirst) + .sort(newestFirst); + const activityAttention = [...approvalQueue, ...mentionQueue] .map((activity) => { const isApproval = isPendingApproval(activity); return { id: activity.id, - kind: isApproval ? 'approval' : 'mention', + kind: (isApproval ? 'approval' : 'mention') as NeedsYouKind, title: isApproval ? 'Approval requested' : `${activity.actor?.name || 'Someone'} mentioned you`, detail: String(activity.preview || activity.content || '').replace(/\s+/g, ' ').trim().slice(0, 180), podId: activity.pod?.id || null, podName: activity.pod?.name || 'Direct activity', timestamp: activity.timestamp, + taskId: null, }; }); - let board: Array> = []; - if (scopedPods.length > 0) { - const taskRows: Array> = await Task.find({ - podId: { $in: scopedPods.map((pod) => pod._id) }, - updatedAt: { $gte: since }, + // Board facts are durable until the task changes. Query current non-done + // rows alongside the window's board delta so an approved PR from yesterday + // still reaches the human today instead of disappearing at midnight. + const taskRows: RecapTask[] = queuePods.length > 0 + ? await Task.find({ + podId: { $in: queuePods.map((pod) => pod._id) }, + $or: [ + { status: { $ne: 'done' } }, + { updatedAt: { $gte: since } }, + ], }) - .select('podId taskId title status updatedAt updates') + .select('podId taskId title status notes prUrl updatedAt updates') .sort({ updatedAt: -1 }) - .limit(24) - .lean(); - const podNames = new Map(scopedPods.map((pod) => [String(pod._id), pod.name])); - board = taskRows.map((task) => { + .limit(100) + .lean() as RecapTask[] + : []; + const podNames = new Map(memberPods.map((pod) => [String(pod._id), pod.name])); + const taskAttention = taskRows + .filter((task) => task.status !== 'done') + .map((task) => { + const kind = taskAttentionKind(task); + if (!kind) return null; + const latestUpdate = newestTaskUpdate(task.updates); + const evidence = String(latestUpdate?.text || task.notes || '').replace(/\s+/g, ' ').trim(); + const taskId = String(task.taskId || 'Task'); + return { + id: `task:${String(task._id || taskId)}`, + kind, + title: String(task.title || taskId), + detail: kind === 'press' + ? `A gated pull request is ready to press.${evidence ? ` ${evidence}` : ''}` + : kind === 'decide' + ? `Waiting for a decision.${evidence ? ` ${evidence}` : ''}` + : `Waiting for a human handoff.${evidence ? ` ${evidence}` : ''}`, + podId: task.podId ? String(task.podId) : null, + podName: podNames.get(String(task.podId)) || 'Pod', + timestamp: latestUpdate?.createdAt as Date | string | null || task.updatedAt || null, + taskId, + prUrl: task.prUrl || null, + }; + }) + .filter((item): item is NonNullable => Boolean(item)); + const attentionPriority: Record = { + press: 0, + decide: 1, + handoff: 2, + approval: 3, + mention: 4, + }; + const needsYou = [...taskAttention, ...activityAttention] + .sort((left, right) => ( + attentionPriority[left.kind] - attentionPriority[right.kind] + || new Date(right.timestamp || 0).getTime() - new Date(left.timestamp || 0).getTime() + )) + .slice(0, 12) + .map((activity) => ({ + ...activity, + detail: activity.detail.slice(0, 180), + })); + + const board = taskRows + .filter((task) => scopedPodIds.has(String(task.podId)) + && new Date(task.updatedAt || 0).getTime() >= since.getTime()) + .slice(0, 24) + .map((task) => { const updates = Array.isArray(task.updates) ? task.updates as Array> : []; - const lastUpdate = updates - .slice() - .sort((a, b) => new Date(b.createdAt as string || 0).getTime() - new Date(a.createdAt as string || 0).getTime())[0]; + const lastUpdate = newestTaskUpdate(updates); return { id: String(task._id), taskId: task.taskId, @@ -319,14 +482,13 @@ class ActivityService { : null, }; }); - } return { window, since: since.toISOString(), generatedAt: new Date().toISOString(), - scope: requestedPodId || 'all', - pods: pods.map((pod) => ({ id: String(pod._id), name: pod.name })), + scope: requestedPodId || 'active', + pods: recapPods, needsYou, agents: agentRecaps, board, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 47f71df36..5613d3c25 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -31,20 +31,29 @@ "controlsAriaLabel": "Activity controls", "windowAriaLabel": "Activity window", "windows": { "today": "Today", "7d": "7 days" }, - "podScopeLabel": "Pod scope", - "allPods": "All pods", + "podScopeLabel": "Scope ·", + "activePods": "Active pods ({{count}})", + "allPods": "All pods ({{count}})", + "podOption": "{{name}} ({{count}})", + "scopeGroups": { + "active": "Active this window ({{count}})", + "other": "Other pods" + }, "loadFailed": "Activity could not be loaded. Try again.", "openThread": "Open thread", + "openBoard": "Open board", + "openPr": "Open PR", + "openRow": "Open row", "lastActive": "Active {{time}} ago", "updatesCount_one": "{{count}} update", "updatesCount_other": "{{count}} updates", "needsYou": { "eyebrow": "Decision queue", "title": "Needs you", - "description": "Only direct mentions and pending approvals appear here.", + "description": "Gated pull requests, decisions, human handoffs, direct mentions, and pending approvals appear here.", "emptyTitle": "Nothing is waiting on you", - "emptyDescription": "New mentions and approval requests will appear here when they need a response.", - "kinds": { "mention": "Mention", "approval": "Approval" } + "emptyDescription": "New mentions, decisions, press-ready pull requests, and human handoffs will appear here.", + "kinds": { "mention": "Mention", "approval": "Approval", "press": "Ready to press", "decide": "Decision needed", "handoff": "Human handoff" } }, "dayZero": { "kind": "Get started", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index cf52cc212..5c5d50fc9 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -31,20 +31,29 @@ "controlsAriaLabel": "动态控制项", "windowAriaLabel": "动态时间范围", "windows": { "today": "今天", "7d": "7 天" }, - "podScopeLabel": "Pod 范围", - "allPods": "所有 Pod", + "podScopeLabel": "范围 ·", + "activePods": "活跃 Pod({{count}})", + "allPods": "所有 Pod({{count}})", + "podOption": "{{name}}({{count}})", + "scopeGroups": { + "active": "此时间范围内活跃({{count}})", + "other": "其他 Pod" + }, "loadFailed": "无法加载动态,请重试。", "openThread": "打开讨论", + "openBoard": "打开看板", + "openPr": "打开 PR", + "openRow": "打开任务", "lastActive": "{{time}}前活跃", "updatesCount_one": "{{count}} 条更新", "updatesCount_other": "{{count}} 条更新", "needsYou": { "eyebrow": "决策队列", "title": "需要你处理", - "description": "这里只显示直接提及和待处理的审批。", + "description": "这里显示等待合并的 PR、需要决策的事项、等待人工交接、直接提及和待处理的审批。", "emptyTitle": "没有事项在等待你", - "emptyDescription": "有需要回复的提及或审批请求时,会显示在这里。", - "kinds": { "mention": "提及", "approval": "审批" } + "emptyDescription": "新的提及、决策、等待合并的 PR 和等待人工交接会显示在这里。", + "kinds": { "mention": "提及", "approval": "审批", "press": "等待合并", "decide": "需要决策", "handoff": "等待人工交接" } }, "dayZero": { "kind": "开始使用", diff --git a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx index 6dfc5bce9..71bcb14c5 100644 --- a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx @@ -22,7 +22,10 @@ const CurrentPath = () => { }; const recap = { - pods: [{ id: 'pod-1', name: 'Launch pod' }], + pods: [ + { id: 'pod-1', name: 'Launch pod', activeInWindow: true, agentMessageCount: 2 }, + { id: 'pod-2', name: 'Quiet pod', activeInWindow: false, agentMessageCount: 0 }, + ], needsYou: [{ id: 'mention-1', kind: 'mention', title: 'Review requested', detail: 'A direct mention.', podId: 'pod-1', podName: 'Launch pod', timestamp: '2026-08-26T11:00:00.000Z', @@ -85,6 +88,69 @@ describe('V2ActivityPage', () => { expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/pod-1'); }); + test('defaults to active pods and groups every selectable pod by current activity', async () => { + renderPage(); + await screen.findByText('Review requested'); + + const scope = screen.getByLabelText(/Scope/); + expect(scope).toHaveValue('active'); + expect(scope.querySelectorAll('option')).toHaveLength(4); + expect(screen.getByRole('option', { name: 'Active pods (1)' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'All pods (2)' })).toBeInTheDocument(); + expect(scope.querySelector('optgroup[label="Active this window (1)"]')).toBeInTheDocument(); + expect(scope.querySelector('optgroup[label="Other pods"]')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Launch pod (2)' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Quiet pod (0)' })).toBeInTheDocument(); + + fireEvent.change(scope, { target: { value: 'pod-1' } }); + await waitFor(() => expect(mockGet).toHaveBeenLastCalledWith('/api/activity/recap', expect.objectContaining({ + params: { window: 'today', podId: 'pod-1' }, + }))); + + fireEvent.change(scope, { target: { value: 'all' } }); + await waitFor(() => expect(mockGet).toHaveBeenLastCalledWith('/api/activity/recap', expect.objectContaining({ + params: { window: 'today', podId: 'all' }, + }))); + }); + + test('opens a gated pull request from a press fact', async () => { + mockGet.mockResolvedValue({ + data: { + ...recap, + needsYou: [{ + id: 'task:press-1', kind: 'press', taskId: 'TASK-201', + title: 'Release the Activity recap', detail: 'Waiting for a human press.', + podId: 'pod-1', podName: 'Launch pod', timestamp: '2026-08-26T11:00:00.000Z', + prUrl: 'https://github.com/Team-Commonly/commonly/pull/1274', + }], + }, + }); + renderPage(); + + expect(await screen.findByText('Release the Activity recap')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open PR' })).toHaveAttribute( + 'href', 'https://github.com/Team-Commonly/commonly/pull/1274', + ); + }); + + test('opens a DECIDE board row rather than treating it as a PR press', async () => { + mockGet.mockResolvedValue({ + data: { + ...recap, + needsYou: [{ + id: 'task:decide-1', kind: 'decide', taskId: 'TASK-202', + title: 'DECIDE: retain unread activity state', detail: 'Waiting for a decision.', + podId: 'pod-1', podName: 'Launch pod', timestamp: '2026-08-26T11:00:00.000Z', + }], + }, + }); + renderPage(); + + expect(await screen.findByText('DECIDE: retain unread activity state')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open row' })); + expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/pod-1/board?taskId=TASK-202'); + }); + test('acknowledges a mention explicitly instead of treating a feed read as acknowledgement', async () => { mockGet .mockResolvedValueOnce({ data: recap }) @@ -106,6 +172,8 @@ describe('V2ActivityPage', () => { renderPage(); expect(await screen.findByText('Nothing is waiting on you')).toBeInTheDocument(); + expect(screen.getByText('New mentions, decisions, press-ready pull requests, and human handoffs will appear here.')).toBeInTheDocument(); + expect(screen.queryByText(/approval requests will appear/i)).not.toBeInTheDocument(); expect(screen.queryByText(/0 needs you/i)).not.toBeInTheDocument(); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 6db88262d..9900cf561 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -447,6 +447,12 @@ describe('v2 layout invariants (CSS rule presence)', () => { // only one character of a task title — an overflow-free but unusable // primary identifier, which violates the craft baseline rule. expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__board-row \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\)/); + // The filter still needs to be usable at 390px after its label becomes + // visually redundant with the selected "Active pods" option. Keeping a + // zero-min grid track here is what prevents a long pod name from forcing + // the time-window control off screen. + expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__controls \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\) minmax\(0, 120px\)/); + expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__scope-label \{ display: none; \}/); }); test('Activity queue actions distinguish an action from the thread handoff', () => { diff --git a/frontend/src/v2/components/V2ActivityPage.tsx b/frontend/src/v2/components/V2ActivityPage.tsx index 6049af17b..194616148 100644 --- a/frontend/src/v2/components/V2ActivityPage.tsx +++ b/frontend/src/v2/components/V2ActivityPage.tsx @@ -27,12 +27,14 @@ interface AgentRecap { interface NeedsYouItem { id: string; - kind: 'mention' | 'approval'; + kind: 'mention' | 'approval' | 'press' | 'decide' | 'handoff'; title: string; detail: string; podId: string | null; podName: string; timestamp: string | null; + taskId?: string | null; + prUrl?: string | null; } interface BoardItem { @@ -47,7 +49,12 @@ interface BoardItem { } interface ActivityRecap { - pods: Array<{ id: string; name: string }>; + pods: Array<{ + id: string; + name: string; + activeInWindow: boolean; + agentMessageCount: number; + }>; needsYou: NeedsYouItem[]; agents: AgentRecap[]; board: BoardItem[]; @@ -68,7 +75,7 @@ const V2ActivityPage: React.FC = () => { const navigate = useNavigate(); const { t } = useTranslation(); const [window, setWindow] = useState('today'); - const [podId, setPodId] = useState('all'); + const [podId, setPodId] = useState('active'); const [recap, setRecap] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -84,7 +91,7 @@ const V2ActivityPage: React.FC = () => { const token = localStorage.getItem('token'); axios.get('/api/activity/recap', { headers: { 'x-auth-token': token ?? '' }, - params: { window, ...(podId !== 'all' ? { podId } : {}) }, + params: { window, ...(podId !== 'active' ? { podId } : {}) }, }) .then((response) => { if (active) setRecap(response.data); @@ -104,6 +111,13 @@ const V2ActivityPage: React.FC = () => { if (targetPodId) navigate(`/v2/pods/${targetPodId}`); }; + const openTaskBoard = (item: NeedsYouItem) => { + if (item.podId) { + const taskQuery = item.taskId ? `?taskId=${encodeURIComponent(item.taskId)}` : ''; + navigate(`/v2/pods/${item.podId}/board${taskQuery}`); + } + }; + const openFirstBoard = () => { const firstPod = recap?.pods[0]; if (firstPod) { @@ -153,9 +167,12 @@ const V2ActivityPage: React.FC = () => { } }; - const isDayZero = podId === 'all' + const isDayZero = podId === 'active' + && recap?.needsYou.length === 0 && recap?.agents.length === 0 && recap.board.length === 0; + const activePods = (recap?.pods || []).filter((pod) => pod.activeInWindow); + const otherPods = (recap?.pods || []).filter((pod) => !pod.activeInWindow); return (
@@ -179,10 +196,28 @@ const V2ActivityPage: React.FC = () => { ))}
@@ -249,7 +284,13 @@ const V2ActivityPage: React.FC = () => {
{recap.needsYou.map((item) => (
- +
{t(`activity.needsYou.kinds.${item.kind}`)}
{item.title} @@ -272,9 +313,28 @@ const V2ActivityPage: React.FC = () => { {acknowledgingMentionId === item.id ? t('activity.mention.working') : t('activity.mention.acknowledge')} )} - + {item.kind === 'press' ? ( + item.prUrl ? ( + + {t('activity.openPr')} + + ) : ( + + ) + ) : (item.kind === 'decide' || item.kind === 'handoff') ? ( + + ) : ( + + )}
))} diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index f543365a7..09dd0bac1 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -7551,6 +7551,20 @@ body.modern-ui.v2-canvas { box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } +.v2-activity__scope { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.v2-activity__scope-label { + color: var(--v2-text-muted); + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + .v2-activity__scope select { min-height: 36px; max-width: 180px; @@ -7665,6 +7679,21 @@ body.modern-ui.v2-canvas { color: #a85d08; } +.v2-activity__queue-row--press .v2-activity__queue-mark { + background: var(--v2-warning-soft); + color: #a85d08; +} + +.v2-activity__queue-row--decide .v2-activity__queue-mark { + background: var(--v2-danger-soft); + color: #b42318; +} + +.v2-activity__queue-row--handoff .v2-activity__queue-mark { + background: var(--v2-surface-hover); + color: var(--v2-text-secondary); +} + .v2-activity__queue-row--onboarding .v2-activity__queue-mark { background: var(--v2-surface-hover); color: var(--v2-text-primary); @@ -7712,6 +7741,7 @@ body.modern-ui.v2-canvas { .v2-activity__queue-actions, .v2-activity__queue-row button, +.v2-activity__queue-row .v2-activity__queue-action, .v2-activity__board-meta button { display: flex; align-items: center; @@ -7719,6 +7749,7 @@ body.modern-ui.v2-canvas { } .v2-activity__queue-row button, +.v2-activity__queue-row .v2-activity__queue-action, .v2-activity__board-meta button { min-height: 32px; padding: 0 10px; @@ -7736,11 +7767,24 @@ body.modern-ui.v2-canvas { transition: background 80ms ease, border-color 80ms ease, color 80ms ease; } +.v2-root .v2-activity__queue-actions .v2-activity__queue-action { + border-color: var(--v2-accent); + background: var(--v2-accent); + color: var(--v2-surface); + text-decoration: none; + transition: background 80ms ease, border-color 80ms ease, color 80ms ease; +} + .v2-root .v2-activity__queue-actions button:hover:not(:disabled) { border-color: var(--v2-accent-strong); background: var(--v2-accent-strong); } +.v2-root .v2-activity__queue-actions .v2-activity__queue-action:hover { + border-color: var(--v2-accent-strong); + background: var(--v2-accent-strong); +} + .v2-root .v2-activity__queue-actions button.v2-activity__queue-action--secondary { border-color: var(--v2-border); background: var(--v2-surface-hover); @@ -7916,12 +7960,23 @@ body.modern-ui.v2-canvas { .v2-activity__controls { display: grid; - grid-template-columns: minmax(0, 1fr) 110px; + /* The shell rail leaves the content pane narrow at 390px. 120px still + fits “Active pods”, while leaving each window button enough room to + keep “7 days” on one line. */ + grid-template-columns: minmax(0, 1fr) minmax(0, 120px); width: 100%; } .v2-activity__window { width: 100%; } .v2-root button.v2-activity__window-button { flex: 1 1 0; } - .v2-activity__scope select { width: 100%; max-width: none; } + .v2-activity__scope { width: 100%; } + .v2-activity__scope-label { display: none; } + .v2-activity__scope select { + width: 100%; + max-width: none; + /* A native select reserves its own arrow space. Tighten only on phones + so the complete “Active pods” label remains visible beside the rail. */ + padding: 0 22px 0 8px; + } .v2-activity__section-heading > p { text-align: left; } .v2-activity__agent-grid { grid-template-columns: minmax(0, 1fr); }