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:
Lukas May
2026-03-06 16:39:48 +01:00
parent 02ca1d568e
commit 1e16ad82e8
8 changed files with 278 additions and 2 deletions

View File

@@ -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);

View File

@@ -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,
};
}),

View 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>
)
}

View File

@@ -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', () => {

View File

@@ -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]

View File

@@ -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} />
)}

View File

@@ -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 |

View File

@@ -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 }>;
}
```