From 2f302ec791399d705dbac293c8aa51764aaa8b92 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:17:44 -0700 Subject: [PATCH 1/4] feat(v2): add agent activity recap --- .../unit/routes/activity.identity.test.js | 1 + .../unit/routes/activity.read.test.js | 11 + .../activityService.agentMessages.test.js | 31 + .../services/activityService.recap.test.js | 80 ++ backend/routes/activity.ts | 24 + backend/services/activityService.ts | 178 ++++- .../src/components/activity/ActivityFeed.tsx | 615 --------------- .../components/activity/ActivityFeedPage.tsx | 737 ------------------ frontend/src/components/activity/index.ts | 2 - frontend/src/i18n/locales/en.json | 39 + frontend/src/i18n/locales/zh-CN.json | 39 + frontend/src/v2/V2App.tsx | 4 +- .../src/v2/__tests__/V2ActivityPage.test.tsx | 90 +++ .../src/v2/__tests__/V2CommunityNav.test.tsx | 12 +- .../v2/__tests__/v2-layout-invariants.test.ts | 16 + frontend/src/v2/components/V2ActivityPage.tsx | 253 ++++++ frontend/src/v2/components/V2NavRail.tsx | 18 +- frontend/src/v2/v2.css | 421 +++++++++- 18 files changed, 1200 insertions(+), 1371 deletions(-) create mode 100644 backend/__tests__/unit/services/activityService.agentMessages.test.js create mode 100644 backend/__tests__/unit/services/activityService.recap.test.js delete mode 100644 frontend/src/components/activity/ActivityFeed.tsx delete mode 100644 frontend/src/components/activity/ActivityFeedPage.tsx delete mode 100644 frontend/src/components/activity/index.ts create mode 100644 frontend/src/v2/__tests__/V2ActivityPage.test.tsx create mode 100644 frontend/src/v2/components/V2ActivityPage.tsx diff --git a/backend/__tests__/unit/routes/activity.identity.test.js b/backend/__tests__/unit/routes/activity.identity.test.js index 6fbae5dbf..33c99f0e6 100644 --- a/backend/__tests__/unit/routes/activity.identity.test.js +++ b/backend/__tests__/unit/routes/activity.identity.test.js @@ -21,6 +21,7 @@ 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 () => []), toggleLike: 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 fe53121ea..3c262c408 100644 --- a/backend/__tests__/unit/routes/activity.read.test.js +++ b/backend/__tests__/unit/routes/activity.read.test.js @@ -9,6 +9,7 @@ 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 () => []), toggleLike: jest.fn(async () => ({ success: true })), @@ -34,6 +35,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 })); 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 000000000..76db0d94f --- /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 000000000..e396060ce --- /dev/null +++ b/backend/__tests__/unit/services/activityService.recap.test.js @@ -0,0 +1,80 @@ +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 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; + + 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 }, + }], + }); + }); + + afterEach(() => { spy?.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'); + }); +}); diff --git a/backend/routes/activity.ts b/backend/routes/activity.ts index 4d6037cb0..b99a148c2 100644 --- a/backend/routes/activity.ts +++ b/backend/routes/activity.ts @@ -35,6 +35,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 || {}; diff --git a/backend/services/activityService.ts b/backend/services/activityService.ts index 0bae98b46..dd5e502f4 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 { @@ -87,6 +89,11 @@ interface GetFeedOptions { mode?: string; } +interface GetRecapOptions { + window?: 'today' | '7d'; + podId?: string; +} + interface ComputeFlagsOptions { actor?: ActorInfo; type?: string; @@ -98,6 +105,173 @@ 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))); + }); + + 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()); + + const needsYou = activities + .filter((activity) => { + const approval = activity.approval as { status?: string } | undefined; + return activity.flags?.isMention || approval?.status === 'pending'; + }) + .slice(0, 12) + .map((activity) => { + const approval = activity.approval as { status?: string } | undefined; + const isApproval = approval?.status === 'pending'; + 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 = {}, @@ -395,12 +569,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}`, diff --git a/frontend/src/components/activity/ActivityFeed.tsx b/frontend/src/components/activity/ActivityFeed.tsx deleted file mode 100644 index 472f35bc5..000000000 --- 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 0ff9ca0e0..000000000 --- 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 7f574ceb8..000000000 --- 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 7602b75e4..a63200a56 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,44 @@ "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" } + }, + "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 deltas", + "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 8ea1ff699..29a81ecac 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,44 @@ "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": "审批" } + }, + "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 b3c52a023..e426de00d 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 CurrentPath = () =>
{useLocation().pathname}
; + +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 deltas' })).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('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(); + }); +}); diff --git a/frontend/src/v2/__tests__/V2CommunityNav.test.tsx b/frontend/src/v2/__tests__/V2CommunityNav.test.tsx index 213f611f2..6e2973941 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__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index e7e5cf94c..a2b1a3db2 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -423,6 +423,22 @@ 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('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 000000000..9dd926fca --- /dev/null +++ b/frontend/src/v2/components/V2ActivityPage.tsx @@ -0,0 +1,253 @@ +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'; + +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); + + 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, t, window]); + + const openPod = (targetPodId: string | null) => { + if (targetPodId) navigate(`/v2/pods/${targetPodId}`); + }; + + 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')}

+
+ {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)}` : ''} +
+ +
+ ))} +
+ )} +
+ +
+
+
+
{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 c4fce1166..29dc0526d 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 }) => {