Merge branch 'cw/headquarters' into cw-merge-1772810307192
This commit is contained in:
320
apps/server/test/unit/headquarters.test.ts
Normal file
320
apps/server/test/unit/headquarters.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Unit tests for getHeadquartersDashboard tRPC procedure.
|
||||
*
|
||||
* Uses in-memory Drizzle DB + inline MockAgentManager for isolation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js';
|
||||
import { headquartersProcedures } from '../../trpc/routers/headquarters.js';
|
||||
import type { TRPCContext } from '../../trpc/context.js';
|
||||
import type { AgentManager, AgentInfo, PendingQuestions } from '../../agent/types.js';
|
||||
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
|
||||
import {
|
||||
DrizzleInitiativeRepository,
|
||||
DrizzlePhaseRepository,
|
||||
DrizzleTaskRepository,
|
||||
} from '../../db/repositories/drizzle/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// MockAgentManager
|
||||
// =============================================================================
|
||||
|
||||
class MockAgentManager implements AgentManager {
|
||||
private agents: AgentInfo[] = [];
|
||||
private questions: Map<string, PendingQuestions> = new Map();
|
||||
|
||||
addAgent(info: Partial<AgentInfo> & Pick<AgentInfo, 'id' | 'name' | 'status'>): void {
|
||||
this.agents.push({
|
||||
taskId: null,
|
||||
initiativeId: null,
|
||||
sessionId: null,
|
||||
worktreeId: info.id,
|
||||
mode: 'execute',
|
||||
provider: 'claude',
|
||||
accountId: null,
|
||||
createdAt: new Date('2025-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2025-01-01T00:00:00Z'),
|
||||
userDismissedAt: null,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
...info,
|
||||
});
|
||||
}
|
||||
|
||||
setQuestions(agentId: string, questions: PendingQuestions): void {
|
||||
this.questions.set(agentId, questions);
|
||||
}
|
||||
|
||||
async list(): Promise<AgentInfo[]> {
|
||||
return [...this.agents];
|
||||
}
|
||||
|
||||
async getPendingQuestions(agentId: string): Promise<PendingQuestions | null> {
|
||||
return this.questions.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
async spawn(): Promise<AgentInfo> { throw new Error('Not implemented'); }
|
||||
async stop(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async get(): Promise<AgentInfo | null> { return null; }
|
||||
async getByName(): Promise<AgentInfo | null> { return null; }
|
||||
async resume(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async getResult() { return null; }
|
||||
async delete(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async dismiss(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async resumeForConversation(): Promise<boolean> { return false; }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test router
|
||||
// =============================================================================
|
||||
|
||||
const testRouter = router({
|
||||
...headquartersProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
const createCaller = createCallerFactory(testRouter);
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function makeCtx(agentManager: MockAgentManager, overrides?: Partial<TRPCContext>): TRPCContext {
|
||||
const db = createTestDatabase();
|
||||
return {
|
||||
eventBus: {} as TRPCContext['eventBus'],
|
||||
serverStartedAt: null,
|
||||
processCount: 0,
|
||||
agentManager,
|
||||
initiativeRepository: new DrizzleInitiativeRepository(db),
|
||||
phaseRepository: new DrizzlePhaseRepository(db),
|
||||
taskRepository: new DrizzleTaskRepository(db),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('getHeadquartersDashboard', () => {
|
||||
it('empty state — no initiatives, no agents → all arrays empty', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const caller = createCaller(makeCtx(agents));
|
||||
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toEqual([]);
|
||||
expect(result.pendingReviewInitiatives).toEqual([]);
|
||||
expect(result.pendingReviewPhases).toEqual([]);
|
||||
expect(result.planningInitiatives).toEqual([]);
|
||||
expect(result.blockedPhases).toEqual([]);
|
||||
});
|
||||
|
||||
it('waitingForInput — agent with waiting_for_input status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' });
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-1',
|
||||
name: 'jolly-agent',
|
||||
status: 'waiting_for_input',
|
||||
initiativeId: initiative.id,
|
||||
userDismissedAt: null,
|
||||
updatedAt: new Date('2025-06-01T12:00:00Z'),
|
||||
});
|
||||
agents.setQuestions('agent-1', {
|
||||
questions: [{ id: 'q1', question: 'Which approach?' }],
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toHaveLength(1);
|
||||
const item = result.waitingForInput[0];
|
||||
expect(item.agentId).toBe('agent-1');
|
||||
expect(item.agentName).toBe('jolly-agent');
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('My Initiative');
|
||||
expect(item.questionText).toBe('Which approach?');
|
||||
});
|
||||
|
||||
it('waitingForInput — dismissed agent is excluded', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' });
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-1',
|
||||
name: 'dismissed-agent',
|
||||
status: 'waiting_for_input',
|
||||
initiativeId: initiative.id,
|
||||
userDismissedAt: new Date(),
|
||||
});
|
||||
agents.setQuestions('agent-1', {
|
||||
questions: [{ id: 'q1', question: 'Which approach?' }],
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toEqual([]);
|
||||
});
|
||||
|
||||
it('pendingReviewInitiatives — initiative with pending_review status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Review Me', status: 'pending_review' });
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.pendingReviewInitiatives).toHaveLength(1);
|
||||
expect(result.pendingReviewInitiatives[0].initiativeId).toBe(initiative.id);
|
||||
expect(result.pendingReviewInitiatives[0].initiativeName).toBe('Review Me');
|
||||
});
|
||||
|
||||
it('pendingReviewPhases — phase with pending_review status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' });
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 1',
|
||||
status: 'pending_review',
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.pendingReviewPhases).toHaveLength(1);
|
||||
const item = result.pendingReviewPhases[0];
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('My Initiative');
|
||||
expect(item.phaseId).toBe(phase.id);
|
||||
expect(item.phaseName).toBe('Phase 1');
|
||||
});
|
||||
|
||||
it('planningInitiatives — all phases pending and no running agents', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' });
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 1',
|
||||
status: 'pending',
|
||||
});
|
||||
await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 2',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.planningInitiatives).toHaveLength(1);
|
||||
const item = result.planningInitiatives[0];
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('Planning Init');
|
||||
expect(item.pendingPhaseCount).toBe(2);
|
||||
// since = oldest phase createdAt
|
||||
expect(item.since).toBe(phase1.createdAt.toISOString());
|
||||
});
|
||||
|
||||
it('planningInitiatives — excluded when a running agent exists for the initiative', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' });
|
||||
await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' });
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-running',
|
||||
name: 'busy-agent',
|
||||
status: 'running',
|
||||
initiativeId: initiative.id,
|
||||
userDismissedAt: null,
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.planningInitiatives).toEqual([]);
|
||||
});
|
||||
|
||||
it('planningInitiatives — excluded when a phase is not pending', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Mixed Init', status: 'active' });
|
||||
await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' });
|
||||
await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 2', status: 'in_progress' });
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.planningInitiatives).toEqual([]);
|
||||
});
|
||||
|
||||
it('blockedPhases — phase with blocked status appears', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
const initiativeRepo = ctx.initiativeRepository!;
|
||||
const phaseRepo = ctx.phaseRepository!;
|
||||
const initiative = await initiativeRepo.create({ name: 'Blocked Init', status: 'active' });
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Stuck Phase',
|
||||
status: 'blocked',
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.blockedPhases).toHaveLength(1);
|
||||
const item = result.blockedPhases[0];
|
||||
expect(item.initiativeId).toBe(initiative.id);
|
||||
expect(item.initiativeName).toBe('Blocked Init');
|
||||
expect(item.phaseId).toBe(phase.id);
|
||||
expect(item.phaseName).toBe('Stuck Phase');
|
||||
expect(item.lastMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('ordering — waitingForInput sorted oldest first', async () => {
|
||||
const agents = new MockAgentManager();
|
||||
const ctx = makeCtx(agents);
|
||||
|
||||
agents.addAgent({
|
||||
id: 'agent-newer',
|
||||
name: 'newer-agent',
|
||||
status: 'waiting_for_input',
|
||||
userDismissedAt: null,
|
||||
updatedAt: new Date('2025-06-02T00:00:00Z'),
|
||||
});
|
||||
agents.addAgent({
|
||||
id: 'agent-older',
|
||||
name: 'older-agent',
|
||||
status: 'waiting_for_input',
|
||||
userDismissedAt: null,
|
||||
updatedAt: new Date('2025-06-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.getHeadquartersDashboard();
|
||||
|
||||
expect(result.waitingForInput).toHaveLength(2);
|
||||
expect(result.waitingForInput[0].agentId).toBe('agent-older');
|
||||
expect(result.waitingForInput[1].agentId).toBe('agent-newer');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { subscriptionProcedures } from './routers/subscription.js';
|
||||
import { previewProcedures } from './routers/preview.js';
|
||||
import { conversationProcedures } from './routers/conversation.js';
|
||||
import { chatSessionProcedures } from './routers/chat-session.js';
|
||||
import { headquartersProcedures } from './routers/headquarters.js';
|
||||
|
||||
// Re-export tRPC primitives (preserves existing import paths)
|
||||
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
|
||||
@@ -63,6 +64,7 @@ export const appRouter = router({
|
||||
...previewProcedures(publicProcedure),
|
||||
...conversationProcedures(publicProcedure),
|
||||
...chatSessionProcedures(publicProcedure),
|
||||
...headquartersProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
214
apps/server/trpc/routers/headquarters.ts
Normal file
214
apps/server/trpc/routers/headquarters.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Headquarters Router
|
||||
*
|
||||
* Provides the composite dashboard query for the Headquarters page,
|
||||
* aggregating all action items that require user intervention.
|
||||
*/
|
||||
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import type { Phase } from '../../db/schema.js';
|
||||
import {
|
||||
requireAgentManager,
|
||||
requireInitiativeRepository,
|
||||
requirePhaseRepository,
|
||||
} from './_helpers.js';
|
||||
|
||||
export function headquartersProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
getHeadquartersDashboard: publicProcedure.query(async ({ ctx }) => {
|
||||
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||
const phaseRepo = requirePhaseRepository(ctx);
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
|
||||
const [allInitiatives, allAgents] = await Promise.all([
|
||||
initiativeRepo.findAll(),
|
||||
agentManager.list(),
|
||||
]);
|
||||
|
||||
// Relevant initiatives: status in ['active', 'pending_review']
|
||||
const relevantInitiatives = allInitiatives.filter(
|
||||
(i) => i.status === 'active' || i.status === 'pending_review',
|
||||
);
|
||||
|
||||
// Non-dismissed agents only
|
||||
const activeAgents = allAgents.filter((a) => !a.userDismissedAt);
|
||||
|
||||
// Fast lookup map: initiative id → initiative
|
||||
const initiativeMap = new Map(relevantInitiatives.map((i) => [i.id, i]));
|
||||
|
||||
// Batch-fetch all phases for relevant initiatives in parallel
|
||||
const phasesByInitiative = new Map<string, Phase[]>();
|
||||
await Promise.all(
|
||||
relevantInitiatives.map(async (init) => {
|
||||
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||
phasesByInitiative.set(init.id, phases);
|
||||
}),
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 1: waitingForInput
|
||||
// -----------------------------------------------------------------------
|
||||
const waitingAgents = activeAgents.filter((a) => a.status === 'waiting_for_input');
|
||||
const pendingQuestionsResults = await Promise.all(
|
||||
waitingAgents.map((a) => agentManager.getPendingQuestions(a.id)),
|
||||
);
|
||||
|
||||
const waitingForInput = waitingAgents
|
||||
.map((agent, i) => {
|
||||
const initiative = agent.initiativeId ? initiativeMap.get(agent.initiativeId) : undefined;
|
||||
return {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
initiativeId: agent.initiativeId,
|
||||
initiativeName: initiative?.name ?? null,
|
||||
questionText: pendingQuestionsResults[i]?.questions[0]?.question ?? '',
|
||||
waitingSince: agent.updatedAt.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.waitingSince.localeCompare(b.waitingSince));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 2a: pendingReviewInitiatives
|
||||
// -----------------------------------------------------------------------
|
||||
const pendingReviewInitiatives = relevantInitiatives
|
||||
.filter((i) => i.status === 'pending_review')
|
||||
.map((i) => ({
|
||||
initiativeId: i.id,
|
||||
initiativeName: i.name,
|
||||
since: i.updatedAt.toISOString(),
|
||||
}))
|
||||
.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 2b: pendingReviewPhases
|
||||
// -----------------------------------------------------------------------
|
||||
const pendingReviewPhases: Array<{
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
phaseId: string;
|
||||
phaseName: string;
|
||||
since: string;
|
||||
}> = [];
|
||||
|
||||
for (const [initiativeId, phases] of phasesByInitiative) {
|
||||
const initiative = initiativeMap.get(initiativeId)!;
|
||||
for (const phase of phases) {
|
||||
if (phase.status === 'pending_review') {
|
||||
pendingReviewPhases.push({
|
||||
initiativeId,
|
||||
initiativeName: initiative.name,
|
||||
phaseId: phase.id,
|
||||
phaseName: phase.name,
|
||||
since: phase.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingReviewPhases.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 3: planningInitiatives
|
||||
// -----------------------------------------------------------------------
|
||||
const planningInitiatives: Array<{
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
pendingPhaseCount: number;
|
||||
since: string;
|
||||
}> = [];
|
||||
|
||||
for (const initiative of relevantInitiatives) {
|
||||
if (initiative.status !== 'active') continue;
|
||||
const phases = phasesByInitiative.get(initiative.id) ?? [];
|
||||
if (phases.length === 0) continue;
|
||||
|
||||
const allPending = phases.every((p) => p.status === 'pending');
|
||||
if (!allPending) continue;
|
||||
|
||||
const hasActiveAgent = activeAgents.some(
|
||||
(a) =>
|
||||
a.initiativeId === initiative.id &&
|
||||
(a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (hasActiveAgent) continue;
|
||||
|
||||
const sortedByCreatedAt = [...phases].sort(
|
||||
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
||||
);
|
||||
|
||||
planningInitiatives.push({
|
||||
initiativeId: initiative.id,
|
||||
initiativeName: initiative.name,
|
||||
pendingPhaseCount: phases.length,
|
||||
since: sortedByCreatedAt[0].createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
planningInitiatives.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Section 4: blockedPhases
|
||||
// -----------------------------------------------------------------------
|
||||
const blockedPhases: Array<{
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
phaseId: string;
|
||||
phaseName: string;
|
||||
lastMessage: string | null;
|
||||
since: string;
|
||||
}> = [];
|
||||
|
||||
for (const initiative of relevantInitiatives) {
|
||||
if (initiative.status !== 'active') continue;
|
||||
const phases = phasesByInitiative.get(initiative.id) ?? [];
|
||||
|
||||
for (const phase of phases) {
|
||||
if (phase.status !== 'blocked') continue;
|
||||
|
||||
let lastMessage: string | null = null;
|
||||
try {
|
||||
if (ctx.taskRepository && ctx.messageRepository) {
|
||||
const taskRepo = ctx.taskRepository;
|
||||
const messageRepo = ctx.messageRepository;
|
||||
const tasks = await taskRepo.findByPhaseId(phase.id);
|
||||
const phaseAgentIds = allAgents
|
||||
.filter((a) => tasks.some((t) => t.id === a.taskId))
|
||||
.map((a) => a.id);
|
||||
|
||||
if (phaseAgentIds.length > 0) {
|
||||
const messageLists = await Promise.all(
|
||||
phaseAgentIds.map((id) => messageRepo.findBySender('agent', id)),
|
||||
);
|
||||
const allMessages = messageLists
|
||||
.flat()
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
if (allMessages.length > 0) {
|
||||
lastMessage = allMessages[0].content.slice(0, 160);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: message retrieval failure does not crash the dashboard
|
||||
}
|
||||
|
||||
blockedPhases.push({
|
||||
initiativeId: initiative.id,
|
||||
initiativeName: initiative.name,
|
||||
phaseId: phase.id,
|
||||
phaseName: phase.name,
|
||||
lastMessage,
|
||||
since: phase.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
blockedPhases.sort((a, b) => a.since.localeCompare(b.since));
|
||||
|
||||
return {
|
||||
waitingForInput,
|
||||
pendingReviewInitiatives,
|
||||
pendingReviewPhases,
|
||||
planningInitiatives,
|
||||
blockedPhases,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
51
apps/web/src/components/hq/HQBlockedSection.tsx
Normal file
51
apps/web/src/components/hq/HQBlockedSection.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
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 { formatRelativeTime } from '@/lib/utils'
|
||||
import type { BlockedPhaseItem } from './types'
|
||||
|
||||
interface Props {
|
||||
items: BlockedPhaseItem[]
|
||||
}
|
||||
|
||||
export function HQBlockedSection({ items }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Blocked
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<Card key={item.phaseId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 font-semibold text-sm">
|
||||
<span>{item.initiativeName} › {item.phaseName}</span>
|
||||
<Badge variant="destructive" className="ml-2">Blocked</Badge>
|
||||
</div>
|
||||
{item.lastMessage && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{item.lastMessage}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{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>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/components/hq/HQEmptyState.tsx
Normal file
15
apps/web/src/components/hq/HQEmptyState.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function HQEmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center gap-3">
|
||||
<p className="text-lg font-semibold">All clear.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No initiatives need your attention right now.
|
||||
</p>
|
||||
<Link to="/initiatives" className="text-sm text-primary hover:underline">
|
||||
Browse active work
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
apps/web/src/components/hq/HQNeedsApprovalSection.tsx
Normal file
50
apps/web/src/components/hq/HQNeedsApprovalSection.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import type { PlanningInitiativeItem } from './types'
|
||||
|
||||
interface Props {
|
||||
items: PlanningInitiativeItem[]
|
||||
}
|
||||
|
||||
export function HQNeedsApprovalSection({ items }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Needs Approval to Continue
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const s = item.pendingPhaseCount === 1 ? '' : 's'
|
||||
return (
|
||||
<Card key={item.initiativeId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm">{item.initiativeName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Plan ready — {item.pendingPhaseCount} phase{s} awaiting approval
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: item.initiativeId },
|
||||
search: { tab: 'plan' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Review Plan
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
apps/web/src/components/hq/HQNeedsReviewSection.tsx
Normal file
68
apps/web/src/components/hq/HQNeedsReviewSection.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import type { PendingReviewInitiativeItem, PendingReviewPhaseItem } from './types'
|
||||
|
||||
interface Props {
|
||||
initiatives: PendingReviewInitiativeItem[]
|
||||
phases: PendingReviewPhaseItem[]
|
||||
}
|
||||
|
||||
export function HQNeedsReviewSection({ initiatives, phases }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Needs Review
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{initiatives.map((item) => (
|
||||
<Card key={item.initiativeId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm">{item.initiativeName}</p>
|
||||
<p className="text-sm text-muted-foreground">Content ready for review</p>
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: item.initiativeId },
|
||||
search: { tab: 'review' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
{phases.map((item) => (
|
||||
<Card key={item.phaseId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm">{item.initiativeName} › {item.phaseName}</p>
|
||||
<p className="text-sm text-muted-foreground">Phase execution complete — review diff</p>
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: item.initiativeId },
|
||||
search: { tab: 'review' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Review Diff
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
376
apps/web/src/components/hq/HQSections.test.tsx
Normal file
376
apps/web/src/components/hq/HQSections.test.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
Link: ({ to, children, className }: { to: string; children: React.ReactNode; className?: string }) => (
|
||||
<a href={to} className={className}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock formatRelativeTime to return a predictable string
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
|
||||
formatRelativeTime: () => '5 minutes ago',
|
||||
}))
|
||||
|
||||
import { HQWaitingForInputSection } from './HQWaitingForInputSection'
|
||||
import { HQNeedsReviewSection } from './HQNeedsReviewSection'
|
||||
import { HQNeedsApprovalSection } from './HQNeedsApprovalSection'
|
||||
import { HQBlockedSection } from './HQBlockedSection'
|
||||
import { HQEmptyState } from './HQEmptyState'
|
||||
|
||||
const since = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||||
|
||||
// ─── HQWaitingForInputSection ────────────────────────────────────────────────
|
||||
|
||||
describe('HQWaitingForInputSection', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders section heading "Waiting for Input"', () => {
|
||||
render(<HQWaitingForInputSection items={[]} />)
|
||||
expect(screen.getByText('Waiting for Input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders agent name and truncated question text', () => {
|
||||
const longQuestion = 'A'.repeat(150)
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: null,
|
||||
initiativeName: null,
|
||||
questionText: longQuestion,
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Agent Alpha')).toBeInTheDocument()
|
||||
// Truncated to 120 chars + ellipsis
|
||||
const truncated = 'A'.repeat(120) + '…'
|
||||
expect(screen.getByText(truncated)).toBeInTheDocument()
|
||||
// Full text in tooltip content (forceMount renders it into DOM)
|
||||
expect(screen.getAllByText(longQuestion).length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders "waiting X" relative time', () => {
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: null,
|
||||
initiativeName: null,
|
||||
questionText: 'What should I do?',
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('waiting 5 minutes ago')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking "Answer" calls navigate to /inbox', () => {
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: null,
|
||||
initiativeName: null,
|
||||
questionText: 'Question?',
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /answer/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({ to: '/inbox' })
|
||||
})
|
||||
|
||||
it('shows initiative name when non-null', () => {
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'My Initiative',
|
||||
questionText: 'Question?',
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/My Initiative/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides initiative name when null', () => {
|
||||
render(
|
||||
<HQWaitingForInputSection
|
||||
items={[
|
||||
{
|
||||
agentId: 'a1',
|
||||
agentName: 'Agent Alpha',
|
||||
initiativeId: null,
|
||||
initiativeName: null,
|
||||
questionText: 'Question?',
|
||||
waitingSince: since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
// No separator dot should appear since initiative is null
|
||||
expect(screen.queryByText(/·/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQNeedsReviewSection ────────────────────────────────────────────────────
|
||||
|
||||
describe('HQNeedsReviewSection', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders section heading "Needs Review"', () => {
|
||||
render(<HQNeedsReviewSection initiatives={[]} phases={[]} />)
|
||||
expect(screen.getByText('Needs Review')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('2a: shows initiative name, "Content ready for review", "Review" CTA navigates correctly', () => {
|
||||
render(
|
||||
<HQNeedsReviewSection
|
||||
initiatives={[
|
||||
{ initiativeId: 'init-1', initiativeName: 'Init One', since },
|
||||
]}
|
||||
phases={[]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Init One')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content ready for review')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^review$/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'review' },
|
||||
})
|
||||
})
|
||||
|
||||
it('2b: shows initiative › phase, "Phase execution complete — review diff", "Review Diff" navigates correctly', () => {
|
||||
render(
|
||||
<HQNeedsReviewSection
|
||||
initiatives={[]}
|
||||
phases={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Init One › Phase One')).toBeInTheDocument()
|
||||
expect(screen.getByText('Phase execution complete — review diff')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /review diff/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'review' },
|
||||
})
|
||||
})
|
||||
|
||||
it('when only initiatives provided, only 2a cards render', () => {
|
||||
render(
|
||||
<HQNeedsReviewSection
|
||||
initiatives={[{ initiativeId: 'init-1', initiativeName: 'Init One', since }]}
|
||||
phases={[]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Content ready for review')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Phase execution complete — review diff')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('when only phases provided, only 2b cards render', () => {
|
||||
render(
|
||||
<HQNeedsReviewSection
|
||||
initiatives={[]}
|
||||
phases={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Phase execution complete — review diff')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Content ready for review')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQNeedsApprovalSection ──────────────────────────────────────────────────
|
||||
|
||||
describe('HQNeedsApprovalSection', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders "Needs Approval to Continue" heading', () => {
|
||||
render(<HQNeedsApprovalSection items={[]} />)
|
||||
expect(screen.getByText('Needs Approval to Continue')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows singular phase count: "1 phase awaiting approval"', () => {
|
||||
render(
|
||||
<HQNeedsApprovalSection
|
||||
items={[
|
||||
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 1, since },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Plan ready — 1 phase awaiting approval')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows plural phase count: "3 phases awaiting approval"', () => {
|
||||
render(
|
||||
<HQNeedsApprovalSection
|
||||
items={[
|
||||
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 3, since },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Plan ready — 3 phases awaiting approval')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Review Plan" CTA navigates to /initiatives/$id?tab=plan', () => {
|
||||
render(
|
||||
<HQNeedsApprovalSection
|
||||
items={[
|
||||
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 2, since },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /review plan/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'plan' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQBlockedSection ────────────────────────────────────────────────────────
|
||||
|
||||
describe('HQBlockedSection', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders "Blocked" heading', () => {
|
||||
render(<HQBlockedSection items={[]} />)
|
||||
expect(screen.getByText('Blocked')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows initiative › phase with "Blocked" badge', () => {
|
||||
render(
|
||||
<HQBlockedSection
|
||||
items={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
lastMessage: null,
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Init One › Phase One')).toBeInTheDocument()
|
||||
// The "Blocked" badge - there will be one in the heading and one in the card
|
||||
const badges = screen.getAllByText('Blocked')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows lastMessage when non-null', () => {
|
||||
render(
|
||||
<HQBlockedSection
|
||||
items={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
lastMessage: 'Something went wrong.',
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits lastMessage when null', () => {
|
||||
render(
|
||||
<HQBlockedSection
|
||||
items={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
lastMessage: null,
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"View" CTA navigates to /initiatives/$id?tab=execution', () => {
|
||||
render(
|
||||
<HQBlockedSection
|
||||
items={[
|
||||
{
|
||||
phaseId: 'ph-1',
|
||||
phaseName: 'Phase One',
|
||||
initiativeId: 'init-1',
|
||||
initiativeName: 'Init One',
|
||||
lastMessage: null,
|
||||
since,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /view/i }))
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
to: '/initiatives/$id',
|
||||
params: { id: 'init-1' },
|
||||
search: { tab: 'execution' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HQEmptyState ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('HQEmptyState', () => {
|
||||
it('renders "All clear." text', () => {
|
||||
render(<HQEmptyState />)
|
||||
expect(screen.getByText('All clear.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders "Browse active work" link pointing to /initiatives', () => {
|
||||
render(<HQEmptyState />)
|
||||
const link = screen.getByRole('link', { name: /browse active work/i })
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link).toHaveAttribute('href', '/initiatives')
|
||||
})
|
||||
})
|
||||
65
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
65
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import type { WaitingForInputItem } from './types'
|
||||
|
||||
interface Props {
|
||||
items: WaitingForInputItem[]
|
||||
}
|
||||
|
||||
export function HQWaitingForInputSection({ items }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Waiting for Input
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const truncated =
|
||||
item.questionText.slice(0, 120) +
|
||||
(item.questionText.length > 120 ? '…' : '')
|
||||
|
||||
return (
|
||||
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-sm">
|
||||
{item.agentName}
|
||||
{item.initiativeName && (
|
||||
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
|
||||
)}
|
||||
</p>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-sm text-muted-foreground truncate">{truncated}</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent forceMount>{item.questionText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
waiting {formatRelativeTime(item.waitingSince)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: '/inbox' })}
|
||||
>
|
||||
Answer
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
apps/web/src/components/hq/types.ts
Normal file
8
apps/web/src/components/hq/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { RouterOutputs } from '@/lib/trpc'
|
||||
|
||||
type HQDashboard = RouterOutputs['getHeadquartersDashboard']
|
||||
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 BlockedPhaseItem = HQDashboard['blockedPhases'][number]
|
||||
12
apps/web/src/components/ui/skeleton.tsx
Normal file
12
apps/web/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -7,6 +7,7 @@ import { trpc } from '@/lib/trpc'
|
||||
import type { ConnectionState } from '@/hooks/useConnectionStatus'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'HQ', to: '/hq', badgeKey: null },
|
||||
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
|
||||
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
|
||||
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import { httpBatchLink, splitLink, httpSubscriptionLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@codewalk-district/shared';
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
|
||||
export function createTRPCClient() {
|
||||
return trpc.createClient({
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as InboxRouteImport } from './routes/inbox'
|
||||
import { Route as HqRouteImport } from './routes/hq'
|
||||
import { Route as AgentsRouteImport } from './routes/agents'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
||||
@@ -29,6 +30,11 @@ const InboxRoute = InboxRouteImport.update({
|
||||
path: '/inbox',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HqRoute = HqRouteImport.update({
|
||||
id: '/hq',
|
||||
path: '/hq',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AgentsRoute = AgentsRouteImport.update({
|
||||
id: '/agents',
|
||||
path: '/agents',
|
||||
@@ -68,6 +74,7 @@ const InitiativesIdRoute = InitiativesIdRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/hq': typeof HqRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
@@ -79,6 +86,7 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/hq': typeof HqRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
@@ -90,6 +98,7 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/hq': typeof HqRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
@@ -103,6 +112,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/hq'
|
||||
| '/inbox'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
@@ -114,6 +124,7 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/hq'
|
||||
| '/inbox'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/hq'
|
||||
| '/inbox'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
@@ -136,6 +148,7 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AgentsRoute: typeof AgentsRoute
|
||||
HqRoute: typeof HqRoute
|
||||
InboxRoute: typeof InboxRoute
|
||||
SettingsRoute: typeof SettingsRouteWithChildren
|
||||
InitiativesIdRoute: typeof InitiativesIdRoute
|
||||
@@ -158,6 +171,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof InboxRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/hq': {
|
||||
id: '/hq'
|
||||
path: '/hq'
|
||||
fullPath: '/hq'
|
||||
preLoaderRoute: typeof HqRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/agents': {
|
||||
id: '/agents'
|
||||
path: '/agents'
|
||||
@@ -229,6 +249,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AgentsRoute: AgentsRoute,
|
||||
HqRoute: HqRoute,
|
||||
InboxRoute: InboxRoute,
|
||||
SettingsRoute: SettingsRouteWithChildren,
|
||||
InitiativesIdRoute: InitiativesIdRoute,
|
||||
|
||||
156
apps/web/src/routes/hq.test.tsx
Normal file
156
apps/web/src/routes/hq.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
const mockUseQuery = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/trpc', () => ({
|
||||
trpc: {
|
||||
getHeadquartersDashboard: { useQuery: mockUseQuery },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks', () => ({
|
||||
useLiveUpdates: vi.fn(),
|
||||
LiveUpdateRule: undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQWaitingForInputSection', () => ({
|
||||
HQWaitingForInputSection: ({ items }: any) => <div data-testid="waiting">{items.length}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
|
||||
HQNeedsReviewSection: ({ initiatives, phases }: any) => (
|
||||
<div data-testid="needs-review">{initiatives.length},{phases.length}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQNeedsApprovalSection', () => ({
|
||||
HQNeedsApprovalSection: ({ items }: any) => <div data-testid="needs-approval">{items.length}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQBlockedSection', () => ({
|
||||
HQBlockedSection: ({ items }: any) => <div data-testid="blocked">{items.length}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hq/HQEmptyState', () => ({
|
||||
HQEmptyState: () => <div data-testid="empty-state">All clear</div>,
|
||||
}))
|
||||
|
||||
// Import after mocks are set up
|
||||
import { HeadquartersPage } from './hq'
|
||||
|
||||
const emptyData = {
|
||||
waitingForInput: [],
|
||||
pendingReviewInitiatives: [],
|
||||
pendingReviewPhases: [],
|
||||
planningInitiatives: [],
|
||||
blockedPhases: [],
|
||||
}
|
||||
|
||||
describe('HeadquartersPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders skeleton loading state', () => {
|
||||
mockUseQuery.mockReturnValue({ isLoading: true, isError: false, data: undefined })
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
// Should show heading
|
||||
expect(screen.getByText('Headquarters')).toBeInTheDocument()
|
||||
// Should show skeleton elements (there are 3)
|
||||
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
// No section components
|
||||
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders error state with retry button', () => {
|
||||
const mockRefetch = vi.fn()
|
||||
mockUseQuery.mockReturnValue({ isLoading: false, isError: true, data: undefined, refetch: mockRefetch })
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByText('Failed to load headquarters data.')).toBeInTheDocument()
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i })
|
||||
expect(retryButton).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(retryButton)
|
||||
expect(mockRefetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders empty state when all arrays are empty', () => {
|
||||
mockUseQuery.mockReturnValue({ isLoading: false, isError: false, data: emptyData })
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders WaitingForInput section when items exist', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
|
||||
})
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('waiting')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all four sections when all arrays have items', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
waitingForInput: [{ id: '1' }],
|
||||
pendingReviewInitiatives: [{ id: '2' }],
|
||||
pendingReviewPhases: [{ id: '3' }],
|
||||
planningInitiatives: [{ id: '4' }],
|
||||
blockedPhases: [{ id: '5' }],
|
||||
},
|
||||
})
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('waiting')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocked')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders NeedsReview section when only pendingReviewInitiatives has items', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { ...emptyData, pendingReviewInitiatives: [{ id: '1' }] },
|
||||
})
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders NeedsReview section when only pendingReviewPhases has items', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { ...emptyData, pendingReviewPhases: [{ id: '1' }] },
|
||||
})
|
||||
render(<HeadquartersPage />)
|
||||
|
||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
117
apps/web/src/routes/hq.tsx
Normal file
117
apps/web/src/routes/hq.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useLiveUpdates, type LiveUpdateRule } from "@/hooks";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
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 { HQBlockedSection } from "@/components/hq/HQBlockedSection";
|
||||
import { HQEmptyState } from "@/components/hq/HQEmptyState";
|
||||
|
||||
export const Route = createFileRoute("/hq")({
|
||||
component: HeadquartersPage,
|
||||
});
|
||||
|
||||
const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [
|
||||
{ prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] },
|
||||
{ prefix: "phase:", invalidate: ["getHeadquartersDashboard"] },
|
||||
{ prefix: "agent:", invalidate: ["getHeadquartersDashboard"] },
|
||||
];
|
||||
|
||||
export function HeadquartersPage() {
|
||||
useLiveUpdates(HQ_LIVE_UPDATE_RULES);
|
||||
const query = trpc.getHeadquartersDashboard.useQuery();
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto max-w-4xl space-y-6"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Headquarters</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Items waiting for your attention.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto max-w-4xl space-y-6"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Failed to load headquarters data.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => query.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = query.data!;
|
||||
|
||||
const hasAny =
|
||||
data.waitingForInput.length > 0 ||
|
||||
data.pendingReviewInitiatives.length > 0 ||
|
||||
data.pendingReviewPhases.length > 0 ||
|
||||
data.planningInitiatives.length > 0 ||
|
||||
data.blockedPhases.length > 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto max-w-4xl space-y-6"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Headquarters</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Items waiting for your attention.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!hasAny ? (
|
||||
<HQEmptyState />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{data.waitingForInput.length > 0 && (
|
||||
<HQWaitingForInputSection items={data.waitingForInput} />
|
||||
)}
|
||||
{(data.pendingReviewInitiatives.length > 0 ||
|
||||
data.pendingReviewPhases.length > 0) && (
|
||||
<HQNeedsReviewSection
|
||||
initiatives={data.pendingReviewInitiatives}
|
||||
phases={data.pendingReviewPhases}
|
||||
/>
|
||||
)}
|
||||
{data.planningInitiatives.length > 0 && (
|
||||
<HQNeedsApprovalSection items={data.planningInitiatives} />
|
||||
)}
|
||||
{data.blockedPhases.length > 0 && (
|
||||
<HQBlockedSection items={data.blockedPhases} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/initiatives' })
|
||||
throw redirect({ to: '/hq' })
|
||||
},
|
||||
})
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user