From 830b75222f992b76afc54bd9dce9b2bfa9e876bf Mon Sep 17 00:00:00 2001 From: Mubai Hua Date: Sun, 19 Apr 2026 23:14:31 -0700 Subject: [PATCH] Add project pod workspace --- AGENTS.md | 1 + .../unit/controllers/podController.test.js | 50 + backend/__tests__/unit/models/Pod.test.js | 25 + backend/controllers/podController.ts | 85 +- backend/models/Pod.ts | 35 +- backend/models/Task.ts | 49 + backend/routes/agentsRuntime.ts | 2 +- backend/routes/pods.ts | 3 +- backend/routes/tasksApi.ts | 194 ++- backend/start-dev.sh | 5 +- docker-compose.dev.yml | 2 +- docs/design/PROJECT_POD.md | 342 +++++ frontend/Dockerfile.dev | 7 +- frontend/src/App.tsx | 3 +- frontend/src/components/Pod.test.tsx | 33 +- frontend/src/components/Pod.tsx | 64 +- frontend/src/components/PodRedirect.test.tsx | 7 + frontend/src/components/PodRedirect.tsx | 12 + .../src/components/PodRoomRouter.test.tsx | 39 + frontend/src/components/PodRoomRouter.tsx | 14 + frontend/src/components/ProjectPodRoom.tsx | 1108 +++++++++++++++++ frontend/start-dev.sh | 19 + packages/types/src/pod.ts | 11 +- 23 files changed, 2057 insertions(+), 53 deletions(-) create mode 100644 docs/design/PROJECT_POD.md create mode 100644 frontend/src/components/PodRoomRouter.test.tsx create mode 100644 frontend/src/components/PodRoomRouter.tsx create mode 100644 frontend/src/components/ProjectPodRoom.tsx create mode 100644 frontend/start-dev.sh diff --git a/AGENTS.md b/AGENTS.md index bf7a7ace..421568a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,7 @@ Agents Hub `Installed` and `Discover` tabs should resolve agent avatars with the Registry installed-agent listing (`/api/registry/pods/:podId/agents`) should prefer matching template `iconUrl` by `(agentName + displayName)` before falling back to registry icon. Pod browse page (`/pods/:type`) should prioritize pre-entry UX: quick filters (`All`, `Joined`, `Discover`), preview-before-join action, and responsive controls that stay usable on phones. Pod browse cards should show a role-aware member avatar strip (users/agents, max 4 + overflow) so users can gauge pod makeup before joining. +Project pods (`type="project"`) use a dedicated two-tab workspace (`Chat`, `Tasks`) rather than the generic pod shell; keep the project header prominent, keep the brief/key info in the right sidebar on chat, and treat task state as structured project data rather than a kanban board. Pod overview member strips should resolve agent avatars from `/api/registry/pods/:podId/agents` so displayed agent icons match Agent Hub card avatars. Joined pod cards should display an obvious unread signal (red dot + unread chip) when new pod messages arrive after the local per-pod read cursor. Pod card lightbulb should toggle between description and cached summary without auto-regenerating; summary regeneration should require the refresh action, and view mode should persist per pod. diff --git a/backend/__tests__/unit/controllers/podController.test.js b/backend/__tests__/unit/controllers/podController.test.js index d8e86127..e15c7129 100644 --- a/backend/__tests__/unit/controllers/podController.test.js +++ b/backend/__tests__/unit/controllers/podController.test.js @@ -72,6 +72,56 @@ describe('podController', () => { expect(res.json).toHaveBeenCalledWith([]); }); + it('createPod accepts project type and project metadata', async () => { + const savedPod = { + _id: 'project-1', + name: 'Website Relaunch', + type: 'project', + projectMeta: { + goal: 'Ship the new marketing site', + scope: 'Landing page and onboarding', + successCriteria: ['launch', 'handoff'], + status: 'planning', + }, + populate: jest.fn().mockResolvedValue(), + }; + const save = jest.fn().mockResolvedValue(savedPod); + Pod.mockImplementation((payload) => ({ + ...payload, + save, + })); + AgentRegistry.findOne.mockResolvedValue(null); + + const req = { + body: { + name: 'Website Relaunch', + description: 'Project pod', + type: 'project', + projectMeta: { + goal: 'Ship the new marketing site', + scope: 'Landing page and onboarding', + successCriteria: ['launch', 'handoff'], + }, + }, + userId: 'creator', + }; + const res = { json: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() }; + + await podController.createPod(req, res); + + expect(Pod).toHaveBeenCalledWith(expect.objectContaining({ + type: 'project', + projectMeta: expect.objectContaining({ + goal: 'Ship the new marketing site', + scope: 'Landing page and onboarding', + successCriteria: ['launch', 'handoff'], + status: 'planning', + ownerIds: ['creator'], + }), + })); + expect(res.json).toHaveBeenCalledWith(savedPod); + }); + it('createPod accepts agent-ensemble type', async () => { const savedPod = { _id: 'p1', populate: jest.fn().mockResolvedValue() }; const save = jest.fn().mockResolvedValue(savedPod); diff --git a/backend/__tests__/unit/models/Pod.test.js b/backend/__tests__/unit/models/Pod.test.js index d63d8351..65ec46b9 100644 --- a/backend/__tests__/unit/models/Pod.test.js +++ b/backend/__tests__/unit/models/Pod.test.js @@ -174,4 +174,29 @@ describe('Pod Model Tests', () => { mockExternalLinkId.toString(), ); }); + + it('should save project metadata on project pods', async () => { + const pod = new Pod({ + name: 'Project Pod', + description: 'Work workspace', + type: 'project', + createdBy: testUser._id, + projectMeta: { + goal: 'Launch v1', + scope: 'Frontend and backend', + successCriteria: ['launch checklist complete'], + status: 'planning', + keyLinks: [{ label: 'Figma', url: 'https://example.com/figma' }], + }, + }); + + const savedPod = await pod.save(); + + expect(savedPod.type).toBe('project'); + expect(savedPod.projectMeta.goal).toBe('Launch v1'); + expect(savedPod.projectMeta.scope).toBe('Frontend and backend'); + expect(savedPod.projectMeta.successCriteria).toEqual(['launch checklist complete']); + expect(savedPod.projectMeta.status).toBe('planning'); + expect(savedPod.projectMeta.keyLinks).toHaveLength(1); + }); }); diff --git a/backend/controllers/podController.ts b/backend/controllers/podController.ts index 2a019439..0e5229ce 100644 --- a/backend/controllers/podController.ts +++ b/backend/controllers/podController.ts @@ -16,7 +16,7 @@ if (process.env.PG_HOST) { PGMessage = require('../models/pg/Message'); } -const VALID_POD_TYPES = ['chat', 'study', 'games', 'agent-ensemble', 'agent-admin', 'agent-room', 'team']; +const VALID_POD_TYPES = ['chat', 'study', 'games', 'project', 'agent-ensemble', 'agent-admin', 'agent-room', 'team']; const DEFAULT_POD_AGENT = process.env.DEFAULT_POD_AGENT_NAME || 'commonly-bot'; const DEFAULT_POD_AGENT_SCOPES = [ 'context:read', @@ -242,7 +242,7 @@ exports.getPodById = async (req: any, res: any) => { exports.createPod = async (req: any, res: any) => { try { const { - name, description, type, joinPolicy, parentPod, + name, description, type, joinPolicy, parentPod, projectMeta, } = req.body; if (!name || !type) { @@ -258,6 +258,19 @@ exports.createPod = async (req: any, res: any) => { description, type, joinPolicy: joinPolicy === 'invite-only' ? 'invite-only' : 'open', + projectMeta: type === 'project' + ? { + goal: projectMeta?.goal || description || '', + scope: projectMeta?.scope || '', + successCriteria: Array.isArray(projectMeta?.successCriteria) ? projectMeta.successCriteria : [], + status: projectMeta?.status || 'planning', + dueDate: projectMeta?.dueDate || null, + ownerIds: Array.isArray(projectMeta?.ownerIds) && projectMeta.ownerIds.length + ? projectMeta.ownerIds + : [req.userId], + keyLinks: Array.isArray(projectMeta?.keyLinks) ? projectMeta.keyLinks : [], + } + : undefined, parentPod: parentPod || null, createdBy: req.userId, members: [req.userId], @@ -307,6 +320,74 @@ exports.createPod = async (req: any, res: any) => { } }; +exports.updatePod = async (req: any, res: any) => { + try { + const { id } = req.params; + const { + name, description, joinPolicy, parentPod, projectMeta, + } = req.body || {}; + + const pod = await Pod.findById(id); + if (!pod) { + return res.status(404).json({ msg: 'Pod not found' }); + } + + const requesterId = req.userId || req.user?.id || req.user?._id; + const isCreator = pod.createdBy.toString() === requesterId?.toString(); + const isGlobalAdmin = await isGlobalAdminRequest(req); + if (!isCreator && !isGlobalAdmin) { + return res.status(403).json({ msg: 'Not authorized to update this pod' }); + } + + if (typeof name === 'string') pod.name = name.trim(); + if (typeof description === 'string') pod.description = description.trim(); + if (joinPolicy === 'invite-only' || joinPolicy === 'open') pod.joinPolicy = joinPolicy; + if (parentPod !== undefined) pod.parentPod = parentPod || null; + + if (pod.type === 'project' && projectMeta && typeof projectMeta === 'object') { + if (typeof projectMeta.goal === 'string') pod.projectMeta.goal = projectMeta.goal.trim(); + if (typeof projectMeta.scope === 'string') pod.projectMeta.scope = projectMeta.scope.trim(); + if (Array.isArray(projectMeta.successCriteria)) { + pod.projectMeta.successCriteria = projectMeta.successCriteria + .map((value: unknown) => String(value || '').trim()) + .filter(Boolean); + } + if (['planning', 'on-track', 'at-risk', 'blocked', 'complete'].includes(String(projectMeta.status))) { + pod.projectMeta.status = String(projectMeta.status); + } + if (projectMeta.dueDate === null || projectMeta.dueDate === '') { + pod.projectMeta.dueDate = null; + } else if (projectMeta.dueDate) { + pod.projectMeta.dueDate = new Date(projectMeta.dueDate); + } + if (Array.isArray(projectMeta.ownerIds)) { + pod.projectMeta.ownerIds = projectMeta.ownerIds; + } + if (Array.isArray(projectMeta.keyLinks)) { + pod.projectMeta.keyLinks = projectMeta.keyLinks + .map((link: any) => ({ + label: String(link?.label || '').trim(), + url: String(link?.url || '').trim(), + })) + .filter((link: any) => link.label || link.url); + } + } + + pod.updatedAt = Date.now(); + await pod.save(); + await pod.populate('createdBy', 'username profilePicture'); + await pod.populate('members', 'username profilePicture'); + + return res.json(pod); + } catch (err: any) { + console.error('Error updating pod:', err.message); + if (err.kind === 'ObjectId') { + return res.status(404).json({ msg: 'Pod not found' }); + } + return res.status(500).json({ msg: 'Server Error' }); + } +}; + // Join a pod exports.joinPod = async (req: any, res: any) => { try { diff --git a/backend/models/Pod.ts b/backend/models/Pod.ts index e55ee6d9..572ad056 100644 --- a/backend/models/Pod.ts +++ b/backend/models/Pod.ts @@ -1,9 +1,10 @@ import mongoose, { Document, Schema, Types } from 'mongoose'; -export type PodType = 'chat' | 'study' | 'games' | 'agent-ensemble' | 'agent-admin' | 'agent-room' | 'team'; +export type PodType = 'chat' | 'study' | 'games' | 'project' | 'agent-ensemble' | 'agent-admin' | 'agent-room' | 'team'; export type PodJoinPolicy = 'open' | 'invite-only'; export type EnsembleParticipantRole = 'starter' | 'responder' | 'synthesizer' | 'observer'; export type HumanParticipation = 'none' | 'read-only' | 'participate'; +export type ProjectStatus = 'planning' | 'on-track' | 'at-risk' | 'blocked' | 'complete'; export interface IEnsembleParticipant { agentType: string; @@ -16,6 +17,18 @@ export interface IPod extends Document { description?: string; type: PodType; joinPolicy: PodJoinPolicy; + projectMeta: { + goal?: string; + scope?: string; + successCriteria: string[]; + status: ProjectStatus; + dueDate?: Date | null; + ownerIds: Types.ObjectId[]; + keyLinks: Array<{ + label: string; + url: string; + }>; + }; parentPod?: Types.ObjectId | null; agentEnsemble: { enabled: boolean; @@ -48,7 +61,7 @@ const PodSchema = new Schema( description: { type: String, trim: true }, type: { type: String, - enum: ['chat', 'study', 'games', 'agent-ensemble', 'agent-admin', 'agent-room', 'team'], + enum: ['chat', 'study', 'games', 'project', 'agent-ensemble', 'agent-admin', 'agent-room', 'team'], default: 'chat', }, joinPolicy: { @@ -56,6 +69,24 @@ const PodSchema = new Schema( enum: ['open', 'invite-only'], default: 'open', }, + projectMeta: { + goal: { type: String, trim: true, default: '' }, + scope: { type: String, trim: true, default: '' }, + successCriteria: { type: [String], default: [] }, + status: { + type: String, + enum: ['planning', 'on-track', 'at-risk', 'blocked', 'complete'], + default: 'planning', + }, + dueDate: { type: Date, default: null }, + ownerIds: [{ type: Schema.Types.ObjectId, ref: 'User' }], + keyLinks: [ + { + label: { type: String, trim: true, default: '' }, + url: { type: String, trim: true, default: '' }, + }, + ], + }, parentPod: { type: Schema.Types.ObjectId, ref: 'Pod', default: null }, agentEnsemble: { enabled: { type: Boolean, default: false }, diff --git a/backend/models/Task.ts b/backend/models/Task.ts index 7d7ab697..ea23893b 100644 --- a/backend/models/Task.ts +++ b/backend/models/Task.ts @@ -1,11 +1,15 @@ import mongoose, { Document, Model, Schema, Types } from 'mongoose'; export type TaskStatus = 'pending' | 'claimed' | 'done' | 'blocked'; +export type TaskUpdateKind = 'note' | 'progress' | 'blocker' | 'handoff' | 'decision' | 'completion'; export interface ITaskUpdate { text: string; author: string; authorId?: string | null; + kind?: TaskUpdateKind; + progressPercent?: number | null; + nextStep?: string | null; createdAt: Date; } @@ -14,11 +18,17 @@ export interface ITask extends Document { taskNum: number; taskId: string; title: string; + description?: string | null; assignee?: string | null; + assigneeType?: 'human' | 'agent' | null; + assigneeRef?: string | null; dep?: string | null; depMockOk: boolean; parentTask?: string | null; status: TaskStatus; + priority?: 'low' | 'medium' | 'high' | null; + dueDate?: Date | null; + progressPercent?: number | null; claimedBy?: string | null; claimedAt?: Date | null; completedAt?: Date | null; @@ -28,6 +38,15 @@ export interface ITask extends Document { sourceRef?: string; githubIssueNumber?: number | null; githubIssueUrl?: string | null; + blocker?: { + open: boolean; + reason?: string | null; + waitingOn?: string | null; + severity?: 'low' | 'medium' | 'high' | null; + openedAt?: Date | null; + openedBy?: string | null; + resolvedAt?: Date | null; + }; updates: ITaskUpdate[]; createdAt: Date; updatedAt: Date; @@ -39,7 +58,10 @@ const TaskSchema = new Schema( taskNum: { type: Number, required: true }, taskId: { type: String, required: true }, title: { type: String, required: true }, + description: { type: String, default: null }, assignee: { type: String, default: null }, + assigneeType: { type: String, enum: ['human', 'agent'], default: null }, + assigneeRef: { type: String, default: null }, dep: { type: String, default: null }, depMockOk: { type: Boolean, default: false }, parentTask: { type: String, default: null }, @@ -48,6 +70,13 @@ const TaskSchema = new Schema( enum: ['pending', 'claimed', 'done', 'blocked'], default: 'pending', }, + priority: { + type: String, + enum: ['low', 'medium', 'high'], + default: 'medium', + }, + dueDate: { type: Date, default: null }, + progressPercent: { type: Number, default: 0, min: 0, max: 100 }, claimedBy: { type: String, default: null }, claimedAt: { type: Date, default: null }, completedAt: { type: Date, default: null }, @@ -57,11 +86,31 @@ const TaskSchema = new Schema( sourceRef: { type: String }, githubIssueNumber: { type: Number, default: null }, githubIssueUrl: { type: String, default: null }, + blocker: { + open: { type: Boolean, default: false }, + reason: { type: String, default: null }, + waitingOn: { type: String, default: null }, + severity: { + type: String, + enum: ['low', 'medium', 'high'], + default: 'medium', + }, + openedAt: { type: Date, default: null }, + openedBy: { type: String, default: null }, + resolvedAt: { type: Date, default: null }, + }, updates: [ { text: { type: String, required: true }, author: { type: String, required: true }, authorId: { type: String, default: null }, + kind: { + type: String, + enum: ['note', 'progress', 'blocker', 'handoff', 'decision', 'completion'], + default: 'note', + }, + progressPercent: { type: Number, default: null, min: 0, max: 100 }, + nextStep: { type: String, default: null }, createdAt: { type: Date, default: Date.now }, }, ], diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 976c076e..9ffcdc72 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -1676,7 +1676,7 @@ router.post('/pods', agentRuntimeAuth, async (req: any, res: any) => { return res.status(400).json({ message: 'name and type are required' }); } - const VALID_POD_TYPES = ['chat', 'study', 'games', 'agent-ensemble', 'agent-admin']; + const VALID_POD_TYPES = ['chat', 'project', 'study', 'games', 'agent-ensemble', 'agent-admin']; if (!VALID_POD_TYPES.includes(type)) { return res.status(400).json({ message: `Invalid pod type. Must be one of: ${VALID_POD_TYPES.join(', ')}` }); } diff --git a/backend/routes/pods.ts b/backend/routes/pods.ts index a5ca15bb..4cd1100f 100644 --- a/backend/routes/pods.ts +++ b/backend/routes/pods.ts @@ -7,7 +7,7 @@ const multer = require('multer'); // eslint-disable-next-line global-require const auth = require('../middleware/auth'); // eslint-disable-next-line global-require -const { getAllPods, getPodsByType, getPodById, createPod, joinPod, leavePod, removeMember, deletePod } = require('../controllers/podController'); +const { getAllPods, getPodsByType, getPodById, createPod, updatePod, joinPod, leavePod, removeMember, deletePod } = require('../controllers/podController'); // eslint-disable-next-line global-require const Pod = require('../models/Pod'); // eslint-disable-next-line global-require @@ -59,6 +59,7 @@ const upload = multer({ router.get('/', auth, getAllPods); router.post('/', auth, createPod); +router.patch('/:id', auth, updatePod); router.post('/announcement', auth, async (req: AuthReq, res: Res) => { try { diff --git a/backend/routes/tasksApi.ts b/backend/routes/tasksApi.ts index 014d22ce..04779d61 100644 --- a/backend/routes/tasksApi.ts +++ b/backend/routes/tasksApi.ts @@ -13,8 +13,12 @@ const Task = require('../models/Task'); // eslint-disable-next-line global-require const User = require('../models/User'); // eslint-disable-next-line global-require +const { AgentInstallation } = require('../models/AgentRegistry'); +// eslint-disable-next-line global-require const GitHubAppService = require('../services/githubAppService'); // eslint-disable-next-line global-require +const AgentEventService = require('../services/agentEventService'); +// eslint-disable-next-line global-require const { emitTaskUpdated } = require('../services/taskEventService'); interface AuthReq { @@ -55,6 +59,74 @@ function resolveAgentInstanceId(req: AuthReq): string | null { return req.user.botMetadata?.instanceId || req.user.botMetadata?.agentName || null; } +function slugify(value: unknown): string { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +async function enqueueTaskAssignedIfNeeded({ + podId, + task, +}: { + podId: string; + task: Record; +}): Promise { + if (!task || task.assigneeType !== 'agent') return; + + const assigneeRef = String(task.assigneeRef || '').trim().toLowerCase(); + const assigneeLabel = String(task.assignee || '').trim(); + + let installation = null; + if (assigneeRef) { + installation = await AgentInstallation.findOne({ + podId: mongoose.Types.ObjectId.createFromHexString(podId), + instanceId: assigneeRef, + status: 'active', + }).lean(); + } + + if (!installation && assigneeLabel) { + const installs = await AgentInstallation.find({ + podId: mongoose.Types.ObjectId.createFromHexString(podId), + status: 'active', + }).select('agentName instanceId displayName').lean(); + const normalizedLabel = slugify(assigneeLabel); + installation = installs.find((item: any) => ( + slugify(item.instanceId) === normalizedLabel + || slugify(item.displayName) === normalizedLabel + || slugify(item.agentName) === normalizedLabel + || slugify(`${item.agentName}-${item.instanceId}`) === normalizedLabel + )) || null; + } + + if (!installation) return; + + await AgentEventService.enqueue({ + agentName: installation.agentName, + instanceId: installation.instanceId || 'default', + podId, + type: 'task.assigned', + payload: { + taskId: task.taskId, + title: task.title, + description: task.description || null, + notes: task.notes || null, + priority: task.priority || 'medium', + dueDate: task.dueDate || null, + progressPercent: task.progressPercent ?? 0, + assignee: task.assignee || null, + assigneeRef: task.assigneeRef || null, + dep: task.dep || null, + parentTask: task.parentTask || null, + blocker: task.blocker || null, + }, + }); +} + async function requirePodMember(podId: string, userId: unknown, { write = false } = {}): Promise<{ error?: string; status?: number; pod?: unknown }> { const pod = await Pod.findById(podId).lean() as { members?: Array<{ userId?: { toString: () => string }; toString: () => string; role?: string }> } | null; if (!pod) return { error: 'Pod not found', status: 404 }; @@ -97,7 +169,26 @@ router.post('/:podId', auth, async (req: AuthReq, res: Res) => { try { const { podId } = req.params || {}; const userId = req.userId || req.user?._id || req.agentUser?._id; - const { title, assignee, dep, depMockOk, parentTask, source, sourceRef, githubIssueNumber, githubIssueUrl, createGithubIssue } = (req.body || {}) as { title?: string; assignee?: string; dep?: string; depMockOk?: boolean; parentTask?: string; source?: string; sourceRef?: string; githubIssueNumber?: number; githubIssueUrl?: string; createGithubIssue?: boolean }; + const { + title, description, assignee, assigneeType, assigneeRef, dep, depMockOk, parentTask, + source, sourceRef, githubIssueNumber, githubIssueUrl, createGithubIssue, priority, dueDate, + } = (req.body || {}) as { + title?: string; + description?: string; + assignee?: string; + assigneeType?: 'human' | 'agent'; + assigneeRef?: string; + dep?: string; + depMockOk?: boolean; + parentTask?: string; + source?: string; + sourceRef?: string; + githubIssueNumber?: number; + githubIssueUrl?: string; + createGithubIssue?: boolean; + priority?: 'low' | 'medium' | 'high'; + dueDate?: string; + }; if (!title) return res.status(400).json({ error: 'title is required' }); const access = await requirePodMember(podId || '', userId, { write: true }); if (access.error) return res.status(access.status || 500).json({ error: access.error }); @@ -140,7 +231,26 @@ router.post('/:podId', auth, async (req: AuthReq, res: Res) => { if (sourceRef) initUpdate.text = `Created by ${author} from ${sourceRef}${assignee ? ` · assigned to ${assignee}` : ''}`; if (ghNumber) initUpdate.text += ` · GH#${ghNumber}`; if (parentTask) initUpdate.text += ` · sub-task of ${parentTask}`; - const task = await Task.create({ podId, taskNum, taskId, title, assignee: assignee || null, dep: dep || null, depMockOk: !!depMockOk, parentTask: parentTask || null, source: source || (ghNumber ? 'github' : 'human'), sourceRef: sourceRef || (ghNumber ? `GH#${ghNumber}` : undefined), githubIssueNumber: ghNumber, githubIssueUrl: ghUrl, updates: [initUpdate] }); + const task = await Task.create({ + podId, + taskNum, + taskId, + title, + description: description || null, + assignee: assignee || null, + assigneeType: assigneeType || (assignee ? 'human' : null), + assigneeRef: assigneeRef || null, + dep: dep || null, + depMockOk: !!depMockOk, + parentTask: parentTask || null, + priority: priority || 'medium', + dueDate: dueDate ? new Date(dueDate) : null, + source: source || (ghNumber ? 'github' : 'human'), + sourceRef: sourceRef || (ghNumber ? `GH#${ghNumber}` : undefined), + githubIssueNumber: ghNumber, + githubIssueUrl: ghUrl, + updates: [initUpdate], + }); if (parentTask && GitHubAppService.isPatConfigured()) { try { const parent = await Task.findOne({ podId: mongoose.Types.ObjectId.createFromHexString(podId || ''), taskId: parentTask }).lean() as { githubIssueNumber?: number } | null; @@ -153,6 +263,10 @@ router.post('/:podId', auth, async (req: AuthReq, res: Res) => { } } emitTaskUpdated(podId, task, 'created'); + await enqueueTaskAssignedIfNeeded({ + podId, + task: task.toObject ? task.toObject() : task, + }); return res.status(201).json({ task }); } catch (err) { console.error('POST /tasks error:', err); @@ -192,7 +306,34 @@ router.post('/:podId/:taskId/complete', auth, async (req: AuthReq, res: Res) => const access = await requirePodMember(podId || '', userId, { write: true }); if (access.error) return res.status(access.status || 500).json({ error: access.error }); const updateText = prUrl ? `Completed by ${author} · PR: ${prUrl}` : `Completed by ${author}`; - const update = { $set: { status: 'done', completedAt: new Date(), ...(prUrl && { prUrl }), ...(notes && { notes }) }, $push: { updates: { text: updateText, author, authorId: userId?.toString() || null, createdAt: new Date() } } }; + const update = { + $set: { + status: 'done', + completedAt: new Date(), + progressPercent: 100, + blocker: { + open: false, + reason: null, + waitingOn: null, + severity: 'medium', + openedAt: null, + openedBy: null, + resolvedAt: new Date(), + }, + ...(prUrl && { prUrl }), + ...(notes && { notes }), + }, + $push: { + updates: { + text: updateText, + author, + authorId: userId?.toString() || null, + createdAt: new Date(), + kind: 'completion', + progressPercent: 100, + }, + }, + }; const task = await Task.findOneAndUpdate({ podId: mongoose.Types.ObjectId.createFromHexString(podId || ''), taskId, status: { $in: ['claimed', 'pending'] } }, update, { new: true }) as { githubIssueNumber?: number; taskId?: string; updates?: unknown[] } | null; if (!task) { const existing = await Task.findOne({ podId: mongoose.Types.ObjectId.createFromHexString(podId || ''), taskId }).lean() as { status?: string } | null; @@ -226,12 +367,38 @@ router.post('/:podId/:taskId/updates', auth, async (req: AuthReq, res: Res) => { try { const { podId, taskId } = req.params || {}; const userId = req.userId || req.user?._id || req.agentUser?._id; - const { text } = (req.body || {}) as { text?: string }; + const { text, kind, progressPercent, nextStep } = (req.body || {}) as { + text?: string; + kind?: 'note' | 'progress' | 'blocker' | 'handoff' | 'decision' | 'completion'; + progressPercent?: number; + nextStep?: string; + }; if (!text?.trim()) return res.status(400).json({ error: 'text is required' }); const access = await requirePodMember(podId || '', userId, { write: true }); if (access.error) return res.status(access.status || 500).json({ error: access.error }); const author = await resolveAuthor(req); - const task = await Task.findOneAndUpdate({ podId: mongoose.Types.ObjectId.createFromHexString(podId || ''), taskId }, { $push: { updates: { text: text.trim(), author, authorId: userId?.toString() || null, createdAt: new Date() } } }, { new: true }); + const updatePayload: Record = { + text: text.trim(), + author, + authorId: userId?.toString() || null, + createdAt: new Date(), + kind: kind || 'note', + }; + if (progressPercent !== undefined && progressPercent !== null) { + updatePayload.progressPercent = Math.max(0, Math.min(100, Number(progressPercent))); + } + if (nextStep) updatePayload.nextStep = String(nextStep); + const taskUpdate: Record = { + $push: { updates: updatePayload }, + }; + if (progressPercent !== undefined && progressPercent !== null) { + taskUpdate.$set = { progressPercent: Math.max(0, Math.min(100, Number(progressPercent))) }; + } + const task = await Task.findOneAndUpdate( + { podId: mongoose.Types.ObjectId.createFromHexString(podId || ''), taskId }, + taskUpdate, + { new: true }, + ); if (!task) return res.status(404).json({ error: 'Task not found' }); emitTaskUpdated(podId, task, 'updated'); return res.json({ task }); @@ -245,7 +412,10 @@ router.patch('/:podId/:taskId', auth, async (req: AuthReq, res: Res) => { try { const { podId, taskId } = req.params || {}; const userId = req.userId || req.user?._id || req.agentUser?._id; - const allowed = ['title', 'assignee', 'dep', 'depMockOk', 'parentTask', 'status', 'notes', 'prUrl']; + const allowed = [ + 'title', 'description', 'assignee', 'assigneeType', 'assigneeRef', 'dep', 'depMockOk', 'parentTask', + 'status', 'notes', 'prUrl', 'priority', 'dueDate', 'progressPercent', 'blocker', + ]; const fieldUpdates: Record = {}; const body = (req.body || {}) as Record; allowed.forEach((k) => { if (body[k] !== undefined) fieldUpdates[k] = body[k]; }); @@ -255,17 +425,29 @@ router.patch('/:podId/:taskId', auth, async (req: AuthReq, res: Res) => { const author = await resolveAuthor(req); const changeParts: string[] = []; if (fieldUpdates.assignee !== undefined) changeParts.push(`reassigned to ${fieldUpdates.assignee || 'unassigned'}`); + if (fieldUpdates.assigneeType !== undefined) changeParts.push(`assignee type → ${fieldUpdates.assigneeType || 'none'}`); if (fieldUpdates.status !== undefined) changeParts.push(`status → ${fieldUpdates.status}`); if (fieldUpdates.dep !== undefined) changeParts.push(`dep → ${fieldUpdates.dep || 'none'}`); if (fieldUpdates.parentTask !== undefined) changeParts.push(`parent → ${fieldUpdates.parentTask || 'none'}`); if (fieldUpdates.prUrl !== undefined) changeParts.push(`PR: ${fieldUpdates.prUrl}`); if (fieldUpdates.notes !== undefined) changeParts.push('notes updated'); if (fieldUpdates.title !== undefined) changeParts.push('title updated'); + if (fieldUpdates.progressPercent !== undefined) changeParts.push(`progress → ${fieldUpdates.progressPercent}%`); + if (fieldUpdates.blocker !== undefined) { + const blocker = fieldUpdates.blocker as { open?: boolean }; + changeParts.push(blocker?.open ? 'blocker raised' : 'blocker cleared'); + } const update: Record = { $set: fieldUpdates }; if (changeParts.length > 0) update.$push = { updates: { text: `${author} updated: ${changeParts.join(', ')}`, author, authorId: userId?.toString() || null, createdAt: new Date() } }; const task = await Task.findOneAndUpdate({ podId: mongoose.Types.ObjectId.createFromHexString(podId || ''), taskId }, update, { new: true }); if (!task) return res.status(404).json({ error: 'Task not found' }); emitTaskUpdated(podId, task, 'updated'); + if (fieldUpdates.assignee !== undefined || fieldUpdates.assigneeRef !== undefined || fieldUpdates.assigneeType !== undefined) { + await enqueueTaskAssignedIfNeeded({ + podId, + task: task.toObject ? task.toObject() : task, + }); + } return res.json({ task }); } catch (err) { console.error('PATCH /tasks error:', err); diff --git a/backend/start-dev.sh b/backend/start-dev.sh index 8fecfe83..120e6580 100644 --- a/backend/start-dev.sh +++ b/backend/start-dev.sh @@ -6,7 +6,10 @@ echo "Starting Commonly Backend (dev)..." if [ ! -d "/app/node_modules" ] \ || [ -z "$(ls -A /app/node_modules 2>/dev/null)" ] \ || [ ! -d "/app/node_modules/@google/generative-ai" ] \ - || [ ! -f "/app/node_modules/.bin/ts-node" ]; then + || [ ! -f "/app/node_modules/.bin/ts-node" ] \ + || [ ! -f "/app/node_modules/.bin/jest" ] \ + || [ ! -f "/app/node_modules/.bin/tsc" ] \ + || [ ! -d "/app/node_modules/ts-jest" ]; then echo "Installing backend dependencies (dev)..." npm install --include=dev else diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1a9edb1b..ddbdd364 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -23,7 +23,7 @@ services: POSTGRES_PASSWORD: ${PG_PASSWORD:-postgres} POSTGRES_DB: ${PG_DATABASE:-commonly} volumes: - - postgres-data-dev:/var/lib/postgresql/data + - postgres-data-dev:/var/lib/postgresql networks: - app-network-dev diff --git a/docs/design/PROJECT_POD.md b/docs/design/PROJECT_POD.md new file mode 100644 index 00000000..bf09fcef --- /dev/null +++ b/docs/design/PROJECT_POD.md @@ -0,0 +1,342 @@ +# Project Pod + +## Summary + +`Project` is a new pod type for work coordination between humans and agents. + +It keeps the pod model, chat transport, agent installs, and shared memory that already exist in Commonly, but it replaces the generic pod presentation with a dedicated execution-first workspace: + +- `Chat` tab: + - persistent project header + - main chat as the primary collaboration surface + - right sidebar for brief, success criteria, key links, owners, blockers, and member/agent context +- `Tasks` tab: + - full task navigator + task detail workspace + - no board view + - task actions centered on assignment, progress, blockers, and completion + +The intent is to shift Commonly from "social/chat with tasks attached" toward "work coordination with chat built in." + +## Goals + +- Keep pods as the collaboration boundary. +- Add a work-first pod template without breaking existing chat/study/game/team pods. +- Make humans and agents feel like members of the same delivery team. +- Track execution state in structured task records instead of freeform chat. +- Preserve existing agent runtime/event plumbing so project pods participate in the same ecosystem. + +## UX + +### Header + +The top of a project pod always shows: + +- project name +- short description +- project health/status +- due date +- open-task and blocker counts +- primary actions: `New Task`, `Edit Project` + +### Chat Tab + +The chat tab stays primary for day-to-day collaboration. + +Layout: + +- main center column: chat stream + composer +- right sidebar: + - brief/goal + - scope + - success criteria + - key information + - key links + - members and installed agents + +Rule: + +- chat is for discussion +- task updates may be discussed in chat +- source of truth for execution lives in tasks, not chat messages + +### Tasks Tab + +The tasks tab is not a kanban board. + +Layout: + +- left rail: + - search + - filters (`all`, `mine`, `human`, `agent`, `blocked`, `done`) + - task list +- main pane: + - task header and status + - assignee/meta chips + - description + - blocker state + - update timeline + - task action buttons + +## Task Workflow + +Current implementation maps onto the existing backend task statuses: + +- `pending` → Todo +- `claimed` → In Progress +- `blocked` → Blocked +- `done` → Done + +Supported actions in the project pod UI: + +- `Take task` +- `Assign` +- `Post progress` +- `Raise blocker` +- `Clear blocker` +- `Complete` + +## Data Model + +### Pod + +`Pod.type` now includes `project`. + +`pod.projectMeta` stores project-level information: + +- `goal` +- `scope` +- `successCriteria[]` +- `status` +- `dueDate` +- `ownerIds[]` +- `keyLinks[]` + +### Task + +Tasks extend the existing schema with structured work fields: + +- `description` +- `assigneeType` +- `assigneeRef` +- `priority` +- `dueDate` +- `progressPercent` +- `blocker` +- structured `updates.kind` +- structured `updates.progressPercent` +- structured `updates.nextStep` + +This is intentionally incremental. It upgrades the current task system without introducing a separate `ProjectTask` collection. + +## Agent Workflow + +Project pods reuse the current agent installation and runtime architecture. + +Key changes: + +- runtime pod creation now accepts `project` +- task assignment to an agent emits `task.assigned` +- agent assignees are modeled explicitly through `assigneeType="agent"` and `assigneeRef=` + +Expected agent behavior in project pods: + +- take or receive assigned work +- post progress updates +- raise blockers +- complete tasks with structured history + +## Infra / DB Notes + +- MongoDB remains the source of truth for project pod metadata and tasks. +- No new infrastructure tier is required for MVP. +- Existing Socket.io `task_updated` events continue to drive live task refresh. +- Existing runtime polling/native-dispatch model remains valid. + +## Rollout + +### Phase 1 + +- add `project` pod type +- add `projectMeta` +- add dedicated frontend project room +- add structured task fields + +### Phase 2 + +- expand task/project reporting +- add richer milestone support +- add project-specific agent tools for progress/blocker management + +### Phase 3 + +- add deeper automation: standups, stale blocker reminders, milestone risk summaries + +## Current MVP Status + +The current implementation covers the first slice of the project pod idea: + +- `project` is a real pod type +- project pods have a dedicated two-tab UI: + - `Chat` + - `Tasks` +- project pods support project-level metadata through `pod.projectMeta` +- tasks support richer fields: + - `description` + - typed assignees + - `priority` + - `dueDate` + - `progressPercent` + - `blocker` + - structured updates +- assigning a task to an agent emits `task.assigned` + +This is enough for an MVP, but it is not yet the full project-coordination system described in the original vision. + +## Remaining Work + +### Project Creation UX + +The current pod creation flow only lightly seeds project metadata. + +Still needed: + +- dedicated `Create Project Pod` flow +- structured input for: + - goal + - scope + - owners + - due date + - success criteria + - key links + +### Milestones + +There is no milestone model yet. + +Still needed: + +- first-class milestone records +- milestone status/health +- task ↔ milestone linkage +- milestone summaries in the project sidebar + +### Task Workflow + +The current backend status model is still implementation-shaped: + +- `pending` +- `claimed` +- `blocked` +- `done` + +Still needed: + +- clearer product-facing workflow such as: + - `todo` + - `in_progress` + - `blocked` + - `in_review` + - `done` +- migration/update plan for existing tasks + +### Task Structure + +The current task model is richer than before, but still incomplete for real project execution. + +Still needed: + +- checklist/subtask support in the dedicated project UI +- acceptance criteria +- review state +- handoff target/state +- stronger dependency visualization +- overdue and due-soon signals + +### Agent Workflow + +The current implementation emits `task.assigned` for agent assignees, but the broader work loop is still incomplete. + +Still needed: + +- agent tools/runtime support for: + - take task + - post progress + - raise blocker + - clear blocker + - complete task +- better task-state mirroring between runtime actions and UI state +- clearer distinction between chat participation and work ownership + +### Chat Tab Context + +The right sidebar exists, but it still needs more live execution surfaces. + +Still needed: + +- `Assigned to me` +- `Agent tasks` +- `Open blockers` +- `Due soon` +- `Recent decisions` + +### Activity / Timeline + +The current two-tab model intentionally avoids adding a separate timeline page. + +Still needed: + +- compact project activity stream inside existing tabs +- visibility for: + - progress updates + - blockers + - handoffs + - completions + - major decisions + +### Assignment Identity + +The model now supports `assigneeType` and `assigneeRef`, but there is still some reliance on human-readable labels. + +Still needed: + +- make typed identity the source of truth everywhere +- treat display labels as presentation only +- tighten agent installation lookup paths + +### Database / Query Support + +MongoDB is still the right store for this phase, but indexing/query support should be improved. + +Still needed: + +- indexes for: + - `podId + status` + - `podId + assigneeRef` + - `podId + dueDate` + - `podId + blocker.open` + +### Documentation + +The dedicated design doc exists, but broader product and engineering docs still lag the implementation. + +Still needed: + +- update architecture/backend/frontend/database docs +- update API docs for project pod fields and task fields +- document project pod creation and usage flow + +### Verification / Tooling + +Backend and frontend verification should be part of the rollout path. + +Still needed: + +- ensure dev containers always have full dev toolchains +- run backend test/typecheck coverage against the new task/project paths +- expand frontend coverage beyond route/browse behavior + +## Open Follow-Ups + +- milestone model is still lightweight and should likely become first-class later +- project pod chat currently reuses message transport but not the full legacy chat feature surface +- future task status normalization should likely move from `pending/claimed/done` to clearer product-facing workflow names diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index e5a954dd..313fa427 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -31,5 +31,8 @@ EXPOSE 3000 # Add a label to indicate this is development LABEL com.commonly.environment="development" -# For development, use the development server -CMD ["npm", "start"] \ No newline at end of file +# Make dev startup script executable +RUN chmod +x /app/start-dev.sh + +# For development, ensure deps then run the dev server +CMD ["/bin/bash", "/app/start-dev.sh"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d0b9ec1..7daacc17 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import Layout from './components/Layout'; import Pod from './components/Pod'; import PodRedirect from './components/PodRedirect'; import ChatRoom from './components/ChatRoom'; +import PodRoomRouter from './components/PodRoomRouter'; import ApiDevPage from './components/ApiDevPage'; import PodContextDevPage from './components/PodContextDevPage'; import DiscordCallback from './components/DiscordCallback'; @@ -232,7 +233,7 @@ function App(): React.ReactElement { } /> } /> } /> - } /> + } /> diff --git a/frontend/src/components/Pod.test.tsx b/frontend/src/components/Pod.test.tsx index 25e61832..2be26db3 100644 --- a/frontend/src/components/Pod.test.tsx +++ b/frontend/src/components/Pod.test.tsx @@ -292,9 +292,17 @@ test('tab change navigates', async () => { // Get all tabs and click the second one (Study tab) const tabs = screen.getAllByRole('tab'); - expect(tabs).toHaveLength(6); // Chat, Study, Games, Ensemble, Teams, Agent DMs - - const studyTab = tabs[1]; // Study is the second tab + expect(tabs).toHaveLength(7); // Chat, Project, Study, Games, Ensemble, Teams, Agent DMs + + const projectTab = tabs[1]; + expect(projectTab).toHaveTextContent('Project'); + fireEvent.click(projectTab); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/pods/project'); + }); + + const studyTab = tabs[2]; // Study is the third tab expect(studyTab).toHaveTextContent('Study'); fireEvent.click(studyTab); @@ -304,7 +312,7 @@ test('tab change navigates', async () => { expect(mockNavigate).toHaveBeenCalledWith('/pods/study'); }); - const ensembleTab = tabs[3]; + const ensembleTab = tabs[4]; expect(ensembleTab).toHaveTextContent('Ensemble'); fireEvent.click(ensembleTab); @@ -313,6 +321,23 @@ test('tab change navigates', async () => { }); }); +test('create dialog includes project pod type option', async () => { + axios.get.mockResolvedValueOnce({ data: [mockPod] }); + + renderPodWithRouter(); + + await waitFor(() => { + expect(screen.getByText('Room')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Create Room')); + + await waitFor(() => { + expect(screen.getByText('Create a New Pod')).toBeInTheDocument(); + expect(screen.getAllByText('Project').length).toBeGreaterThan(1); + }); +}); + test('search filters pods', async () => { const pod2 = { ...mockPod, _id: '2', name: 'Other', description: 'Desc', type: 'chat' }; axios.get.mockResolvedValueOnce({ data: [mockPod, pod2] }); diff --git a/frontend/src/components/Pod.tsx b/frontend/src/components/Pod.tsx index 234b90c7..a3aa1a0b 100644 --- a/frontend/src/components/Pod.tsx +++ b/frontend/src/components/Pod.tsx @@ -64,6 +64,16 @@ interface AgentInfo { }; } +const POD_TYPE_ORDER = ['chat', 'project', 'study', 'games', 'agent-ensemble', 'team', 'agent-room']; +const CREATEABLE_POD_TYPE_OPTIONS = [ + { value: 0, label: 'Chat' }, + { value: 1, label: 'Project' }, + { value: 2, label: 'Study' }, + { value: 3, label: 'Games' }, + { value: 4, label: 'Agent Ensemble' }, + { value: 5, label: 'Team' }, +]; + const isLikelyAgentUsername = (username: string | undefined): boolean => { const normalized = String(username || '').trim().toLowerCase(); if (!normalized) return false; @@ -117,28 +127,13 @@ const Pod = () => { if (podType) { return podType; } - switch (tabValue) { - case 0: return 'chat'; - case 1: return 'study'; - case 2: return 'games'; - case 3: return 'agent-ensemble'; - case 4: return 'team'; - case 5: return 'agent-room'; - default: return 'chat'; - } + return POD_TYPE_ORDER[tabValue] || 'chat'; }, [podType, tabValue]); useEffect(() => { if (podType) { - switch (podType) { - case 'chat': setTabValue(0); break; - case 'study': setTabValue(1); break; - case 'games': setTabValue(2); break; - case 'agent-ensemble': setTabValue(3); break; - case 'team': setTabValue(4); break; - case 'agent-room': setTabValue(5); break; - default: setTabValue(0); - } + const nextIndex = POD_TYPE_ORDER.indexOf(podType); + setTabValue(nextIndex >= 0 ? nextIndex : 0); } }, [podType]); @@ -326,13 +321,18 @@ const Pod = () => { const handleCreateRoom = async () => { try { if (!roomName.trim()) { setError('Pod name is required'); return; } - const podTypes = ['chat', 'study', 'games', 'agent-ensemble', 'team', 'agent-room']; - const newPodType = podTypes[tabValue] || 'chat'; + const newPodType = POD_TYPE_ORDER[tabValue] || 'chat'; const response = await axios.post('/api/pods', { name: roomName, description: roomDescription, type: newPodType, joinPolicy: inviteOnly ? 'invite-only' : 'open', + ...(newPodType === 'project' ? { + projectMeta: { + goal: roomDescription, + status: 'planning', + }, + } : {}), ...(parentPodId ? { parentPod: parentPodId } : {}), }, { headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } @@ -413,8 +413,7 @@ const Pod = () => { const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { setTabValue(newValue); - const podTypes = ['chat', 'study', 'games', 'agent-ensemble', 'team', 'agent-room']; - navigate(`/pods/${podTypes[newValue]}`); + navigate(`/pods/${POD_TYPE_ORDER[newValue] || 'chat'}`); }; const isAgentAdminView = getPodType() === 'agent-admin'; @@ -492,6 +491,7 @@ const Pod = () => { className="pod-tabs" > + @@ -528,6 +528,8 @@ const Pod = () => { {isAgentAdminView ? 'Open a DM from the Agents page.' + : getPodType() === 'project' + ? 'Create a project pod to coordinate work between people and agents.' : getPodType() === 'agent-ensemble' ? 'Create a new agent ensemble pod to orchestrate multi-agent conversations.' : 'Create a new pod to start chatting with others!'} @@ -654,23 +656,23 @@ const Pod = () => { - Create a New Pod + {tabValue === 1 ? 'Create a New Project Pod' : 'Create a New Pod'} - Create a new pod to chat with others. Pods are spaces where you can discuss topics, share ideas, and connect with people. + {tabValue === 1 + ? 'Create a project pod to coordinate work between people and agents with shared chat, structured tasks, and project context.' + : 'Create a new pod to chat with others. Pods are spaces where you can discuss topics, share ideas, and connect with people.'} - setRoomName(e.target.value)} sx={{ mb: 2 }} placeholder="E.g., JavaScript Developers, Book Club, etc." /> - setRoomDescription(e.target.value)} sx={{ mb: 2 }} placeholder="Describe what this pod is about..." /> + setRoomName(e.target.value)} sx={{ mb: 2 }} placeholder={tabValue === 1 ? 'E.g., Website Relaunch, Q2 Launch Plan, API Migration' : 'E.g., JavaScript Developers, Book Club, etc.'} /> + setRoomDescription(e.target.value)} sx={{ mb: 2 }} placeholder={tabValue === 1 ? 'Describe the project goal, scope, or execution context...' : 'Describe what this pod is about...'} /> Pod Type diff --git a/frontend/src/components/PodRedirect.test.tsx b/frontend/src/components/PodRedirect.test.tsx index 88ce878a..df917cc1 100644 --- a/frontend/src/components/PodRedirect.test.tsx +++ b/frontend/src/components/PodRedirect.test.tsx @@ -49,6 +49,13 @@ describe('PodRedirect', () => { }); expect(navigate).toHaveBeenCalledWith('/pods/chat'); + const projectButton = buttons.find((btn) => btn.textContent === 'Project Pods'); + expect(projectButton).toBeTruthy(); + act(() => { + TestUtils.Simulate.click(projectButton); + }); + expect(navigate).toHaveBeenCalledWith('/pods/project'); + const ensembleButton = buttons.find((btn) => btn.textContent === 'Agent Ensemble Pods'); expect(ensembleButton).toBeTruthy(); act(() => { diff --git a/frontend/src/components/PodRedirect.tsx b/frontend/src/components/PodRedirect.tsx index 801d8ffd..900a0d85 100644 --- a/frontend/src/components/PodRedirect.tsx +++ b/frontend/src/components/PodRedirect.tsx @@ -7,6 +7,7 @@ import SchoolIcon from '@mui/icons-material/School'; import SportsEsportsIcon from '@mui/icons-material/SportsEsports'; import PsychologyIcon from '@mui/icons-material/Psychology'; import GroupsIcon from '@mui/icons-material/Groups'; +import WorkspacesIcon from '@mui/icons-material/Workspaces'; const PodRedirect: React.FC = () => { const navigate = useNavigate(); @@ -62,6 +63,17 @@ const PodRedirect: React.FC = () => { Chat Pods + + + + + + + + + + + 0 ? 'warning' : 'default'} /> + + + + + {error ? setError('')}>{error} : null} + + setActiveTab(nextValue)} + sx={{ + '& .MuiTabs-indicator': { backgroundColor: '#f59e0b', height: 3 }, + '& .MuiTab-root': { color: '#cbd5e1', textTransform: 'none', fontWeight: 700 }, + '& .Mui-selected': { color: '#f8fafc !important' }, + }} + > + + + + + {activeTab === PROJECT_TAB_CHAT ? ( + + + + + Team Chat + + + Discussion stays live here. Project truth stays in the task system and sidebar. + + + + + {(messages || []).map((message) => { + const author = renderMessageAuthor(message); + const avatarValue = renderMessageAvatar(message); + return ( + + + + {String(author || '?').charAt(0).toUpperCase()} + + + + + {author} + + + {formatDateTime(message.createdAt || message.created_at)} + + + + {message.content || message.text || ''} + + + + + ); + })} +
+ + + + + + + + + + + Brief + + + {room?.projectMeta?.goal || room?.description || 'Add a clear project brief.'} + + + {room?.projectMeta?.scope || 'Scope is not defined yet.'} + + + + + + Success Criteria + + + {(room?.projectMeta?.successCriteria || []).length ? ( + room.projectMeta.successCriteria.map((criterion, index) => ( + + • {criterion} + + )) + ) : ( + No success criteria captured yet. + )} + + + + + + Key Information + + + Owners: {projectOwners.length ? projectOwners.map((owner) => owner.username).join(', ') : 'Not assigned'} + Due date: {formatShortDate(room?.projectMeta?.dueDate)} + Open blockers: {blockedTaskCount} + Active tasks: {openTaskCount} + + + + + + Key Links + + + {(room?.projectMeta?.keyLinks || []).length ? ( + room.projectMeta.keyLinks.map((link, index) => ( + + {link.label || link.url} + + )) + ) : ( + No linked resources yet. + )} + + + + + + Members & Agents + + + {(room?.members || []).slice(0, 6).map((member) => ( + + + {member.username?.charAt(0)?.toUpperCase()} + + {member.username} + + ))} + {(podAgents || []).slice(0, 6).map((agent) => ( + + + {(agent.displayName || agent.instanceId || agent.name || 'A').charAt(0).toUpperCase()} + + + {agent.profile?.displayName || agent.displayName || agent.instanceId || agent.name} + + + ))} + + + + + ) : ( + + + + setTaskSearch(event.target.value)} + placeholder="Search tasks..." + fullWidth + size="small" + /> + + {TASK_FILTERS.map((filterKey) => ( + + ))} + + + + {filteredTasks.length ? filteredTasks.map((task) => ( + setSelectedTaskId(task.taskId)} + sx={{ + p: 1.4, + cursor: 'pointer', + borderRadius: 3, + backgroundColor: selectedTaskId === task.taskId ? 'rgba(59,130,246,0.16)' : 'rgba(15,23,42,0.88)', + border: selectedTaskId === task.taskId ? '1px solid rgba(96,165,250,0.55)' : '1px solid rgba(148,163,184,0.08)', + }} + > + + + + {task.taskId} + + + + + {task.title} + + + {task.assignee ? `Assigned to ${task.assignee}` : 'Unassigned'} + + + + {task.priority ? : null} + {task.dueDate ? : null} + + + + )) : ( + + No tasks match the current filter. + + )} + + + + + + {selectedTask ? ( + + + + + + {selectedTask.taskId} + + + {selectedTask.priority ? : null} + + + {selectedTask.title} + + + + + + + + + + + + + { setAssignKey(''); setAssignOpen(true); }} disabled={saving}> + + + + + + + setProgressOpen(true)} disabled={saving}> + + + + + + + setBlockerOpen(true)} disabled={saving}> + + + + + + + setCompleteOpen(true)} disabled={saving}> + + + + + + + + + + + + {selectedTask.dep ? : null} + + + + + Description + + + {selectedTask.description || selectedTask.notes || 'No detailed task brief yet.'} + + + + + + + Blocker + + {selectedTask.blocker?.open ? ( + + ) : null} + + {selectedTask.blocker?.open ? ( + + {selectedTask.blocker.reason} + Waiting on: {selectedTask.blocker.waitingOn || 'Not specified'} + Severity: {selectedTask.blocker.severity || 'medium'} + Opened {formatDateTime(selectedTask.blocker.openedAt)} + + ) : ( + + No active blocker. + + )} + + + + + Updates + + + {(selectedTask.updates || []).length ? selectedTask.updates.slice().reverse().map((update, index) => ( + + + + {update.author || 'system'} + + {update.kind ? : null} + + {formatDateTime(update.createdAt)} + + + + {update.text} + + {update.progressPercent !== undefined && update.progressPercent !== null ? ( + + Progress: {update.progressPercent}% + + ) : null} + {update.nextStep ? ( + + Next step: {update.nextStep} + + ) : null} + + )) : ( + No task updates yet. + )} + + + + ) : ( + + Select a task to inspect its full details. + + )} + + + )} + + + + setProjectDialogOpen(false)} fullWidth maxWidth="md"> + Edit Project + + setProjectForm((prev) => ({ ...prev, name: event.target.value }))} fullWidth /> + setProjectForm((prev) => ({ ...prev, description: event.target.value }))} fullWidth multiline minRows={2} /> + setProjectForm((prev) => ({ ...prev, goal: event.target.value }))} fullWidth multiline minRows={2} /> + setProjectForm((prev) => ({ ...prev, scope: event.target.value }))} fullWidth multiline minRows={3} /> + setProjectForm((prev) => ({ ...prev, successCriteriaText: event.target.value }))} fullWidth multiline minRows={3} /> + + setProjectForm((prev) => ({ ...prev, status: event.target.value }))} fullWidth> + Planning + On track + At risk + Blocked + Complete + + setProjectForm((prev) => ({ ...prev, dueDate: event.target.value }))} fullWidth InputLabelProps={{ shrink: true }} /> + + setProjectForm((prev) => ({ ...prev, keyLinksText: event.target.value }))} fullWidth multiline minRows={3} /> + + + + + + + + setNewTaskOpen(false)} fullWidth maxWidth="sm"> + New Task + + setNewTaskForm((prev) => ({ ...prev, title: event.target.value }))} fullWidth /> + setNewTaskForm((prev) => ({ ...prev, description: event.target.value }))} fullWidth multiline minRows={3} /> + setNewTaskForm((prev) => ({ ...prev, assigneeKey: event.target.value }))} fullWidth> + Unassigned + {assignmentOptions.map((option) => ( + {option.label} + ))} + + + setNewTaskForm((prev) => ({ ...prev, priority: event.target.value }))} fullWidth> + Low + Medium + High + + setNewTaskForm((prev) => ({ ...prev, dueDate: event.target.value }))} fullWidth InputLabelProps={{ shrink: true }} /> + + + + + + + + + setAssignOpen(false)} fullWidth maxWidth="xs"> + Assign Task + + setAssignKey(event.target.value)} fullWidth> + {assignmentOptions.map((option) => ( + {option.label} + ))} + + + + + + + + + setProgressOpen(false)} fullWidth maxWidth="sm"> + Post Progress + + setProgressForm((prev) => ({ ...prev, text: event.target.value }))} fullWidth multiline minRows={3} /> + + setProgressForm((prev) => ({ ...prev, progressPercent: event.target.value }))} fullWidth inputProps={{ min: 0, max: 100 }} /> + setProgressForm((prev) => ({ ...prev, nextStep: event.target.value }))} fullWidth /> + + + + + + + + + setBlockerOpen(false)} fullWidth maxWidth="sm"> + Raise Blocker + + setBlockerForm((prev) => ({ ...prev, reason: event.target.value }))} fullWidth multiline minRows={3} /> + setBlockerForm((prev) => ({ ...prev, waitingOn: event.target.value }))} fullWidth /> + setBlockerForm((prev) => ({ ...prev, severity: event.target.value }))} fullWidth> + Low + Medium + High + + + + + + + + + setCompleteOpen(false)} fullWidth maxWidth="sm"> + Complete Task + + setCompleteNotes(event.target.value)} fullWidth multiline minRows={3} /> + + + + + + + + ); +}; + +export default ProjectPodRoom; diff --git a/frontend/start-dev.sh b/frontend/start-dev.sh new file mode 100644 index 00000000..faa86f83 --- /dev/null +++ b/frontend/start-dev.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e + +echo "Starting Commonly Frontend (dev)..." + +if [ ! -d "/app/node_modules" ] \ + || [ -z "$(ls -A /app/node_modules 2>/dev/null)" ] \ + || [ ! -d "/app/node_modules/react" ] \ + || [ ! -f "/app/node_modules/.bin/vite" ] \ + || [ ! -f "/app/node_modules/.bin/jest" ] \ + || [ ! -f "/app/node_modules/.bin/eslint" ]; then + echo "Installing frontend dependencies (dev)..." + npm ci --only=production=false --prefer-offline --no-audit +else + echo "Dependencies already present, skipping install" +fi + +echo "Starting dev server..." +exec npm start diff --git a/packages/types/src/pod.ts b/packages/types/src/pod.ts index 197e2e13..09830249 100644 --- a/packages/types/src/pod.ts +++ b/packages/types/src/pod.ts @@ -1,4 +1,4 @@ -export type PodType = 'chat' | 'team' | 'study' | 'games' | 'agent-admin' | 'dm'; +export type PodType = 'chat' | 'team' | 'study' | 'games' | 'project' | 'agent-admin' | 'dm'; export type PodJoinPolicy = 'open' | 'invite-only' | 'request'; export interface IPod { @@ -7,6 +7,15 @@ export interface IPod { description?: string; type: PodType; joinPolicy: PodJoinPolicy; + projectMeta?: { + goal?: string; + scope?: string; + successCriteria?: string[]; + status?: 'planning' | 'on-track' | 'at-risk' | 'blocked' | 'complete'; + dueDate?: string | Date | null; + ownerIds?: string[]; + keyLinks?: Array<{ label: string; url: string }>; + }; members: string[]; createdBy: string; isPrivate?: boolean;