Merge branch 'cw/headquarters' into cw-merge-1772810307192

This commit is contained in:
Lukas May
2026-03-06 16:18:27 +01:00
19 changed files with 1504 additions and 2 deletions

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

View File

@@ -24,6 +24,7 @@ import { subscriptionProcedures } from './routers/subscription.js';
import { previewProcedures } from './routers/preview.js'; import { previewProcedures } from './routers/preview.js';
import { conversationProcedures } from './routers/conversation.js'; import { conversationProcedures } from './routers/conversation.js';
import { chatSessionProcedures } from './routers/chat-session.js'; import { chatSessionProcedures } from './routers/chat-session.js';
import { headquartersProcedures } from './routers/headquarters.js';
// Re-export tRPC primitives (preserves existing import paths) // Re-export tRPC primitives (preserves existing import paths)
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js'; export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
@@ -63,6 +64,7 @@ export const appRouter = router({
...previewProcedures(publicProcedure), ...previewProcedures(publicProcedure),
...conversationProcedures(publicProcedure), ...conversationProcedures(publicProcedure),
...chatSessionProcedures(publicProcedure), ...chatSessionProcedures(publicProcedure),
...headquartersProcedures(publicProcedure),
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

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

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

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

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

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

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

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

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

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

View File

@@ -7,6 +7,7 @@ import { trpc } from '@/lib/trpc'
import type { ConnectionState } from '@/hooks/useConnectionStatus' import type { ConnectionState } from '@/hooks/useConnectionStatus'
const navItems = [ const navItems = [
{ label: 'HQ', to: '/hq', badgeKey: null },
{ label: 'Initiatives', to: '/initiatives', badgeKey: null }, { label: 'Initiatives', to: '/initiatives', badgeKey: null },
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const }, { label: 'Agents', to: '/agents', badgeKey: 'running' as const },
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const }, { label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },

View File

@@ -1,8 +1,10 @@
import { createTRPCReact } from '@trpc/react-query'; import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink, splitLink, httpSubscriptionLink } from '@trpc/client'; import { httpBatchLink, splitLink, httpSubscriptionLink } from '@trpc/client';
import type { AppRouter } from '@codewalk-district/shared'; import type { AppRouter } from '@codewalk-district/shared';
import type { inferRouterOutputs } from '@trpc/server';
export const trpc = createTRPCReact<AppRouter>(); export const trpc = createTRPCReact<AppRouter>();
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function createTRPCClient() { export function createTRPCClient() {
return trpc.createClient({ return trpc.createClient({

View File

@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings' import { Route as SettingsRouteImport } from './routes/settings'
import { Route as InboxRouteImport } from './routes/inbox' import { Route as InboxRouteImport } from './routes/inbox'
import { Route as HqRouteImport } from './routes/hq'
import { Route as AgentsRouteImport } from './routes/agents' import { Route as AgentsRouteImport } from './routes/agents'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings/index' import { Route as SettingsIndexRouteImport } from './routes/settings/index'
@@ -29,6 +30,11 @@ const InboxRoute = InboxRouteImport.update({
path: '/inbox', path: '/inbox',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const HqRoute = HqRouteImport.update({
id: '/hq',
path: '/hq',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsRoute = AgentsRouteImport.update({ const AgentsRoute = AgentsRouteImport.update({
id: '/agents', id: '/agents',
path: '/agents', path: '/agents',
@@ -68,6 +74,7 @@ const InitiativesIdRoute = InitiativesIdRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/agents': typeof AgentsRoute '/agents': typeof AgentsRoute
'/hq': typeof HqRoute
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/settings': typeof SettingsRouteWithChildren '/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute '/initiatives/$id': typeof InitiativesIdRoute
@@ -79,6 +86,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/agents': typeof AgentsRoute '/agents': typeof AgentsRoute
'/hq': typeof HqRoute
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/initiatives/$id': typeof InitiativesIdRoute '/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute '/settings/health': typeof SettingsHealthRoute
@@ -90,6 +98,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/agents': typeof AgentsRoute '/agents': typeof AgentsRoute
'/hq': typeof HqRoute
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/settings': typeof SettingsRouteWithChildren '/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute '/initiatives/$id': typeof InitiativesIdRoute
@@ -103,6 +112,7 @@ export interface FileRouteTypes {
fullPaths: fullPaths:
| '/' | '/'
| '/agents' | '/agents'
| '/hq'
| '/inbox' | '/inbox'
| '/settings' | '/settings'
| '/initiatives/$id' | '/initiatives/$id'
@@ -114,6 +124,7 @@ export interface FileRouteTypes {
to: to:
| '/' | '/'
| '/agents' | '/agents'
| '/hq'
| '/inbox' | '/inbox'
| '/initiatives/$id' | '/initiatives/$id'
| '/settings/health' | '/settings/health'
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
| '__root__' | '__root__'
| '/' | '/'
| '/agents' | '/agents'
| '/hq'
| '/inbox' | '/inbox'
| '/settings' | '/settings'
| '/initiatives/$id' | '/initiatives/$id'
@@ -136,6 +148,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AgentsRoute: typeof AgentsRoute AgentsRoute: typeof AgentsRoute
HqRoute: typeof HqRoute
InboxRoute: typeof InboxRoute InboxRoute: typeof InboxRoute
SettingsRoute: typeof SettingsRouteWithChildren SettingsRoute: typeof SettingsRouteWithChildren
InitiativesIdRoute: typeof InitiativesIdRoute InitiativesIdRoute: typeof InitiativesIdRoute
@@ -158,6 +171,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InboxRouteImport preLoaderRoute: typeof InboxRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/hq': {
id: '/hq'
path: '/hq'
fullPath: '/hq'
preLoaderRoute: typeof HqRouteImport
parentRoute: typeof rootRouteImport
}
'/agents': { '/agents': {
id: '/agents' id: '/agents'
path: '/agents' path: '/agents'
@@ -229,6 +249,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AgentsRoute: AgentsRoute, AgentsRoute: AgentsRoute,
HqRoute: HqRoute,
InboxRoute: InboxRoute, InboxRoute: InboxRoute,
SettingsRoute: SettingsRouteWithChildren, SettingsRoute: SettingsRouteWithChildren,
InitiativesIdRoute: InitiativesIdRoute, InitiativesIdRoute: InitiativesIdRoute,

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

View File

@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
beforeLoad: () => { beforeLoad: () => {
throw redirect({ to: '/initiatives' }) throw redirect({ to: '/hq' })
}, },
}) })

File diff suppressed because one or more lines are too long

View File

@@ -272,3 +272,27 @@ Persistent chat loop for iterative phase/task refinement via agent.
`sendChatMessage` finds or creates an active session, stores the user message, then either resumes the existing agent (if `waiting_for_input`) or spawns a fresh one with full chat history + initiative context. Agent runs in `'chat'` mode and signals `"questions"` after applying changes, staying alive for the next message. `sendChatMessage` finds or creates an active session, stores the user message, then either resumes the existing agent (if `waiting_for_input`) or spawns a fresh one with full chat history + initiative context. Agent runs in `'chat'` mode and signals `"questions"` after applying changes, staying alive for the next message.
Context dependency: `requireChatSessionRepository(ctx)`, `requireAgentManager(ctx)`, `requireInitiativeRepository(ctx)`, `requireTaskRepository(ctx)`. Context dependency: `requireChatSessionRepository(ctx)`, `requireAgentManager(ctx)`, `requireInitiativeRepository(ctx)`, `requireTaskRepository(ctx)`.
## Headquarters Procedures
Composite dashboard query aggregating all action items that require user intervention.
| Procedure | Type | Description |
|-----------|------|-------------|
| `getHeadquartersDashboard` | query | Returns 5 typed arrays of action items (no input required) |
### Return Shape
```typescript
{
waitingForInput: Array<{ agentId, agentName, initiativeId, initiativeName, questionText, waitingSince }>;
pendingReviewInitiatives: Array<{ initiativeId, initiativeName, since }>;
pendingReviewPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, since }>;
planningInitiatives: Array<{ initiativeId, initiativeName, pendingPhaseCount, since }>;
blockedPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, lastMessage, since }>;
}
```
Each array is sorted ascending by timestamp (oldest-first). All timestamps are ISO 8601 strings. `lastMessage` is truncated to 160 chars and is `null` when no messages exist or the message repository is not wired.
Context dependency: `requireInitiativeRepository(ctx)`, `requirePhaseRepository(ctx)`, `requireAgentManager(ctx)`. Task/message repos are accessed via optional `ctx` fields for `blockedPhases.lastMessage`.