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.pendingReviewInitiatives).toEqual([]);
|
||||||
expect(result.pendingReviewPhases).toEqual([]);
|
expect(result.pendingReviewPhases).toEqual([]);
|
||||||
expect(result.planningInitiatives).toEqual([]);
|
expect(result.planningInitiatives).toEqual([]);
|
||||||
|
expect(result.resolvingConflicts).toEqual([]);
|
||||||
expect(result.blockedPhases).toEqual([]);
|
expect(result.blockedPhases).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,6 +292,115 @@ describe('getHeadquartersDashboard', () => {
|
|||||||
expect(item.lastMessage).toBeNull();
|
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 () => {
|
it('ordering — waitingForInput sorted oldest first', async () => {
|
||||||
const agents = new MockAgentManager();
|
const agents = new MockAgentManager();
|
||||||
const ctx = makeCtx(agents);
|
const ctx = makeCtx(agents);
|
||||||
|
|||||||
@@ -145,7 +145,40 @@ export function headquartersProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
planningInitiatives.sort((a, b) => a.since.localeCompare(b.since));
|
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<{
|
const blockedPhases: Array<{
|
||||||
initiativeId: string;
|
initiativeId: string;
|
||||||
@@ -207,6 +240,7 @@ export function headquartersProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
pendingReviewInitiatives,
|
pendingReviewInitiatives,
|
||||||
pendingReviewPhases,
|
pendingReviewPhases,
|
||||||
planningInitiatives,
|
planningInitiatives,
|
||||||
|
resolvingConflicts,
|
||||||
blockedPhases,
|
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 { HQWaitingForInputSection } from './HQWaitingForInputSection'
|
||||||
import { HQNeedsReviewSection } from './HQNeedsReviewSection'
|
import { HQNeedsReviewSection } from './HQNeedsReviewSection'
|
||||||
import { HQNeedsApprovalSection } from './HQNeedsApprovalSection'
|
import { HQNeedsApprovalSection } from './HQNeedsApprovalSection'
|
||||||
|
import { HQResolvingConflictsSection } from './HQResolvingConflictsSection'
|
||||||
import { HQBlockedSection } from './HQBlockedSection'
|
import { HQBlockedSection } from './HQBlockedSection'
|
||||||
import { HQEmptyState } from './HQEmptyState'
|
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 ────────────────────────────────────────────────────────
|
// ─── HQBlockedSection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('HQBlockedSection', () => {
|
describe('HQBlockedSection', () => {
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export type WaitingForInputItem = HQDashboard['waitingForInput'][number]
|
|||||||
export type PendingReviewInitiativeItem = HQDashboard['pendingReviewInitiatives'][number]
|
export type PendingReviewInitiativeItem = HQDashboard['pendingReviewInitiatives'][number]
|
||||||
export type PendingReviewPhaseItem = HQDashboard['pendingReviewPhases'][number]
|
export type PendingReviewPhaseItem = HQDashboard['pendingReviewPhases'][number]
|
||||||
export type PlanningInitiativeItem = HQDashboard['planningInitiatives'][number]
|
export type PlanningInitiativeItem = HQDashboard['planningInitiatives'][number]
|
||||||
|
export type ResolvingConflictsItem = HQDashboard['resolvingConflicts'][number]
|
||||||
export type BlockedPhaseItem = HQDashboard['blockedPhases'][number]
|
export type BlockedPhaseItem = HQDashboard['blockedPhases'][number]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection";
|
import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection";
|
||||||
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
|
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
|
||||||
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
|
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
|
||||||
|
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
|
||||||
import { HQBlockedSection } from "@/components/hq/HQBlockedSection";
|
import { HQBlockedSection } from "@/components/hq/HQBlockedSection";
|
||||||
import { HQEmptyState } from "@/components/hq/HQEmptyState";
|
import { HQEmptyState } from "@/components/hq/HQEmptyState";
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ export function HeadquartersPage() {
|
|||||||
data.pendingReviewInitiatives.length > 0 ||
|
data.pendingReviewInitiatives.length > 0 ||
|
||||||
data.pendingReviewPhases.length > 0 ||
|
data.pendingReviewPhases.length > 0 ||
|
||||||
data.planningInitiatives.length > 0 ||
|
data.planningInitiatives.length > 0 ||
|
||||||
|
data.resolvingConflicts.length > 0 ||
|
||||||
data.blockedPhases.length > 0;
|
data.blockedPhases.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -107,6 +109,9 @@ export function HeadquartersPage() {
|
|||||||
{data.planningInitiatives.length > 0 && (
|
{data.planningInitiatives.length > 0 && (
|
||||||
<HQNeedsApprovalSection items={data.planningInitiatives} />
|
<HQNeedsApprovalSection items={data.planningInitiatives} />
|
||||||
)}
|
)}
|
||||||
|
{data.resolvingConflicts.length > 0 && (
|
||||||
|
<HQResolvingConflictsSection items={data.resolvingConflicts} />
|
||||||
|
)}
|
||||||
{data.blockedPhases.length > 0 && (
|
{data.blockedPhases.length > 0 && (
|
||||||
<HQBlockedSection items={data.blockedPhases} />
|
<HQBlockedSection items={data.blockedPhases} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ Use `mapEntityStatus(rawStatus)` from `StatusDot.tsx` to convert raw entity stat
|
|||||||
| Route | Component | Purpose |
|
| Route | Component | Purpose |
|
||||||
|-------|-----------|---------|
|
|-------|-----------|---------|
|
||||||
| `/` | `routes/index.tsx` | Dashboard / initiative list |
|
| `/` | `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) |
|
| `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) |
|
||||||
| `/agents` | `routes/agents.tsx` | Agent list with Output / Details tab panel |
|
| `/agents` | `routes/agents.tsx` | Agent list with Output / Details tab panel |
|
||||||
| `/settings` | `routes/settings/index.tsx` | Settings page |
|
| `/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 |
|
| 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
|
### Return Shape
|
||||||
|
|
||||||
@@ -289,6 +289,7 @@ Composite dashboard query aggregating all action items that require user interve
|
|||||||
pendingReviewInitiatives: Array<{ initiativeId, initiativeName, since }>;
|
pendingReviewInitiatives: Array<{ initiativeId, initiativeName, since }>;
|
||||||
pendingReviewPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, since }>;
|
pendingReviewPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, since }>;
|
||||||
planningInitiatives: Array<{ initiativeId, initiativeName, pendingPhaseCount, since }>;
|
planningInitiatives: Array<{ initiativeId, initiativeName, pendingPhaseCount, since }>;
|
||||||
|
resolvingConflicts: Array<{ initiativeId, initiativeName, agentId, agentName, agentStatus, since }>;
|
||||||
blockedPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, lastMessage, since }>;
|
blockedPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, lastMessage, since }>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user