fix: Show conflict resolution agents in HQ dashboard
getHeadquartersDashboard had no section for active conflict agents, so initiatives with a running conflict-* agent disappeared from all HQ sections. Add resolvingConflicts array to surface them.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
52
apps/web/src/components/hq/HQResolvingConflictsSection.tsx
Normal file
52
apps/web/src/components/hq/HQResolvingConflictsSection.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Resolving Conflicts
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<StatusDot status="resolving_conflict" variant="urgent" size="sm" pulse />
|
||||
<span className="font-semibold">{item.initiativeName}</span>
|
||||
<Badge variant="urgent" size="xs">{item.agentStatus === 'waiting_for_input' ? 'Needs Input' : 'Running'}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{item.agentName} · started {formatRelativeTime(item.since)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: item.initiativeId },
|
||||
search: { tab: 'execution' },
|
||||
})
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(<HQResolvingConflictsSection items={[]} />)
|
||||
expect(screen.getByText('Resolving Conflicts')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows initiative name and "Running" badge for running agent', () => {
|
||||
render(
|
||||
<HQResolvingConflictsSection
|
||||
items={[
|
||||
{
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'My Initiative',
|
||||
agentId: 'a1',
|
||||
agentName: 'conflict-1234567890',
|
||||
agentStatus: 'running',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('My Initiative')).toBeInTheDocument()
|
||||
expect(screen.getByText('Running')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Needs Input" badge for waiting_for_input agent', () => {
|
||||
render(
|
||||
<HQResolvingConflictsSection
|
||||
items={[
|
||||
{
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'My Initiative',
|
||||
agentId: 'a1',
|
||||
agentName: 'conflict-1234567890',
|
||||
agentStatus: 'waiting_for_input',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Needs Input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"View" CTA navigates to /initiatives/$id?tab=execution', () => {
|
||||
render(
|
||||
<HQResolvingConflictsSection
|
||||
items={[
|
||||
{
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'My Initiative',
|
||||
agentId: 'a1',
|
||||
agentName: 'conflict-1234567890',
|
||||
agentStatus: 'running',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /view/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'execution' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQBlockedSection ────────────────────────────────────────────────────────
|
||||
|
||||
describe('HQBlockedSection', () => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 && (
|
||||
<HQNeedsApprovalSection items={data.planningInitiatives} />
|
||||
)}
|
||||
{data.resolvingConflicts.length > 0 && (
|
||||
<HQResolvingConflictsSection items={data.resolvingConflicts} />
|
||||
)}
|
||||
{data.blockedPhases.length > 0 && (
|
||||
<HQBlockedSection items={data.blockedPhases} />
|
||||
)}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user