diff --git a/apps/server/test/unit/headquarters.test.ts b/apps/server/test/unit/headquarters.test.ts index 8f079a2..bc94e09 100644 --- a/apps/server/test/unit/headquarters.test.ts +++ b/apps/server/test/unit/headquarters.test.ts @@ -108,6 +108,7 @@ describe('getHeadquartersDashboard', () => { expect(result.pendingReviewInitiatives).toEqual([]); expect(result.pendingReviewPhases).toEqual([]); expect(result.planningInitiatives).toEqual([]); + expect(result.resolvingConflicts).toEqual([]); expect(result.blockedPhases).toEqual([]); }); @@ -291,6 +292,115 @@ describe('getHeadquartersDashboard', () => { expect(item.lastMessage).toBeNull(); }); + it('resolvingConflicts — running conflict agent appears', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' }); + + agents.addAgent({ + id: 'agent-conflict', + name: 'conflict-1234567890', + status: 'running', + initiativeId: initiative.id, + userDismissedAt: null, + updatedAt: new Date('2025-06-01T12:00:00Z'), + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.resolvingConflicts).toHaveLength(1); + const item = result.resolvingConflicts[0]; + expect(item.initiativeId).toBe(initiative.id); + expect(item.initiativeName).toBe('Conflicting Init'); + expect(item.agentId).toBe('agent-conflict'); + expect(item.agentName).toBe('conflict-1234567890'); + expect(item.agentStatus).toBe('running'); + }); + + it('resolvingConflicts — waiting_for_input conflict agent appears', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' }); + + agents.addAgent({ + id: 'agent-conflict', + name: 'conflict-1234567890', + status: 'waiting_for_input', + initiativeId: initiative.id, + userDismissedAt: null, + updatedAt: new Date('2025-06-01T12:00:00Z'), + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.resolvingConflicts).toHaveLength(1); + expect(result.resolvingConflicts[0].agentStatus).toBe('waiting_for_input'); + }); + + it('resolvingConflicts — dismissed conflict agent is excluded', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' }); + + agents.addAgent({ + id: 'agent-conflict', + name: 'conflict-1234567890', + status: 'running', + initiativeId: initiative.id, + userDismissedAt: new Date(), + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.resolvingConflicts).toEqual([]); + }); + + it('resolvingConflicts — idle conflict agent is excluded', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' }); + + agents.addAgent({ + id: 'agent-conflict', + name: 'conflict-1234567890', + status: 'idle', + initiativeId: initiative.id, + userDismissedAt: null, + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.resolvingConflicts).toEqual([]); + }); + + it('resolvingConflicts — non-conflict agent is excluded', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const initiative = await initiativeRepo.create({ name: 'Some Init', status: 'active' }); + + agents.addAgent({ + id: 'agent-regular', + name: 'regular-agent', + status: 'running', + initiativeId: initiative.id, + userDismissedAt: null, + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.resolvingConflicts).toEqual([]); + }); + it('ordering — waitingForInput sorted oldest first', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); diff --git a/apps/server/trpc/routers/headquarters.ts b/apps/server/trpc/routers/headquarters.ts index eed001b..1812528 100644 --- a/apps/server/trpc/routers/headquarters.ts +++ b/apps/server/trpc/routers/headquarters.ts @@ -145,7 +145,40 @@ export function headquartersProcedures(publicProcedure: ProcedureBuilder) { planningInitiatives.sort((a, b) => a.since.localeCompare(b.since)); // ----------------------------------------------------------------------- - // Section 4: blockedPhases + // Section 4: resolvingConflicts + // ----------------------------------------------------------------------- + const resolvingConflicts: Array<{ + initiativeId: string; + initiativeName: string; + agentId: string; + agentName: string; + agentStatus: string; + since: string; + }> = []; + + for (const agent of activeAgents) { + if ( + agent.name?.startsWith('conflict-') && + (agent.status === 'running' || agent.status === 'waiting_for_input') && + agent.initiativeId + ) { + const initiative = initiativeMap.get(agent.initiativeId); + if (initiative) { + resolvingConflicts.push({ + initiativeId: initiative.id, + initiativeName: initiative.name, + agentId: agent.id, + agentName: agent.name, + agentStatus: agent.status, + since: agent.updatedAt.toISOString(), + }); + } + } + } + resolvingConflicts.sort((a, b) => a.since.localeCompare(b.since)); + + // ----------------------------------------------------------------------- + // Section 5: blockedPhases // ----------------------------------------------------------------------- const blockedPhases: Array<{ initiativeId: string; @@ -207,6 +240,7 @@ export function headquartersProcedures(publicProcedure: ProcedureBuilder) { pendingReviewInitiatives, pendingReviewPhases, planningInitiatives, + resolvingConflicts, blockedPhases, }; }), diff --git a/apps/web/src/components/hq/HQResolvingConflictsSection.tsx b/apps/web/src/components/hq/HQResolvingConflictsSection.tsx new file mode 100644 index 0000000..d30a9d4 --- /dev/null +++ b/apps/web/src/components/hq/HQResolvingConflictsSection.tsx @@ -0,0 +1,52 @@ +import { useNavigate } from '@tanstack/react-router' +import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { StatusDot } from '@/components/StatusDot' +import { formatRelativeTime } from '@/lib/utils' +import type { ResolvingConflictsItem } from './types' + +interface Props { + items: ResolvingConflictsItem[] +} + +export function HQResolvingConflictsSection({ items }: Props) { + const navigate = useNavigate() + + return ( +
+ {item.agentName} · started {formatRelativeTime(item.since)} +
+