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 ( +
+

+ Resolving Conflicts +

+
+ {items.map((item) => ( + +
+
+ + {item.initiativeName} + {item.agentStatus === 'waiting_for_input' ? 'Needs Input' : 'Running'} +
+

+ {item.agentName} · started {formatRelativeTime(item.since)} +

+
+ +
+ ))} +
+
+ ) +} diff --git a/apps/web/src/components/hq/HQSections.test.tsx b/apps/web/src/components/hq/HQSections.test.tsx index dab6734..d090333 100644 --- a/apps/web/src/components/hq/HQSections.test.tsx +++ b/apps/web/src/components/hq/HQSections.test.tsx @@ -20,6 +20,7 @@ vi.mock('@/lib/utils', () => ({ import { HQWaitingForInputSection } from './HQWaitingForInputSection' import { HQNeedsReviewSection } from './HQNeedsReviewSection' import { HQNeedsApprovalSection } from './HQNeedsApprovalSection' +import { HQResolvingConflictsSection } from './HQResolvingConflictsSection' import { HQBlockedSection } from './HQBlockedSection' import { HQEmptyState } from './HQEmptyState' @@ -268,6 +269,77 @@ describe('HQNeedsApprovalSection', () => { }) }) +// ─── HQResolvingConflictsSection ────────────────────────────────────────────── + +describe('HQResolvingConflictsSection', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders "Resolving Conflicts" heading', () => { + render() + expect(screen.getByText('Resolving Conflicts')).toBeInTheDocument() + }) + + it('shows initiative name and "Running" badge for running agent', () => { + render( + + ) + expect(screen.getByText('My Initiative')).toBeInTheDocument() + expect(screen.getByText('Running')).toBeInTheDocument() + }) + + it('shows "Needs Input" badge for waiting_for_input agent', () => { + render( + + ) + expect(screen.getByText('Needs Input')).toBeInTheDocument() + }) + + it('"View" CTA navigates to /initiatives/$id?tab=execution', () => { + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /view/i })) + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/initiatives/$id', + params: { id: 'init-1' }, + search: { tab: 'execution' }, + }) + }) +}) + // ─── HQBlockedSection ──────────────────────────────────────────────────────── describe('HQBlockedSection', () => { diff --git a/apps/web/src/components/hq/types.ts b/apps/web/src/components/hq/types.ts index d59a26c..f257eec 100644 --- a/apps/web/src/components/hq/types.ts +++ b/apps/web/src/components/hq/types.ts @@ -5,4 +5,5 @@ export type WaitingForInputItem = HQDashboard['waitingForInput'][number] export type PendingReviewInitiativeItem = HQDashboard['pendingReviewInitiatives'][number] export type PendingReviewPhaseItem = HQDashboard['pendingReviewPhases'][number] export type PlanningInitiativeItem = HQDashboard['planningInitiatives'][number] +export type ResolvingConflictsItem = HQDashboard['resolvingConflicts'][number] export type BlockedPhaseItem = HQDashboard['blockedPhases'][number] diff --git a/apps/web/src/routes/hq.tsx b/apps/web/src/routes/hq.tsx index dea4865..d1f881e 100644 --- a/apps/web/src/routes/hq.tsx +++ b/apps/web/src/routes/hq.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection"; import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection"; import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection"; +import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection"; import { HQBlockedSection } from "@/components/hq/HQBlockedSection"; import { HQEmptyState } from "@/components/hq/HQEmptyState"; @@ -74,6 +75,7 @@ export function HeadquartersPage() { data.pendingReviewInitiatives.length > 0 || data.pendingReviewPhases.length > 0 || data.planningInitiatives.length > 0 || + data.resolvingConflicts.length > 0 || data.blockedPhases.length > 0; return ( @@ -107,6 +109,9 @@ export function HeadquartersPage() { {data.planningInitiatives.length > 0 && ( )} + {data.resolvingConflicts.length > 0 && ( + + )} {data.blockedPhases.length > 0 && ( )} diff --git a/docs/frontend.md b/docs/frontend.md index 0797687..eee8f3d 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -43,6 +43,7 @@ Use `mapEntityStatus(rawStatus)` from `StatusDot.tsx` to convert raw entity stat | Route | Component | Purpose | |-------|-----------|---------| | `/` | `routes/index.tsx` | Dashboard / initiative list | +| `/hq` | `routes/hq.tsx` | Headquarters — action items requiring user attention | | `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) | | `/agents` | `routes/agents.tsx` | Agent list with Output / Details tab panel | | `/settings` | `routes/settings/index.tsx` | Settings page | diff --git a/docs/server-api.md b/docs/server-api.md index b064576..350fa02 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -279,7 +279,7 @@ Composite dashboard query aggregating all action items that require user interve | Procedure | Type | Description | |-----------|------|-------------| -| `getHeadquartersDashboard` | query | Returns 5 typed arrays of action items (no input required) | +| `getHeadquartersDashboard` | query | Returns 6 typed arrays of action items (no input required) | ### Return Shape @@ -289,6 +289,7 @@ Composite dashboard query aggregating all action items that require user interve pendingReviewInitiatives: Array<{ initiativeId, initiativeName, since }>; pendingReviewPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, since }>; planningInitiatives: Array<{ initiativeId, initiativeName, pendingPhaseCount, since }>; + resolvingConflicts: Array<{ initiativeId, initiativeName, agentId, agentName, agentStatus, since }>; blockedPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, lastMessage, since }>; } ```