diff --git a/backend/__tests__/unit/routes/activity.identity.test.js b/backend/__tests__/unit/routes/activity.identity.test.js index 6fbae5dbf4..229056d2cc 100644 --- a/backend/__tests__/unit/routes/activity.identity.test.js +++ b/backend/__tests__/unit/routes/activity.identity.test.js @@ -21,8 +21,10 @@ describe('activity route identity handling', () => { }); jest.doMock('../../../services/activityService', () => ({ getUserFeed: jest.fn(async () => ({ activities: [], hasMore: false })), + getRecap: jest.fn(async () => ({ needsYou: [], agents: [], board: [] })), getPodFeed: jest.fn(async () => ({ activities: [], hasMore: false })), getPendingApprovals: jest.fn(async () => []), + acknowledgeMention: jest.fn(async () => ({ success: true })), toggleLike: jest.fn(async () => ({ success: true })), addReply: jest.fn(async () => ({ success: true })), approveActivity: jest.fn(async () => ({ success: true })), diff --git a/backend/__tests__/unit/routes/activity.read.test.js b/backend/__tests__/unit/routes/activity.read.test.js index fe53121ea4..2beaafd0d7 100644 --- a/backend/__tests__/unit/routes/activity.read.test.js +++ b/backend/__tests__/unit/routes/activity.read.test.js @@ -9,8 +9,10 @@ jest.mock('../../../middleware/auth', () => (req, res, next) => { jest.mock('../../../services/activityService', () => ({ getUserFeed: jest.fn(async () => ({ activities: [], hasMore: false })), + getRecap: jest.fn(async () => ({ needsYou: [], agents: [], board: [] })), getPodFeed: jest.fn(async () => ({ activities: [], hasMore: false })), getPendingApprovals: jest.fn(async () => []), + acknowledgeMention: jest.fn(async () => ({ success: true })), toggleLike: jest.fn(async () => ({ success: true })), addReply: jest.fn(async () => ({ success: true })), approveActivity: jest.fn(async () => ({ success: true })), @@ -34,6 +36,16 @@ describe('activity read routes', () => { expect(ActivityService.getUnreadCount).toHaveBeenCalled(); }); + it('GET /api/activity/recap validates its small fixed window vocabulary', async () => { + await request(app).get('/api/activity/recap?window=7d&podId=pod-1').expect(200); + expect(ActivityService.getRecap).toHaveBeenCalledWith('user123', { + window: '7d', + podId: 'pod-1', + }); + + await request(app).get('/api/activity/recap?window=month').expect(400); + }); + it('POST /api/activity/mark-read with all:true calls markRead', async () => { await request(app).post('/api/activity/mark-read').send({ all: true }).expect(200); expect(ActivityService.markRead).toHaveBeenCalledWith('user123', expect.objectContaining({ all: true })); @@ -42,4 +54,9 @@ describe('activity read routes', () => { it('POST /api/activity/mark-read without args returns 400', async () => { await request(app).post('/api/activity/mark-read').send({}).expect(400); }); + + it('POST /api/activity/:id/acknowledge uses the dedicated mention acknowledgement', async () => { + await request(app).post('/api/activity/mention-1/acknowledge').expect(200); + expect(ActivityService.acknowledgeMention).toHaveBeenCalledWith('user123', 'mention-1'); + }); }); diff --git a/backend/__tests__/unit/services/activityService.agentMessages.test.js b/backend/__tests__/unit/services/activityService.agentMessages.test.js new file mode 100644 index 0000000000..76db0d94f4 --- /dev/null +++ b/backend/__tests__/unit/services/activityService.agentMessages.test.js @@ -0,0 +1,31 @@ +jest.mock('../../../models/pg/Message', () => ({ findByPodId: jest.fn() })); + +const PGMessage = require('../../../models/pg/Message'); +const ActivityService = require('../../../services/activityService'); + +describe('ActivityService.getMessageActivities', () => { + afterEach(() => jest.restoreAllMocks()); + + test('uses the persisted bot flag and preserves the Postgres pod id for an agent update', async () => { + PGMessage.findByPodId.mockResolvedValue([{ + id: 'message-1', + pod_id: 'pod-1', + content: 'The verification suite passed.', + created_at: new Date(), + userId: { _id: 'agent-1', username: 'release-bot', isBot: true }, + }]); + jest.spyOn(ActivityService, 'isAgentUsername').mockReturnValue(false); + + const activities = await ActivityService.getMessageActivities( + ['pod-1'], + new Map([['pod-1', { _id: 'pod-1', name: 'Release pod' }]]), + { filter: 'agents' }, + ); + + expect(activities).toEqual([expect.objectContaining({ + actor: expect.objectContaining({ type: 'agent', name: 'release-bot' }), + pod: { id: 'pod-1', name: 'Release pod' }, + flags: expect.objectContaining({ isAgentAction: true }), + })]); + }); +}); diff --git a/backend/__tests__/unit/services/activityService.recap.test.js b/backend/__tests__/unit/services/activityService.recap.test.js new file mode 100644 index 0000000000..dc103a287a --- /dev/null +++ b/backend/__tests__/unit/services/activityService.recap.test.js @@ -0,0 +1,229 @@ +jest.mock('../../../models/Pod', () => ({ find: jest.fn() })); +jest.mock('../../../models/Task', () => ({ find: jest.fn() })); + +const Pod = require('../../../models/Pod'); +const Task = require('../../../models/Task'); +const Activity = require('../../../models/Activity'); +const User = require('../../../models/User'); +const ActivityService = require('../../../services/activityService'); + +const ownerId = 'owner-1'; +const pod = { _id: 'pod-1', name: 'Activity source pod', type: 'team' }; + +const podQuery = (pods) => ({ + select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue(pods) }), +}); + +const taskQuery = (tasks) => ({ + select: jest.fn().mockReturnValue({ + sort: jest.fn().mockReturnValue({ + limit: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue(tasks) }), + }), + }), +}); + +describe('ActivityService.getRecap', () => { + let spy; + let findByIdSpy; + let pendingApprovalsSpy; + let userFindByIdSpy; + + beforeEach(() => { + jest.clearAllMocks(); + Pod.find.mockReturnValue(podQuery([pod])); + Task.find.mockReturnValue(taskQuery([{ + _id: 'board-1', + podId: 'pod-1', + taskId: 'TASK-068', + title: 'Activity tab', + status: 'claimed', + updatedAt: new Date(), + updates: [{ text: 'Implementation began.', author: 'sprint-impl', createdAt: new Date() }], + }])); + spy = jest.spyOn(ActivityService, 'getUserFeed').mockResolvedValue({ + activities: [{ + id: 'message-1', + type: 'message', + actor: { id: 'agent-1', name: 'sprint-impl', type: 'agent' }, + action: 'posted a message', + preview: 'Checks passed.', + timestamp: new Date(), + pod: { id: 'pod-1', name: pod.name }, + flags: { isAgentAction: true, isMention: true }, + }], + }); + pendingApprovalsSpy = jest.spyOn(ActivityService, 'getPendingApprovals').mockResolvedValue([]); + }); + + afterEach(() => { + spy?.mockRestore(); + findByIdSpy?.mockRestore(); + pendingApprovalsSpy?.mockRestore(); + userFindByIdSpy?.mockRestore(); + }); + + 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.needsYou).toEqual([expect.objectContaining({ + kind: 'mention', podId: 'pod-1', title: 'sprint-impl mentioned you', + })]); + expect(result.agents).toEqual([expect.objectContaining({ + id: 'agent-1', name: 'sprint-impl', messageCount: 1, + updates: [expect.objectContaining({ content: 'Checks passed.' })], + })]); + expect(result.board).toEqual([expect.objectContaining({ + taskId: 'TASK-068', title: 'Activity tab', status: 'claimed', + lastUpdate: expect.objectContaining({ text: 'Implementation began.' }), + })]); + 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) }, + })); + }); + + 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'); + }); + + test('does not mistake the approval.status default on an ordinary message for a request', async () => { + // Build the stored document with the real schema. Its nested default is + // the production condition that caused every ordinary activity to be + // projected as an approval; a hand-written missing approval field would + // not reproduce it. + const storedMessage = new Activity({ + type: 'message', + action: 'posted a message', + content: 'An ordinary update.', + }); + expect(storedMessage.approval.status).toBe('pending'); + spy.mockResolvedValue({ + activities: [{ + id: 'message-with-defaulted-approval', + type: 'message', + actor: { id: 'human-1', name: 'A human', type: 'human' }, + action: 'posted a message', + preview: 'An ordinary update.', + timestamp: new Date(), + pod: { id: 'pod-1', name: pod.name }, + approval: storedMessage.approval.toObject(), + flags: { isAgentAction: false, isMention: false }, + }], + }); + + const result = await ActivityService.getRecap(ownerId, { window: 'today' }); + + expect(result.needsYou).toEqual([]); + }); + + test('keeps an actual pending approval in the decision queue', async () => { + spy.mockResolvedValue({ + activities: [{ + id: 'approval-1', + type: 'approval_needed', + actor: { id: 'agent-1', name: 'release-agent', type: 'agent' }, + action: 'approval_needed', + preview: 'Approve access to Production.', + timestamp: new Date(), + pod: { id: 'pod-1', name: pod.name }, + approval: { status: 'pending' }, + flags: { isAgentAction: true, isMention: false }, + }], + }); + + const result = await ActivityService.getRecap(ownerId, { window: 'today' }); + + expect(result.needsYou).toEqual([expect.objectContaining({ + id: 'approval-1', kind: 'approval', title: 'Approval requested', + })]); + }); + + test('keeps a pending approval older than the recap window and absent from the sampled feed', async () => { + spy.mockResolvedValue({ activities: [] }); + pendingApprovalsSpy.mockResolvedValue([{ + _id: 'approval-before-feed-page', + type: 'approval_needed', + actor: { id: 'agent-1', name: 'release-agent', type: 'agent' }, + action: 'approval_needed', + content: 'Approve a decision that has waited longer than seven days.', + podId: 'pod-1', + approval: { status: 'pending' }, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + }]); + + const result = await ActivityService.getRecap(ownerId, { window: '7d' }); + + expect(result.needsYou).toEqual([expect.objectContaining({ + id: 'approval-before-feed-page', kind: 'approval', podId: 'pod-1', + })]); + }); + + test('removes a mention only after its dedicated acknowledgement is recorded', async () => { + spy.mockResolvedValue({ + acknowledgedMentionIds: ['message-1'], + activities: [{ + id: 'message-1', + type: 'message', + actor: { id: 'agent-1', name: 'sprint-impl', type: 'agent' }, + action: 'posted a message', + preview: 'Please review this.', + timestamp: new Date(), + pod: { id: 'pod-1', name: pod.name }, + flags: { isAgentAction: true, isMention: true }, + }], + }); + + const result = await ActivityService.getRecap(ownerId, { window: 'today' }); + + expect(result.needsYou).toEqual([]); + }); + + test('stores an acknowledgement separately from activity feed read-state', async () => { + const user = { + activityQueue: { acknowledgedMentionIds: [] }, + save: jest.fn().mockResolvedValue(), + }; + userFindByIdSpy = jest.spyOn(User, 'findById').mockReturnValue({ + select: jest.fn().mockResolvedValue(user), + }); + + const result = await ActivityService.acknowledgeMention(ownerId, 'message-1'); + + expect(result).toEqual({ success: true, acknowledgedMentionIds: ['message-1'] }); + expect(user.save).toHaveBeenCalledTimes(1); + expect(user).not.toHaveProperty('activityFeed'); + }); + + test('projects only an approval that the existing approve writer accepts', async () => { + const storedApproval = new Activity({ + type: 'approval_needed', + action: 'approval_needed', + content: 'Approve the release.', + }); + const approve = jest.fn().mockResolvedValue(); + storedApproval.approve = approve; + spy.mockResolvedValue({ + activities: [{ + id: String(storedApproval._id), + type: storedApproval.type, + actor: { id: 'agent-1', name: 'release-agent', type: 'agent' }, + action: storedApproval.action, + preview: storedApproval.content, + timestamp: new Date(), + pod: { id: 'pod-1', name: pod.name }, + approval: storedApproval.approval.toObject(), + flags: { isAgentAction: true, isMention: false }, + }], + }); + const recap = await ActivityService.getRecap(ownerId, { window: 'today' }); + findByIdSpy = jest.spyOn(Activity, 'findById').mockResolvedValue(storedApproval); + + const result = await ActivityService.approveActivity(recap.needsYou[0].id, ownerId, 'Approved in Activity'); + + expect(result).toEqual({ success: true, status: 'approved' }); + expect(approve).toHaveBeenCalledWith(ownerId, 'Approved in Activity'); + }); +}); diff --git a/backend/models/User.ts b/backend/models/User.ts index 75fec4cf8f..4a70ba086c 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -148,6 +148,11 @@ export interface IUser extends Document { lastViewedAt: Date; readItemIds: string[]; }; + // Queue acknowledgement is deliberately separate from activityFeed read + // state. Opening a feed is not the same as resolving a direct mention. + activityQueue: { + acknowledgedMentionIds: string[]; + }; digestPreferences: { enabled: boolean; frequency: DigestFrequency; @@ -333,6 +338,9 @@ const userSchema = new Schema({ lastViewedAt: { type: Date, default: new Date(0) }, readItemIds: { type: [String], default: [] }, }, + activityQueue: { + acknowledgedMentionIds: { type: [String], default: [] }, + }, digestPreferences: { enabled: { type: Boolean, default: true }, frequency: { type: String, enum: ['daily', 'weekly', 'never'], default: 'daily' }, diff --git a/backend/routes/activity.ts b/backend/routes/activity.ts index 4d6037cb03..0c669a5413 100644 --- a/backend/routes/activity.ts +++ b/backend/routes/activity.ts @@ -1,3 +1,9 @@ +// ESM import so CodeQL recognises the limiter on this database-backed read +// route; it runs before auth and the recap aggregation. +import rateLimit from 'express-rate-limit'; +import type { Request } from 'express'; +import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; + // eslint-disable-next-line global-require const express = require('express'); // eslint-disable-next-line global-require @@ -23,6 +29,20 @@ interface Res { const router: ReturnType = express.Router(); +// Activity queries and actions fan out to multiple projections. Sixty per +// minute leaves room for normal use without an unbounded hot loop. +const activityRateLimit = rateLimit({ + windowMs: 60_000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.NODE_ENV === 'test', + keyGenerator: (req: Request) => cloudflareIpRateLimitKeyGenerator(req), + handler: (_req: unknown, res: Res) => res.status(429).json({ code: 'rate_limited' }), +}); + +router.use(activityRateLimit); + router.get('/feed', auth, async (req: Req, res: Res) => { try { const { limit = '20', before, filter, mode = 'updates' } = req.query || {}; @@ -35,6 +55,30 @@ router.get('/feed', auth, async (req: Req, res: Res) => { } }); +router.get('/recap', auth, async (req: Req, res: Res) => { + try { + const window = req.query?.window; + const podId = req.query?.podId; + if (window !== undefined && window !== 'today' && window !== '7d') { + return res.status(400).json({ error: 'window must be today or 7d' }); + } + if (podId !== undefined && typeof podId !== 'string') { + return res.status(400).json({ error: 'podId must be a string' }); + } + const userId = getAuthenticatedUserId(req); + const result = await ActivityService.getRecap(userId, { + window: window === '7d' ? '7d' : 'today', + podId, + }); + return res.json(result); + } catch (error) { + const e = error as { message?: string }; + if (e.message === 'Access denied') return res.status(403).json({ error: 'Access denied' }); + console.error('Error fetching activity recap:', error); + return res.status(500).json({ error: 'Failed to fetch activity recap' }); + } +}); + router.get('/pods/:podId', auth, async (req: Req, res: Res) => { try { const { podId } = req.params || {}; @@ -94,6 +138,19 @@ router.post('/mark-read', auth, async (req: Req, res: Res) => { } }); +router.post('/:activityId/acknowledge', auth, async (req: Req, res: Res) => { + try { + const { activityId } = req.params || {}; + const userId = getAuthenticatedUserId(req); + const result = await ActivityService.acknowledgeMention(userId, String(activityId)) as { success?: boolean; error?: string }; + if (!result.success) return res.status(400).json({ error: result.error || 'Failed to acknowledge mention' }); + return res.json(result); + } catch (error) { + console.error('Error acknowledging activity mention:', error); + return res.status(500).json({ error: 'Failed to acknowledge mention' }); + } +}); + router.post('/:activityId/like', auth, async (req: Req, res: Res) => { try { const { activityId } = req.params || {}; diff --git a/backend/services/activityService.ts b/backend/services/activityService.ts index 0bae98b46f..85d69ebc41 100644 --- a/backend/services/activityService.ts +++ b/backend/services/activityService.ts @@ -8,6 +8,8 @@ const Activity = require('../models/Activity'); const Summary = require('../models/Summary'); // eslint-disable-next-line global-require const Post = require('../models/Post'); +// eslint-disable-next-line global-require +const Task = require('../models/Task'); let PGMessage: unknown = null; try { @@ -67,6 +69,7 @@ interface UserDoc { followers?: unknown[]; followedThreads?: Array<{ postId: unknown; followedAt?: Date }>; activityFeed?: { lastViewedAt?: Date | string; readItemIds?: unknown[] }; + activityQueue?: { acknowledgedMentionIds?: unknown[] }; } interface PodDoc { @@ -87,6 +90,11 @@ interface GetFeedOptions { mode?: string; } +interface GetRecapOptions { + window?: 'today' | '7d'; + podId?: string; +} + interface ComputeFlagsOptions { actor?: ActorInfo; type?: string; @@ -98,6 +106,233 @@ interface ComputeFlagsOptions { } class ActivityService { + /** + * Read-side projection for the v2 Activity surface. The source events stay + * in their owning stores: messages remain in Postgres and board transitions + * remain Task updates in Mongo. This endpoint only groups a member's + * existing, authorized data; it does not introduce another activity log. + */ + static async getRecap( + userId: unknown, + options: GetRecapOptions = {}, + ): 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({ + $or: [ + { createdBy: userId }, + { 'members.userId': userId }, + { members: userId }, + ], + }).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) { + 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 timestamp = activity.timestamp ? new Date(activity.timestamp).getTime() : 0; + return timestamp >= since.getTime() + && (!requestedPodId || (activity.pod && scopedPodIds.has(activity.pod.id))); + }); + const acknowledgedMentionIds = new Set( + ((feed.acknowledgedMentionIds as unknown[] | undefined) || []).map((id) => String(id)), + ); + + type AgentRecap = { + id: string; + name: string; + profilePicture?: string; + lastActiveAt: Date | string | null; + messageCount: number; + recap: string; + updates: Array<{ + id: string; + podId: string | null; + podName: string; + content: string; + timestamp: Date | string | null; + }>; + }; + const agents = new Map(); + + activities + .filter((activity) => activity.actor?.type === 'agent' || activity.flags?.isAgentAction) + .forEach((activity) => { + const actorId = String(activity.actor?.id || activity.actor?.name || 'unknown-agent'); + const name = activity.actor?.name || 'Agent'; + const existing = agents.get(actorId) || { + id: actorId, + name, + profilePicture: activity.actor?.profilePicture, + lastActiveAt: activity.timestamp, + messageCount: 0, + recap: '', + updates: [], + }; + existing.messageCount += 1; + if (new Date(activity.timestamp || 0).getTime() > new Date(existing.lastActiveAt || 0).getTime()) { + existing.lastActiveAt = activity.timestamp; + } + const content = String(activity.preview || activity.content || activity.action || '').replace(/\s+/g, ' ').trim(); + if (content) { + existing.updates.push({ + id: activity.id, + podId: activity.pod?.id || null, + podName: activity.pod?.name || 'Direct activity', + content: content.slice(0, 180), + timestamp: activity.timestamp, + }); + } + agents.set(actorId, existing); + }); + + const agentRecaps = Array.from(agents.values()) + .map((agent) => { + const updates = agent.updates + .sort((a, b) => new Date(b.timestamp || 0).getTime() - new Date(a.timestamp || 0).getTime()) + .slice(0, 3); + const podNames = Array.from(new Set(updates.map((update) => update.podName))); + return { + ...agent, + updates, + recap: agent.messageCount === 1 + ? `Posted an update${podNames[0] ? ` in ${podNames[0]}` : ''}.` + : `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()); + + // 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. + const pendingApprovals = await ActivityService.getPendingApprovals(userId) as Array<{ + _id?: unknown; + id?: unknown; + type?: string; + actor?: ActorInfo; + action?: string; + content?: string; + podId?: unknown; + approval?: unknown; + agentMetadata?: { agentName?: string }; + createdAt?: Date | string; + updatedAt?: Date | string; + }>; + const approvalItems: ActivityItem[] = pendingApprovals + .filter((approval) => !requestedPodId || scopedPodIds.has(String(approval.podId))) + .map((approval) => { + const podId = approval.podId ? String(approval.podId) : ''; + const pod = scopedPods.find((candidate) => String(candidate._id) === podId); + return { + id: String(approval._id || approval.id || ''), + type: approval.type || 'approval_needed', + actor: approval.actor || { + name: approval.agentMetadata?.agentName || 'An agent', type: 'agent', + }, + action: approval.action || 'approval_needed', + content: approval.content, + timestamp: approval.createdAt || approval.updatedAt || null, + pod: pod ? { id: String(pod._id), name: pod.name } : null, + approval: approval.approval, + reactions: { likes: 0, liked: false }, + replyCount: 0, + replies: [], + }; + }) + .filter((approval) => Boolean(approval.id)); + + const isPendingApproval = (activity: ActivityItem): boolean => { + const approval = activity.approval as { status?: string } | undefined; + // Mongoose materializes approval.status = 'pending' for every Activity + // document. It is meaningful only on the one activity type that carries + // an approval request; otherwise every message becomes a human action. + return activity.type === 'approval_needed' && approval?.status === 'pending'; + }; + const queueCandidates = new Map(); + [...activities, ...approvalItems].forEach((activity) => { + if (!queueCandidates.has(activity.id)) queueCandidates.set(activity.id, activity); + }); + const newestFirst = (left: ActivityItem, right: ActivityItem) => ( + new Date(right.timestamp || 0).getTime() - new Date(left.timestamp || 0).getTime() + ); + const approvalQueue = Array.from(queueCandidates.values()) + .filter(isPendingApproval) + .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) + .map((activity) => { + const isApproval = isPendingApproval(activity); + return { + id: activity.id, + kind: isApproval ? 'approval' : 'mention', + 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, + }; + }); + + let board: Array> = []; + if (scopedPods.length > 0) { + const taskRows: Array> = await Task.find({ + podId: { $in: scopedPods.map((pod) => pod._id) }, + updatedAt: { $gte: since }, + }) + .select('podId taskId title status updatedAt updates') + .sort({ updatedAt: -1 }) + .limit(24) + .lean(); + const podNames = new Map(scopedPods.map((pod) => [String(pod._id), pod.name])); + board = taskRows.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]; + return { + id: String(task._id), + taskId: task.taskId, + title: task.title, + status: task.status, + podId: String(task.podId), + podName: podNames.get(String(task.podId)) || 'Pod', + updatedAt: task.updatedAt, + lastUpdate: lastUpdate + ? { + text: String(lastUpdate.text || '').slice(0, 180), + author: String(lastUpdate.author || ''), + createdAt: lastUpdate.createdAt, + } + : null, + }; + }); + } + + return { + window, + since: since.toISOString(), + generatedAt: new Date().toISOString(), + scope: requestedPodId || 'all', + pods: pods.map((pod) => ({ id: String(pod._id), name: pod.name })), + needsYou, + agents: agentRecaps, + board, + }; + } + static async getUserFeed( userId: unknown, options: GetFeedOptions = {}, @@ -108,7 +343,7 @@ class ActivityService { try { const user: UserDoc | null = await User.findById(userId) - .select('_id username following followers followedThreads') + .select('_id username following followers followedThreads activityQueue') .lean(); if (!user) { return { activities: [], hasMore: false, quick: null }; @@ -139,6 +374,7 @@ class ActivityService { hasMore: withReadState.length === limit, quick, unreadCount: withReadState.filter((item) => !item.read).length, + acknowledgedMentionIds: user.activityQueue?.acknowledgedMentionIds || [], }; } catch (error) { console.error('Error in getUserFeed:', error); @@ -395,12 +631,12 @@ class ActivityService { messages.forEach((msg) => { const userId = msg.userId as Record | undefined; const authorName = (msg.username as string) || (userId?.username as string) || 'Unknown'; - const isAgent = ActivityService.isAgentUsername(authorName); + const isAgent = userId?.isBot === true || ActivityService.isAgentUsername(authorName); if (filter === 'humans' && isAgent) return; if (filter === 'agents' && !isAgent) return; - const pod = podMap.get(String(msg.podId) || String(msg.pod_id)); + const pod = podMap.get(String(msg.podId || msg.pod_id || '')); activities.push({ id: `msg_${msg._id || msg.id}`, @@ -564,6 +800,21 @@ class ActivityService { }; } + static async acknowledgeMention(userId: unknown, activityId: string): Promise> { + const user = await User.findById(userId).select('_id activityQueue'); + if (!user) return { success: false, error: 'User not found' }; + + if (!user.activityQueue) user.activityQueue = { acknowledgedMentionIds: [] }; + const next = new Set((user.activityQueue.acknowledgedMentionIds || []).map((id: unknown) => String(id))); + next.add(String(activityId)); + // This is per-(user, message) state, rather than a recent-feed cache: an + // acknowledged mention must not resurface merely because more messages + // arrive later. + user.activityQueue.acknowledgedMentionIds = Array.from(next); + await user.save(); + return { success: true, acknowledgedMentionIds: user.activityQueue.acknowledgedMentionIds }; + } + static async getUnreadCount(userId: unknown, options: GetFeedOptions = {}): Promise<{ unreadCount: number }> { const activitiesResult = await ActivityService.getUserFeed(userId, { ...options, diff --git a/frontend/src/components/activity/ActivityFeed.tsx b/frontend/src/components/activity/ActivityFeed.tsx deleted file mode 100644 index 472f35bc5b..0000000000 --- a/frontend/src/components/activity/ActivityFeed.tsx +++ /dev/null @@ -1,615 +0,0 @@ -/** - * ActivityFeed Component - * - * Unified activity feed showing both human and AI agent interactions. - * The core social experience of Commonly. - */ - -import React, { useState } from 'react'; -import { - Box, - Paper, - Typography, - Avatar, - IconButton, - Button, - Chip, - Tooltip, - Skeleton, - alpha, - useTheme, - Collapse, -} from '@mui/material'; -import { - VerifiedUser as VerifiedIcon, - ThumbUp as LikeIcon, - ThumbUpOutlined as LikeOutlinedIcon, - ChatBubbleOutline as ReplyIcon, - MoreHoriz as MoreIcon, - PushPin as PinIcon, - AutoAwesome as SkillIcon, - Link as LinkIcon, - Search as QueryIcon, - Check as ApproveIcon, - Close as RejectIcon, - SmartToy as AgentIcon, - Person as HumanIcon, -} from '@mui/icons-material'; -import { SvgIconComponent } from '@mui/icons-material'; -import { Theme } from '@mui/material/styles'; - -interface Actor { - id?: string; - name?: string; - type?: string; - verified?: boolean; - profilePicture?: string; -} - -interface Target { - title?: string; - preview?: string; - description?: string; -} - -interface AgentMetadata { - sources?: Array<{ title?: string }>; -} - -interface Pod { - id: string; - name: string; -} - -interface Participant { - name?: string; - type?: string; -} - -interface Reply { - actor: { name?: string; type?: string }; - content?: string; -} - -export interface Activity { - id: string; - type?: string; - actor: Actor; - action?: string; - content?: string; - preview?: string; - timestamp: string; - reactions?: { likes?: number; liked?: boolean }; - replyCount?: number; - replies?: Reply[]; - target?: Target; - involves?: Participant[]; - agentMetadata?: AgentMetadata; - pod?: Pod | null; - read?: boolean; - approval?: { status?: string }; - flags?: Record; -} - -interface ParticipantStyle { - avatarShape: 'circular' | 'rounded'; - badgeIcon: SvgIconComponent | null; - glowColor: ((theme: Theme) => string) | null; -} - -// Participant type styling -const participantStyles: Record = { - human: { avatarShape: 'circular', badgeIcon: null, glowColor: null }, - agent: { - avatarShape: 'rounded', - badgeIcon: AgentIcon, - glowColor: (theme) => theme.palette.primary.main, - }, - system: { avatarShape: 'rounded', badgeIcon: null, glowColor: null }, -}; - -interface ActivityTypeInfo { - icon: SvgIconComponent | null; - color: string; - label: string; -} - -// Activity type icons and colors -const activityTypes: Record = { - message: { icon: null, color: 'text.primary', label: '' }, - reply: { icon: ReplyIcon, color: 'text.secondary', label: 'replied' }, - skill_created: { icon: SkillIcon, color: 'secondary.main', label: 'created a skill' }, - joined: { icon: null, color: 'success.main', label: 'joined' }, - mentioned: { icon: null, color: 'primary.main', label: 'mentioned' }, - task_completed: { icon: null, color: 'success.main', label: 'completed a task' }, - query: { icon: QueryIcon, color: 'info.main', label: 'searched' }, - approval_needed: { icon: null, color: 'warning.main', label: 'needs approval' }, - pod_linked: { icon: LinkIcon, color: 'primary.main', label: 'linked pods' }, - agent_action: { icon: AgentIcon, color: 'primary.main', label: '' }, - summary: { icon: SkillIcon, color: 'info.main', label: 'summarized' }, - thread_comment: { icon: ReplyIcon, color: 'info.main', label: 'replied on thread' }, - thread_followed: { icon: LinkIcon, color: 'primary.main', label: 'followed thread' }, - user_followed: { icon: HumanIcon, color: 'success.main', label: 'followed' }, -}; - -interface ActivityItemProps { - activity: Activity; - onLike?: (activity: Activity) => void; - onReply?: (activity: Activity, content?: string) => void; - onApprove?: (activity: Activity) => void; - onReject?: (activity: Activity) => void; - onMarkRead?: (activity: Activity) => void; - onActorClick?: (actorId: string) => void; -} - -// Single activity item -const ActivityItem: React.FC = ({ - activity, - onLike, - onReply, - onApprove, - onReject, - onMarkRead, - onActorClick, -}) => { - const theme = useTheme(); - const [showReplies, setShowReplies] = useState(false); - const [isLiked, setIsLiked] = useState(false); - - const { - actor, - action, - content, - preview, - timestamp, - reactions = {}, - replyCount = 0, - replies = [], - target, - involves = [], - agentMetadata, - pod, - read, - } = activity; - - const isAgent = actor.type === 'agent'; - const isUnread = !read; - const style = participantStyles[actor.type ?? 'human'] || participantStyles.human; - const actionType = activityTypes[action ?? 'message'] || activityTypes.message; - - const formatTime = (ts: string): string => { - const date = new Date(ts); - const now = new Date(); - const diff = (now.getTime() - date.getTime()) / 1000; - - if (diff < 60) return 'now'; - if (diff < 3600) return `${Math.floor(diff / 60)}m`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h`; - return date.toLocaleDateString(); - }; - - const handleLike = (): void => { - setIsLiked(!isLiked); - onLike?.(activity); - }; - - return ( - - {/* Header */} - - {/* Avatar with agent indicator */} - - - {isAgent ? '🤖' : actor.name?.charAt(0).toUpperCase()} - - {isAgent && actor.verified && ( - - - - )} - - - {/* Content */} - - {/* Actor info */} - - actor?.id && onActorClick?.(actor.id)} - > - {actor.name} - - {isUnread && ( - - )} - {isUnread && ( - - )} - {isAgent && ( - - )} - {actionType.label && ( - - {actionType.label} - - )} - {involves.length > 0 && ( - <> - - with - - {involves.slice(0, 2).map((p, i) => ( - - @{p.name} - - ))} - - )} - {pod && ( - - )} - - {formatTime(timestamp)} - - - - {/* Main content */} - {(content || preview) && ( - - {content || preview} - - )} - - {/* Target card (for skills, links, etc.) */} - {target && action === 'skill_created' && ( - - - - - {target.title || target.preview} - - - {target.description && ( - - {target.description} - - )} - - - - - - )} - - {/* Approval request */} - {action === 'approval_needed' && ( - - {activity.approval?.status === 'pending' ? ( - - - - - ) : ( - - )} - - )} - - {/* Agent metadata */} - {isAgent && agentMetadata?.sources && agentMetadata.sources.length > 0 && ( - - - Sources: - - {agentMetadata.sources.slice(0, 3).map((src, i) => ( - - ))} - - )} - - - {/* Actions */} - - - - - - {/* Reactions & Reply bar */} - - - - - {isAgent && ( - - - - - - )} - {isUnread && ( - - )} - - - {/* Replies */} - 0}> - - {replies.map((reply, i) => ( - - - {reply.actor.type === 'agent' ? '🤖' : reply.actor.name?.charAt(0)} - - - - {reply.actor.name} - - {reply.content} - - - ))} - - - - ); -}; - -interface ActivityFeedProps { - activities?: Activity[]; - loading?: boolean; - onLike?: (activity: Activity) => void; - onReply?: (activity: Activity, content?: string) => void; - onApprove?: (activity: Activity) => void; - onReject?: (activity: Activity) => void; - onMarkRead?: (activity: Activity) => void; - onActorClick?: (actorId: string) => void; - onLoadMore?: () => void; - hasMore?: boolean; - filter?: string; -} - -// Main ActivityFeed component -const ActivityFeed: React.FC = ({ - activities = [], - loading = false, - onLike, - onReply, - onApprove, - onReject, - onMarkRead, - onActorClick, - onLoadMore, - hasMore = false, - filter = 'all', -}) => { - const filteredActivities = activities.filter((a) => { - if (filter === 'all') return true; - if (filter === 'humans') return a.actor.type === 'human'; - if (filter === 'agents') return a.actor.type === 'agent'; - if (filter === 'skills') return a.action === 'skill_created'; - return true; - }); - - if (loading && activities.length === 0) { - return ( - - {[1, 2, 3].map((i) => ( - - ))} - - ); - } - - if (filteredActivities.length === 0) { - return ( - - - No activity yet - - - Start a conversation or add an agent to get things going! - - - ); - } - - return ( - - {filteredActivities.map((activity) => ( - - ))} - - {hasMore && ( - - - - )} - - ); -}; - -// Loading skeleton -const ActivityItemSkeleton: React.FC = () => ( - - - - - - - - - - - - - - -); - -export default ActivityFeed; diff --git a/frontend/src/components/activity/ActivityFeedPage.tsx b/frontend/src/components/activity/ActivityFeedPage.tsx deleted file mode 100644 index 0ff9ca0e0e..0000000000 --- a/frontend/src/components/activity/ActivityFeedPage.tsx +++ /dev/null @@ -1,737 +0,0 @@ -/** - * ActivityFeedPage - * - * Page wrapper for the ActivityFeed component. - * Fetches activity data and provides filtering. - */ - -import React, { useMemo, useState, useEffect } from 'react'; -import { - Container, - Box, - Typography, - Tabs, - Tab, - Paper, - Alert, - Button, - FormControl, - Select, - MenuItem, - InputLabel, - Snackbar, -} from '@mui/material'; -import { - People as AllIcon, - Person as HumansIcon, - SmartToy as AgentsIcon, - AutoAwesome as SkillsIcon, - Refresh as RefreshIcon, - NotificationsActive as NotificationsIcon, - DynamicFeed as UpdatesIcon, - Bolt as ActionsIcon, - AlternateEmail as MentionsIcon, - Forum as ThreadsIcon, - Favorite as FollowingIcon, - Chat as PodsIcon, -} from '@mui/icons-material'; -import ActivityFeed, { Activity } from './ActivityFeed'; -import axios from 'axios'; -import { useSocket } from '../../context/SocketContext'; -import { useAuth } from '../../context/AuthContext'; -import { useV2Embedded } from '../../v2/hooks/useV2Embedded'; - -interface UserPod { - _id: string; - name: string; -} - -interface QuickPod { - id: string; - name: string; -} - -interface FollowedThread { - postId: string; - url: string; - preview?: string; - newReplies: number; -} - -interface QuickData { - social?: { followers?: number; following?: number }; - recentPods?: QuickPod[]; - followedThreads?: FollowedThread[]; -} - -interface SnackbarState { - open: boolean; - message: string; - severity: 'info' | 'error' | 'success' | 'warning'; -} - -const ActivityFeedPage: React.FC = () => { - const v2Embedded = useV2Embedded(); - const [activities, setActivities] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [mode, setMode] = useState('updates'); - const [filter, setFilter] = useState('all'); - const [hasMore, setHasMore] = useState(true); - const [userPods, setUserPods] = useState([]); - const [quick, setQuick] = useState(null); - const [liveUpdates, setLiveUpdates] = useState(0); - const [unreadCount, setUnreadCount] = useState(0); - const [selectedPodId, setSelectedPodId] = useState('all'); - const [snackbar, setSnackbar] = useState({ - open: false, - message: '', - severity: 'info', - }); - const { socket, connected } = useSocket(); - const { currentUser } = useAuth(); - - const activityFilterOptions = useMemo(() => { - const countWhere = (predicate: (activity: Activity) => boolean) => ( - activities.filter(predicate).length - ); - if (mode === 'actions') { - return [ - { value: 'all', label: 'All', count: activities.length }, - { - value: 'agents', - label: 'Agents', - count: countWhere((activity) => activity.actor?.type === 'agent' - || activity.involves?.some((participant) => participant.type === 'agent') === true), - }, - { - value: 'humans', - label: 'Humans', - count: countWhere((activity) => activity.actor?.type === 'human' - || activity.involves?.some((participant) => participant.type === 'human') === true), - }, - { - value: 'skills', - label: 'Skills', - count: countWhere((activity) => activity.type?.includes('skill') === true - || activity.flags?.skill === true), - }, - ]; - } - return [ - { value: 'all', label: 'All', count: activities.length }, - { - value: 'mentions', - label: 'Mentions', - count: countWhere((activity) => activity.type?.includes('mention') === true - || activity.flags?.mention === true), - }, - { - value: 'following', - label: 'Following', - count: countWhere((activity) => activity.flags?.following === true), - }, - { - value: 'threads', - label: 'Threads', - count: countWhere((activity) => activity.type?.includes('thread') === true - || (activity.replyCount || 0) > 0), - }, - { - value: 'pods', - label: 'Pods', - count: countWhere((activity) => Boolean(activity.pod)), - }, - ]; - }, [activities, mode]); - - const getAuthHeaders = (): Record => { - const token = localStorage.getItem('token'); - return { 'x-auth-token': token ?? '' }; - }; - - // Fetch user's pods for the selector - useEffect(() => { - const fetchUserPods = async (): Promise => { - try { - const response = await axios.get('/api/pods', { - headers: getAuthHeaders(), - }); - setUserPods(response.data || []); - } catch (err) { - console.error('Error fetching user pods:', err); - } - }; - fetchUserPods(); - }, []); - - useEffect(() => { - fetchActivities(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, filter, selectedPodId]); - - useEffect(() => { - fetchUnreadCount(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, filter]); - - const fetchUnreadCount = async (): Promise => { - try { - const response = await axios.get<{ unreadCount?: number }>('/api/activity/unread-count', { - headers: getAuthHeaders(), - params: { mode, filter }, - }); - setUnreadCount(response.data?.unreadCount || 0); - } catch { - // keep current value - } - }; - - const fetchActivities = async (): Promise => { - setLoading(true); - try { - const params: Record = { limit: 20 }; - if (filter !== 'all') params.filter = filter; - params.mode = mode; - - let url = '/api/activity/feed'; - if (selectedPodId !== 'all') { - url = `/api/activity/pods/${selectedPodId}`; - } - - const response = await axios.get<{ - activities?: Activity[]; - hasMore?: boolean; - quick?: QuickData; - unreadCount?: number; - }>(url, { - headers: getAuthHeaders(), - params, - }); - - const fetchedActivities = response.data.activities || []; - setActivities(fetchedActivities); - setHasMore(response.data.hasMore || false); - setQuick(response.data.quick || null); - setLiveUpdates(0); - setUnreadCount( - response.data.unreadCount !== undefined - ? response.data.unreadCount - : fetchedActivities.filter((item) => !item.read).length, - ); - setError(fetchedActivities.length === 0 ? 'No activity yet' : null); - } catch (err) { - console.error('Error fetching activities:', err); - setError('Failed to load activities'); - setActivities([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (!socket || !connected || !Array.isArray(userPods) || userPods.length === 0) { - return undefined; - } - userPods.forEach((pod) => socket.emit('joinPod', pod._id)); - - const handleNewMessage = (message: Record): void => { - const pod = userPods.find( - (item) => item._id === (message.podId || message.pod_id), - ); - const actorName = - (message.username as string) || - ((message.userId as Record)?.username as string) || - 'User'; - const isAgent = - actorName.toLowerCase().includes('bot') || actorName === 'commonly-ai-agent'; - - const activity: Activity = { - id: `live_${(message._id as string) || (message.id as string) || Date.now()}`, - type: 'message', - actor: { - id: - ((message.userId as Record)?._id as string) || - (message.userId as string) || - (message.user_id as string), - name: actorName, - type: isAgent ? 'agent' : 'human', - verified: isAgent, - profilePicture: - (message.profilePicture as string) || - (message.profile_picture as string) || - ((message.userId as Record)?.profilePicture as string), - }, - action: 'message', - content: (message.content as string) || (message.text as string) || '', - preview: ((message.content as string) || (message.text as string) || '').slice(0, 200), - timestamp: - (message.createdAt as string) || - (message.created_at as string) || - new Date().toISOString(), - pod: pod ? { id: pod._id, name: pod.name } : null, - reactions: { likes: 0, liked: false }, - replyCount: 0, - replies: [], - flags: { - isAgentAction: isAgent, - isMention: Boolean( - currentUser?.username && - ((message.content as string) || '').toLowerCase().includes( - `@${currentUser.username.toLowerCase()}`, - ), - ), - isFollowing: false, - isThreadUpdate: false, - }, - }; - - setActivities((prev) => - [activity, ...prev.filter((item) => item.id !== activity.id)].slice(0, 50), - ); - setLiveUpdates((count) => count + 1); - setUnreadCount((count) => count + 1); - }; - - socket.on('newMessage', handleNewMessage); - return () => { - socket.off('newMessage', handleNewMessage); - userPods.forEach((pod) => socket.emit('leavePod', pod._id)); - }; - }, [socket, connected, userPods, currentUser]); - - const handleMarkRead = async (activity: Activity): Promise => { - if (!activity?.id) return; - try { - await axios.post( - '/api/activity/mark-read', - { activityId: activity.id }, - { headers: getAuthHeaders() }, - ); - setActivities((prev) => - prev.map((item) => (item.id === activity.id ? { ...item, read: true } : item)), - ); - setUnreadCount((count) => Math.max(0, count - 1)); - } catch { - setSnackbar({ open: true, message: 'Failed to mark as read', severity: 'error' }); - } - }; - - const handleMarkAllRead = async (): Promise => { - try { - await axios.post( - '/api/activity/mark-read', - { all: true }, - { headers: getAuthHeaders() }, - ); - setActivities((prev) => prev.map((item) => ({ ...item, read: true }))); - setUnreadCount(0); - setSnackbar({ open: true, message: 'All caught up', severity: 'success' }); - } catch { - setSnackbar({ open: true, message: 'Failed to mark all as read', severity: 'error' }); - } - }; - - const handleLike = async (activity: Activity): Promise => { - try { - await axios.post(`/api/activity/${activity.id}/like`, {}, { headers: getAuthHeaders() }); - setActivities((prev) => - prev.map((a) => - a.id === activity.id - ? { - ...a, - reactions: { ...a.reactions, likes: (a.reactions?.likes || 0) + 1, liked: true }, - } - : a, - ), - ); - } catch (err) { - console.error('Error liking activity:', err); - setSnackbar({ open: true, message: 'Failed to like', severity: 'error' }); - } - }; - - const handleReply = async (activity: Activity, content?: string): Promise => { - try { - const response = await axios.post<{ success: boolean; reply: unknown }>( - `/api/activity/${activity.id}/reply`, - { content }, - { headers: getAuthHeaders() }, - ); - if (response.data.success) { - setActivities((prev) => - prev.map((a) => - a.id === activity.id - ? { - ...a, - replyCount: (a.replyCount || 0) + 1, - replies: [ - ...(a.replies || []), - response.data.reply as Activity['replies'][0], - ], - } - : a, - ), - ); - setSnackbar({ open: true, message: 'Reply added', severity: 'success' }); - } - } catch (err) { - console.error('Error replying to activity:', err); - setSnackbar({ open: true, message: 'Failed to reply', severity: 'error' }); - } - }; - - const handleApprove = async (activity: Activity): Promise => { - try { - const response = await axios.post<{ success: boolean }>( - `/api/activity/${activity.id}/approve`, - { notes: 'Approved via UI' }, - { headers: getAuthHeaders() }, - ); - if (response.data.success) { - setActivities((prev) => - prev.map((a) => - a.id === activity.id - ? { ...a, approval: { ...a.approval, status: 'approved' } } - : a, - ), - ); - setSnackbar({ open: true, message: 'Approved successfully', severity: 'success' }); - } - } catch (err) { - console.error('Error approving:', err); - setSnackbar({ open: true, message: 'Failed to approve', severity: 'error' }); - } - }; - - const handleReject = async (activity: Activity): Promise => { - try { - const response = await axios.post<{ success: boolean }>( - `/api/activity/${activity.id}/reject`, - { notes: 'Rejected via UI' }, - { headers: getAuthHeaders() }, - ); - if (response.data.success) { - setActivities((prev) => - prev.map((a) => - a.id === activity.id - ? { ...a, approval: { ...a.approval, status: 'rejected' } } - : a, - ), - ); - setSnackbar({ open: true, message: 'Rejected', severity: 'info' }); - } - } catch (err) { - console.error('Error rejecting:', err); - setSnackbar({ open: true, message: 'Failed to reject', severity: 'error' }); - } - }; - - const handleLoadMore = async (): Promise => { - if (activities.length === 0 || loading) return; - - const lastActivity = activities[activities.length - 1]; - try { - const params: Record = { - limit: 20, - before: lastActivity.timestamp, - }; - if (filter !== 'all') params.filter = filter; - params.mode = mode; - - let url = '/api/activity/feed'; - if (selectedPodId !== 'all') { - url = `/api/activity/pods/${selectedPodId}`; - } - - const response = await axios.get<{ activities?: Activity[]; hasMore?: boolean }>(url, { - headers: getAuthHeaders(), - params, - }); - - setActivities([...activities, ...(response.data.activities || [])]); - setHasMore(response.data.hasMore || false); - } catch (err) { - console.error('Error loading more activities:', err); - } - }; - - const handleSeedActivities = async (): Promise => { - if (selectedPodId === 'all') { - setSnackbar({ - open: true, - message: 'Select a specific pod to seed activities', - severity: 'warning', - }); - return; - } - - try { - const response = await axios.post<{ success: boolean; count: number }>( - `/api/activity/seed/${selectedPodId}`, - {}, - { headers: getAuthHeaders() }, - ); - - if (response.data.success) { - setSnackbar({ - open: true, - message: `Seeded ${response.data.count} demo activities`, - severity: 'success', - }); - fetchActivities(); - } - } catch (err) { - console.error('Error seeding activities:', err); - setSnackbar({ open: true, message: 'Failed to seed activities', severity: 'error' }); - } - }; - - return ( - - {/* Header */} - - {/* The v2 shell renders its own page header, so hide this legacy - title to avoid a duplicated heading echo. */} - {!v2Embedded && ( - - - Activity - - - Real-time social updates across your pods, threads, and follows - - - )} - - {/* Pod Selector */} - - - Pod - - - - - - - - {liveUpdates > 0 && ( - }> - {liveUpdates} new live update{liveUpdates === 1 ? '' : 's'} received - - )} - - {quick && ( - - - Quick View - - - - - - - Recent Joined/Active Pods - - - {(quick.recentPods || []).slice(0, 4).map((pod) => ( - - ))} - - - Followed Threads - - - {(quick.followedThreads || []).slice(0, 3).map((thread) => ( - { - window.location.href = thread.url; - }} - > - {thread.preview || 'Thread'}{' '} - {thread.newReplies > 0 ? `• ${thread.newReplies} new replies` : ''} - - ))} - {(quick.followedThreads || []).length === 0 && ( - - No followed threads yet. - - )} - - - )} - - {/* Mode tabs */} - - { - setMode(v); - setFilter('all'); - }} - variant="fullWidth" - sx={{ '& .MuiTab-root': { minHeight: 48, textTransform: 'none', fontWeight: 500 } }} - > - } iconPosition="start" label="Updates" value="updates" /> - } iconPosition="start" label="Actions" value="actions" /> - - - - {/* Type filters: under v2 these are filters, not a second navigation row. */} - {v2Embedded ? ( - - - {activityFilterOptions.map((option) => ( - - ))} - - - {selectedPodId === 'all' ? 'All pods' : 'Pod scoped'} · {activities.length} visible - - - ) : ( - - setFilter(v)} - variant="scrollable" - allowScrollButtonsMobile - sx={{ '& .MuiTab-root': { minHeight: 44, textTransform: 'none', fontWeight: 500 } }} - > - } iconPosition="start" label="All" value="all" /> - {mode === 'updates' && ( - } - iconPosition="start" - label="Mentions" - value="mentions" - /> - )} - {mode === 'updates' && ( - } - iconPosition="start" - label="Following" - value="following" - /> - )} - {mode === 'updates' && ( - } - iconPosition="start" - label="Threads" - value="threads" - /> - )} - {mode === 'updates' && ( - } iconPosition="start" label="Pods" value="pods" /> - )} - {mode === 'actions' && ( - } iconPosition="start" label="Agents" value="agents" /> - )} - {mode === 'actions' && ( - } iconPosition="start" label="Humans" value="humans" /> - )} - {mode === 'actions' && ( - } iconPosition="start" label="Skills" value="skills" /> - )} - - - )} - - {error && ( - - Seed Demo Data - - ) - } - > - {error} - - )} - - {/* Activity feed */} - { - window.location.href = `/profile/${actorId}`; - }} - onLoadMore={handleLoadMore} - hasMore={hasMore} - /> - - {/* Snackbar for notifications */} - setSnackbar({ ...snackbar, open: false })} - message={snackbar.message} - /> - - ); -}; - -export default ActivityFeedPage; diff --git a/frontend/src/components/activity/index.ts b/frontend/src/components/activity/index.ts deleted file mode 100644 index 7f574ceb84..0000000000 --- a/frontend/src/components/activity/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as ActivityFeed } from './ActivityFeed'; -export { default as ActivityFeedPage } from './ActivityFeedPage'; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7602b75e48..47f71df36c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -14,6 +14,7 @@ "nav": { "pods": "Pods", "agents": "Agents", + "activity": "Activity", "community": "Community", "settings": "Settings", "connectors": "Connectors" @@ -24,6 +25,77 @@ "text": "Checking your access…" } }, + "activity": { + "title": "Activity", + "subtitle": "What needs your attention and what your agents moved forward.", + "controlsAriaLabel": "Activity controls", + "windowAriaLabel": "Activity window", + "windows": { "today": "Today", "7d": "7 days" }, + "podScopeLabel": "Pod scope", + "allPods": "All pods", + "loadFailed": "Activity could not be loaded. Try again.", + "openThread": "Open thread", + "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.", + "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" } + }, + "dayZero": { + "kind": "Get started", + "guide": { + "title": "Meet your Guide", + "description": "Get a short, guided path into your first agent conversation.", + "leaves": "Leaves when your Guide replies.", + "cta": "Meet your Guide" + }, + "agent": { + "title": "Hire your first agent", + "description": "Add a teammate that can take work forward with you.", + "leaves": "Leaves when an agent joins your workspace.", + "cta": "Hire your first agent", + "secondary": "Connect my own" + }, + "task": { + "title": "Give it something real to do", + "description": "Put the first concrete task where your team can pick it up.", + "leaves": "Leaves when a task is claimed.", + "cta": "Create a task" + } + }, + "approval": { + "approve": "Approve", + "reject": "Reject", + "working": "Saving…", + "actionFailed": "That approval could not be updated. Try again." + }, + "mention": { + "acknowledge": "Acknowledge", + "working": "Saving…", + "actionFailed": "That mention could not be acknowledged. Try again." + }, + "agents": { + "eyebrow": "Agent recap", + "title": "What your agents did", + "description": "A factual recap of agent-authored updates in this window.", + "emptyTitle": "No agent updates in this window", + "emptyDescription": "When an agent posts in a pod, its work will be grouped here." + }, + "board": { + "eyebrow": "Board delta", + "title": "Board", + "description": "Tasks that changed in this window.", + "emptyTitle": "No board changes in this window", + "emptyDescription": "Task changes will appear here without creating a second board.", + "status": { "pending": "Pending", "claimed": "In progress", "blocked": "Blocked", "done": "Done" } + }, + "footer": "Open the source thread to act; this recap leaves when the underlying fact changes." + }, "auth": { "fields": { "username": "Username", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 8ea1ff6994..cf52cc2125 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -14,6 +14,7 @@ "nav": { "pods": "Pod", "agents": "智能体", + "activity": "动态", "community": "社区", "settings": "设置", "connectors": "连接器" @@ -24,6 +25,77 @@ "text": "正在检查你的访问权限…" } }, + "activity": { + "title": "动态", + "subtitle": "需要你处理的事项,以及智能体推动的工作。", + "controlsAriaLabel": "动态控制项", + "windowAriaLabel": "动态时间范围", + "windows": { "today": "今天", "7d": "7 天" }, + "podScopeLabel": "Pod 范围", + "allPods": "所有 Pod", + "loadFailed": "无法加载动态,请重试。", + "openThread": "打开讨论", + "lastActive": "{{time}}前活跃", + "updatesCount_one": "{{count}} 条更新", + "updatesCount_other": "{{count}} 条更新", + "needsYou": { + "eyebrow": "决策队列", + "title": "需要你处理", + "description": "这里只显示直接提及和待处理的审批。", + "emptyTitle": "没有事项在等待你", + "emptyDescription": "有需要回复的提及或审批请求时,会显示在这里。", + "kinds": { "mention": "提及", "approval": "审批" } + }, + "dayZero": { + "kind": "开始使用", + "guide": { + "title": "认识你的向导", + "description": "通过简短引导,开始第一次与智能体的对话。", + "leaves": "向导回复后,此项会离开。", + "cta": "认识你的向导" + }, + "agent": { + "title": "聘用你的第一个智能体", + "description": "添加一位能和你一起推进工作的队友。", + "leaves": "智能体加入工作区后,此项会离开。", + "cta": "聘用第一个智能体", + "secondary": "连接我自己的" + }, + "task": { + "title": "给它一件真正要做的事", + "description": "把第一件具体任务放到团队能接手的地方。", + "leaves": "任务被认领后,此项会离开。", + "cta": "创建任务" + } + }, + "approval": { + "approve": "批准", + "reject": "拒绝", + "working": "正在保存…", + "actionFailed": "无法更新该审批,请重试。" + }, + "mention": { + "acknowledge": "确认", + "working": "正在保存…", + "actionFailed": "无法确认该提及,请重试。" + }, + "agents": { + "eyebrow": "智能体回顾", + "title": "你的智能体做了什么", + "description": "按智能体归类的事实更新回顾。", + "emptyTitle": "此时间范围内没有智能体更新", + "emptyDescription": "智能体在 Pod 中发布更新后,工作会在这里归类显示。" + }, + "board": { + "eyebrow": "看板变更", + "title": "看板", + "description": "此时间范围内发生变化的任务。", + "emptyTitle": "此时间范围内没有看板变更", + "emptyDescription": "任务变更会显示在这里,不会创建第二个看板。", + "status": { "pending": "待处理", "claimed": "进行中", "blocked": "已阻塞", "done": "已完成" } + }, + "footer": "请在源讨论中行动;底层事实变化后,这份回顾会自动离开。" + }, "auth": { "fields": { "username": "用户名", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index b3c52a023e..e426de00de 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -31,7 +31,7 @@ import V2AgentBYO from './components/V2AgentBYO'; import V2ConnectorsPage from './components/V2ConnectorsPage'; import V2PodBoard from './components/V2PodBoard'; import SkillsCatalogPage from '../components/skills/SkillsCatalogPage'; -import ActivityFeedPage from '../components/activity/ActivityFeedPage'; +import V2ActivityPage from './components/V2ActivityPage'; import AnalyticsDashboard from '../components/analytics/AnalyticsDashboard'; import ChatRoom from '../components/ChatRoom'; import ApiDevPage from '../components/ApiDevPage'; @@ -309,7 +309,7 @@ const V2App: React.FC = () => { /> )} + element={feature('Activity', 'Review updates, mentions, approvals, pod activity, and unread items.', , false, false)} /> { + const MockV2Avatar = ({ name }: { name: string }) => {name} avatar; + MockV2Avatar.displayName = 'MockV2Avatar'; + return MockV2Avatar; +}); + +const mockGet = axios.get as jest.Mock; +const mockPost = axios.post as jest.Mock; +const CurrentPath = () => { + const location = useLocation(); + return
{location.pathname}{location.search}
; +}; + +const recap = { + pods: [{ id: 'pod-1', name: 'Launch pod' }], + 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', + }], + agents: [{ + id: 'agent-1', name: 'release-agent', lastActiveAt: '2026-08-26T11:00:00.000Z', + messageCount: 2, recap: 'Posted two updates.', updates: [{ + id: 'update-1', podId: 'pod-1', podName: 'Launch pod', content: 'Checks passed.', + timestamp: '2026-08-26T11:00:00.000Z', + }], + }], + board: [{ + id: 'board-1', taskId: 'TASK-068', title: 'Activity tab', status: 'claimed', + podId: 'pod-1', podName: 'Launch pod', updatedAt: '2026-08-26T11:00:00.000Z', + lastUpdate: { author: 'release-agent', text: 'Implementation began.', createdAt: '2026-08-26T11:00:00.000Z' }, + }], +}; + +const renderPage = () => render( + + + + , +); + +describe('V2ActivityPage', () => { + beforeAll(async () => { await i18nReady; }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockGet.mockResolvedValue({ data: recap }); + await act(async () => { await i18n.changeLanguage('en'); }); + }); + + test('projects existing activity, direct interrupts, and board changes without inventing a queue count', async () => { + renderPage(); + + expect(await screen.findByRole('heading', { name: 'Activity' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Needs you' })).toBeInTheDocument(); + expect(screen.getByText('Review requested')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'What your agents did' })).toBeInTheDocument(); + expect(screen.getByText('release-agent')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Board' })).toBeInTheDocument(); + expect(screen.getByText('TASK-068')).toBeInTheDocument(); + expect(mockGet).toHaveBeenCalledWith('/api/activity/recap', expect.objectContaining({ + params: { window: 'today' }, + })); + }); + + test('changes the read window and opens the source pod from a factual queue row', async () => { + renderPage(); + await screen.findByText('Review requested'); + + fireEvent.click(screen.getByRole('button', { name: '7 days' })); + await waitFor(() => expect(mockGet).toHaveBeenLastCalledWith('/api/activity/recap', expect.objectContaining({ + params: { window: '7d' }, + }))); + + fireEvent.click(screen.getAllByRole('button', { name: 'Open thread' })[0]); + expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/pod-1'); + }); + + test('acknowledges a mention explicitly instead of treating a feed read as acknowledgement', async () => { + mockGet + .mockResolvedValueOnce({ data: recap }) + .mockResolvedValue({ data: { ...recap, needsYou: [] } }); + mockPost.mockResolvedValue({ data: { success: true } }); + renderPage(); + + fireEvent.click(await screen.findByRole('button', { name: 'Acknowledge' })); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith( + '/api/activity/mention-1/acknowledge', + {}, + expect.objectContaining({ headers: expect.any(Object) }), + )); + expect(await screen.findByText('Nothing is waiting on you')).toBeInTheDocument(); + }); + + test('keeps an empty Needs you state honest', async () => { + mockGet.mockResolvedValue({ data: { ...recap, needsYou: [] } }); + renderPage(); + + expect(await screen.findByText('Nothing is waiting on you')).toBeInTheDocument(); + expect(screen.queryByText(/0 needs you/i)).not.toBeInTheDocument(); + }); + + test('turns a truly empty workspace into the three factual onboarding rows', async () => { + mockGet.mockResolvedValue({ data: { ...recap, needsYou: [], agents: [], board: [] } }); + const onGuide = jest.fn(); + window.addEventListener(FIRST_RUN_REOPEN_EVENT, onGuide); + renderPage(); + + expect(await screen.findByRole('button', { name: 'Meet your Guide' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Hire your first agent' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create a task' })).toBeInTheDocument(); + expect(screen.queryByText('Nothing is waiting on you')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Meet your Guide' })); + expect(onGuide).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'Create a task' })); + expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/pod-1/board?createTask=1'); + window.removeEventListener(FIRST_RUN_REOPEN_EVENT, onGuide); + }); + + test('keeps an approval actionable and refreshes the fact after approval', async () => { + const approvalRecap = { + ...recap, + needsYou: [{ + id: 'approval-1', kind: 'approval', title: 'Approval requested', detail: 'Deploy the change.', + podId: 'pod-1', podName: 'Launch pod', timestamp: '2026-08-26T11:00:00.000Z', + }], + }; + mockGet + .mockResolvedValueOnce({ data: approvalRecap }) + .mockResolvedValue({ data: { ...approvalRecap, needsYou: [] } }); + mockPost.mockResolvedValue({ data: { success: true } }); + renderPage(); + + fireEvent.click(await screen.findByRole('button', { name: 'Approve' })); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith( + '/api/activity/approval-1/approve', + { notes: 'Approved via Activity' }, + expect.objectContaining({ headers: expect.any(Object) }), + )); + expect(await screen.findByText('Nothing is waiting on you')).toBeInTheDocument(); + }); + + test('offers Reject as the approval secondary action', async () => { + const approvalRecap = { + ...recap, + needsYou: [{ + id: 'approval-2', kind: 'approval', title: 'Approval requested', detail: 'Deploy the change.', + podId: 'pod-1', podName: 'Launch pod', timestamp: '2026-08-26T11:00:00.000Z', + }], + }; + mockGet.mockResolvedValue({ data: approvalRecap }); + mockPost.mockResolvedValue({ data: { success: true } }); + renderPage(); + + fireEvent.click(await screen.findByRole('button', { name: 'Reject' })); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith( + '/api/activity/approval-2/reject', + { notes: 'Rejected via Activity' }, + expect.objectContaining({ headers: expect.any(Object) }), + )); + }); +}); diff --git a/frontend/src/v2/__tests__/V2CommunityNav.test.tsx b/frontend/src/v2/__tests__/V2CommunityNav.test.tsx index 213f611f22..6e29739415 100644 --- a/frontend/src/v2/__tests__/V2CommunityNav.test.tsx +++ b/frontend/src/v2/__tests__/V2CommunityNav.test.tsx @@ -16,6 +16,11 @@ const mockLogout = jest.fn(); const mockAxiosGet = axios.get as jest.Mock; jest.mock('axios'); +jest.mock('../components/V2Avatar', () => { + const MockV2Avatar = () => ; + MockV2Avatar.displayName = 'MockV2Avatar'; + return MockV2Avatar; +}); jest.mock('../../context/AuthContext', () => ({ useAuth: () => ({ currentUser: { _id: 'user-1', username: 'Sam' }, @@ -78,14 +83,15 @@ describe('Community navigation', () => { await i18n.changeLanguage('en'); }); - test('shows the rail entry only when a community pod is configured', () => { + test('always shows Activity in the third rail slot and keeps Community off the rail', () => { const { unmount } = renderRail(); - expect(screen.getByRole('button', { name: 'Community' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Community' })).not.toBeInTheDocument(); unmount(); delete process.env.REACT_APP_COMMUNITY_POD_ID; renderRail(); - expect(screen.queryByRole('button', { name: 'Community' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument(); }); test('reopens the first-run guide from the feedback menu', async () => { diff --git a/frontend/src/v2/__tests__/V2PodBoard.test.tsx b/frontend/src/v2/__tests__/V2PodBoard.test.tsx index fe5770d402..42359b39b5 100644 --- a/frontend/src/v2/__tests__/V2PodBoard.test.tsx +++ b/frontend/src/v2/__tests__/V2PodBoard.test.tsx @@ -58,9 +58,9 @@ const wireAxios = (tasks = TASKS) => { }); }; -const renderBoard = () => render( +const renderBoard = (entry = '/v2/pods/pod-1/board') => render( - + } /> chat page} /> @@ -130,6 +130,12 @@ describe('V2PodBoard', () => { }); }); + test('opens the real task dialog when Activity hands it a create-task intent', async () => { + renderBoard('/v2/pods/pod-1/board?createTask=1'); + + expect(await screen.findByPlaceholderText('What needs to happen?')).toBeInTheDocument(); + }); + test('refetches when a task_updated socket event lands for this pod', async () => { const handlers = {}; mockSocketValue = { diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index e7e5cf94ca..6db88262d0 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -113,6 +113,16 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2).toMatch(/@media \(hover: none\), \(pointer: coarse\) \{[\s\S]*?\.v2-root input,\n\s*\.v2-root textarea,\n\s*\.v2-root select \{\n\s*font-size: 16px !important;/); }); + test('Activity queue actions stay in the row grammar and wrap on narrow screens', () => { + // Day-zero onboarding and approval actions share the queue row. Keeping + // their action cluster explicit prevents a later button refactor from + // forcing a third column past a 390px viewport. + // The desktop declaration shares its rule with the buttons, so it is not + // eligible for ruleBody's exact-selector helper. + expect(v2).toMatch(/\.v2-activity__queue-actions,\n\.v2-activity__queue-row button,[\s\S]*?\{\n\s*display: flex;[\s\S]*?gap: 6px;/); + expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__queue-actions \{ grid-column: 2; \}[\s\S]*?\.v2-activity__queue-actions \{ flex-wrap: wrap; \}/); + }); + test('the mobile inspector is a drawer, never display:none — the header avatars button must do something', () => { // Below 1024px the pane used display:none while V2Layout still mounted it // on tap: the avatar-stack button looked broken and members/files were @@ -423,6 +433,28 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(row).toContain('34px minmax(0, 1fr) auto'); }); + test('Activity cards have shrinkable desktop and mobile layout guards', () => { + // The recap is a feature-wide page, but it is still reachable at 390px. + // The zero-min grid tracks are the load-bearing no-horizontal-overflow + // rule; jsdom cannot observe the scrollbar they prevent. + expect(ruleBody(v2, '.v2-activity__agent-grid')) + .toContain('repeat(2, minmax(0, 1fr))'); + expect(ruleBody(v2, '.v2-activity__queue-row')) + .toContain('28px minmax(0, 1fr) auto'); + expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__agent-grid \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\)/); + expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__queue-row \{[\s\S]*?28px minmax\(0, 1fr\)/); + // Board rows do not inherit the queue icon column. At 390px that left + // 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\)/); + }); + + test('Activity queue actions distinguish an action from the thread handoff', () => { + expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button')).toContain('background: var(--v2-accent)'); + expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--secondary')).toContain('background: var(--v2-surface-hover)'); + expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--thread')).toContain('background: transparent'); + }); + test('the shared filter segment uses an unmistakable token-backed selected state', () => { const active = ruleBody(v2, '.v2-root button.v2-filter-segment__item--active'); diff --git a/frontend/src/v2/components/V2ActivityPage.tsx b/frontend/src/v2/components/V2ActivityPage.tsx new file mode 100644 index 0000000000..6049af17b6 --- /dev/null +++ b/frontend/src/v2/components/V2ActivityPage.tsx @@ -0,0 +1,369 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import V2Avatar from './V2Avatar'; +import { requestFirstRunGuide } from '../firstRunGuide'; + +type ActivityWindow = 'today' | '7d'; + +interface ActivityUpdate { + id: string; + podId: string | null; + podName: string; + content: string; + timestamp: string | null; +} + +interface AgentRecap { + id: string; + name: string; + profilePicture?: string; + lastActiveAt: string | null; + messageCount: number; + recap: string; + updates: ActivityUpdate[]; +} + +interface NeedsYouItem { + id: string; + kind: 'mention' | 'approval'; + title: string; + detail: string; + podId: string | null; + podName: string; + timestamp: string | null; +} + +interface BoardItem { + id: string; + taskId: string; + title: string; + status: 'pending' | 'claimed' | 'blocked' | 'done'; + podId: string; + podName: string; + updatedAt: string | null; + lastUpdate: { text: string; author: string; createdAt: string | null } | null; +} + +interface ActivityRecap { + pods: Array<{ id: string; name: string }>; + needsYou: NeedsYouItem[]; + agents: AgentRecap[]; + board: BoardItem[]; +} + +const relativeTime = (value: string | null | undefined): string => { + if (!value) return ''; + const elapsed = Math.max(0, Date.now() - new Date(value).getTime()); + const minutes = Math.floor(elapsed / 60_000); + if (minutes < 1) return 'now'; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +}; + +const V2ActivityPage: React.FC = () => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const [window, setWindow] = useState('today'); + const [podId, setPodId] = useState('all'); + const [recap, setRecap] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + const [actingApprovalId, setActingApprovalId] = useState(null); + const [acknowledgingMentionId, setAcknowledgingMentionId] = useState(null); + const [actionError, setActionError] = useState(null); + + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + const token = localStorage.getItem('token'); + axios.get('/api/activity/recap', { + headers: { 'x-auth-token': token ?? '' }, + params: { window, ...(podId !== 'all' ? { podId } : {}) }, + }) + .then((response) => { + if (active) setRecap(response.data); + }) + .catch(() => { + if (active) setError(t('activity.loadFailed')); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [podId, reloadKey, t, window]); + + const openPod = (targetPodId: string | null) => { + if (targetPodId) navigate(`/v2/pods/${targetPodId}`); + }; + + const openFirstBoard = () => { + const firstPod = recap?.pods[0]; + if (firstPod) { + navigate(`/v2/pods/${firstPod.id}/board?createTask=1`); + return; + } + navigate('/v2'); + }; + + const actOnApproval = async (item: NeedsYouItem, action: 'approve' | 'reject') => { + if (actingApprovalId) return; + setActingApprovalId(item.id); + setActionError(null); + try { + const token = localStorage.getItem('token'); + const response = await axios.post<{ success?: boolean }>( + `/api/activity/${item.id}/${action}`, + { notes: `${action === 'approve' ? 'Approved' : 'Rejected'} via Activity` }, + { headers: { 'x-auth-token': token ?? '' } }, + ); + if (!response.data?.success) throw new Error('Activity action failed'); + setReloadKey((value) => value + 1); + } catch { + setActionError(t('activity.approval.actionFailed')); + } finally { + setActingApprovalId(null); + } + }; + + const acknowledgeMention = async (item: NeedsYouItem) => { + if (acknowledgingMentionId) return; + setAcknowledgingMentionId(item.id); + setActionError(null); + try { + const token = localStorage.getItem('token'); + const response = await axios.post<{ success?: boolean }>( + `/api/activity/${item.id}/acknowledge`, + {}, + { headers: { 'x-auth-token': token ?? '' } }, + ); + if (!response.data?.success) throw new Error('Mention acknowledgement failed'); + setReloadKey((value) => value + 1); + } catch { + setActionError(t('activity.mention.actionFailed')); + } finally { + setAcknowledgingMentionId(null); + } + }; + + const isDayZero = podId === 'all' + && recap?.agents.length === 0 + && recap.board.length === 0; + + return ( +
+
+
+

{t('activity.title')}

+

{t('activity.subtitle')}

+
+
+
+ {(['today', '7d'] as ActivityWindow[]).map((value) => ( + + ))} +
+ +
+
+ + {loading &&
} + {!loading && error &&
{error}
} + {!loading && !error && recap && ( +
+
+
+
+
{t('activity.needsYou.eyebrow')}
+

{t('activity.needsYou.title')}

+
+

{t('activity.needsYou.description')}

+
+ {isDayZero ? ( +
+
+ +
+
{t('activity.dayZero.kind')}
+ {t('activity.dayZero.guide.title')} +

{t('activity.dayZero.guide.description')}

+ {t('activity.dayZero.guide.leaves')} +
+
+ +
+
+
+ +
+
{t('activity.dayZero.kind')}
+ {t('activity.dayZero.agent.title')} +

{t('activity.dayZero.agent.description')}

+ {t('activity.dayZero.agent.leaves')} +
+
+ + +
+
+
+ +
+
{t('activity.dayZero.kind')}
+ {t('activity.dayZero.task.title')} +

{t('activity.dayZero.task.description')}

+ {t('activity.dayZero.task.leaves')} +
+
+ +
+
+
+ ) : recap.needsYou.length === 0 ? ( +
+ {t('activity.needsYou.emptyTitle')} + {t('activity.needsYou.emptyDescription')} +
+ ) : ( +
+ {recap.needsYou.map((item) => ( +
+ +
+
{t(`activity.needsYou.kinds.${item.kind}`)}
+ {item.title} + {item.detail &&

{item.detail}

} + {item.podName}{item.timestamp ? ` · ${relativeTime(item.timestamp)}` : ''} +
+
+ {item.kind === 'approval' && ( + <> + + + + )} + {item.kind === 'mention' && ( + + )} + +
+
+ ))} +
+ )} + {actionError &&
{actionError}
} +
+ +
+
+
+
{t('activity.agents.eyebrow')}
+

{t('activity.agents.title')}

+
+

{t('activity.agents.description')}

+
+ {recap.agents.length === 0 ? ( +
+ {t('activity.agents.emptyTitle')} + {t('activity.agents.emptyDescription')} +
+ ) : ( +
+ {recap.agents.map((agent) => ( +
+
+ +
+

{agent.name}

+ {agent.lastActiveAt ? t('activity.lastActive', { time: relativeTime(agent.lastActiveAt) }) : ''} +
+ {t('activity.updatesCount', { count: agent.messageCount })} +
+

{agent.recap}

+
+ {agent.updates.map((update) => ( + + ))} +
+
+ ))} +
+ )} +
+ +
+
+
+
{t('activity.board.eyebrow')}
+

{t('activity.board.title')}

+
+

{t('activity.board.description')}

+
+ {recap.board.length === 0 ? ( +
+ {t('activity.board.emptyTitle')} + {t('activity.board.emptyDescription')} +
+ ) : ( +
+ {recap.board.map((item) => ( +
+
+ {t(`activity.board.status.${item.status}`)} +
+ {item.taskId} +

{item.title}

+ {item.lastUpdate &&

{item.lastUpdate.author ? `${item.lastUpdate.author}: ` : ''}{item.lastUpdate.text}

} +
+
+
+ {item.podName} + {relativeTime(item.updatedAt)} + +
+
+ ))} +
+ )} +
+
+ )} +
{t('activity.footer')}
+
+ ); +}; + +export default V2ActivityPage; diff --git a/frontend/src/v2/components/V2NavRail.tsx b/frontend/src/v2/components/V2NavRail.tsx index c4fce11661..29dc0526d6 100644 --- a/frontend/src/v2/components/V2NavRail.tsx +++ b/frontend/src/v2/components/V2NavRail.tsx @@ -20,13 +20,17 @@ const Icon = ({ d }: { d: string }) => ( ); -// Trimmed for YC demo path (2026-04-29): Pods · Agents · Community · Settings. -// Routes for Feed, Activity, Skills, Digest, Analytics still resolve — they -// just aren't reachable from the rail. Re-add when the surface earns its slot. +// Trimmed for the work-first path: Pods · Agents · Activity · Settings. +// Community remains available via its redirect and sidebar offer; Activity +// earned this rail slot as the agent-work recap surface (TASK-068). const NAV_ITEMS: NavItem[] = [ { key: 'pods', label: 'Pods', path: '/v2', icon: }, { key: 'agents', label: 'Agents', path: '/v2/agents', icon: }, - { key: 'community', label: 'Community', path: '/v2/community', icon: }, + // Rebase resolution (commander, 2026-08-26): Sam ruled Activity replaces + // the THIRD rail slot — Community's. Connectors arrived on main after this + // branch cut and keeps its slot; Community's surface remains reachable via + // /browse. + { key: 'activity', label: 'Activity', path: '/v2/activity', icon: }, { key: 'connectors', label: 'Connectors', path: '/v2/connectors', icon: }, // 'Apps' (marketplace) removed from the rail while the marketplace is behind // its "coming soon" wall — a nav item that only leads to a coming-soon page @@ -55,10 +59,6 @@ const V2NavRail: React.FC = ({ onPodsMobileNav }) => { // Labels resolve at render so the rail tooltip (v2.css attr(data-label)) // follows the active locale — NAV_ITEMS.label is the English fallback. const navLabel = (item: NavItem) => t(`common.nav.${item.key}`, { defaultValue: item.label }); - const visibleNavItems = NAV_ITEMS.filter( - (item) => item.key !== 'community' || Boolean(process.env.REACT_APP_COMMUNITY_POD_ID), - ); - const isActive = (item: NavItem): boolean => { if (item.path === '/v2') { return location.pathname === '/v2' || location.pathname.startsWith('/v2/pods'); @@ -90,7 +90,7 @@ const V2NavRail: React.FC = ({ onPodsMobileNav }) => {