diff --git a/apps/web/src/components/hq/HQBlockedSection.tsx b/apps/web/src/components/hq/HQBlockedSection.tsx
new file mode 100644
index 0000000..8f61a13
--- /dev/null
+++ b/apps/web/src/components/hq/HQBlockedSection.tsx
@@ -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 (
+
+
+ Blocked
+
+
+ {items.map((item) => (
+
+
+
+ {item.initiativeName} › {item.phaseName}
+ Blocked
+
+ {item.lastMessage && (
+
{item.lastMessage}
+ )}
+
{formatRelativeTime(item.since)}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/components/hq/HQEmptyState.tsx b/apps/web/src/components/hq/HQEmptyState.tsx
new file mode 100644
index 0000000..d65bac2
--- /dev/null
+++ b/apps/web/src/components/hq/HQEmptyState.tsx
@@ -0,0 +1,15 @@
+import { Link } from '@tanstack/react-router'
+
+export function HQEmptyState() {
+ return (
+
+
All clear.
+
+ No initiatives need your attention right now.
+
+
+ Browse active work
+
+
+ )
+}
diff --git a/apps/web/src/components/hq/HQNeedsApprovalSection.tsx b/apps/web/src/components/hq/HQNeedsApprovalSection.tsx
new file mode 100644
index 0000000..396fa5f
--- /dev/null
+++ b/apps/web/src/components/hq/HQNeedsApprovalSection.tsx
@@ -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 (
+
+
+ Needs Approval to Continue
+
+
+ {items.map((item) => {
+ const s = item.pendingPhaseCount === 1 ? '' : 's'
+ return (
+
+
+
{item.initiativeName}
+
+ Plan ready — {item.pendingPhaseCount} phase{s} awaiting approval
+
+
{formatRelativeTime(item.since)}
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/web/src/components/hq/HQNeedsReviewSection.tsx b/apps/web/src/components/hq/HQNeedsReviewSection.tsx
new file mode 100644
index 0000000..8ac3970
--- /dev/null
+++ b/apps/web/src/components/hq/HQNeedsReviewSection.tsx
@@ -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 (
+
+
+ Needs Review
+
+
+ {initiatives.map((item) => (
+
+
+
{item.initiativeName}
+
Content ready for review
+
{formatRelativeTime(item.since)}
+
+
+
+ ))}
+ {phases.map((item) => (
+
+
+
{item.initiativeName} › {item.phaseName}
+
Phase execution complete — review diff
+
{formatRelativeTime(item.since)}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/components/hq/HQSections.test.tsx b/apps/web/src/components/hq/HQSections.test.tsx
new file mode 100644
index 0000000..dab6734
--- /dev/null
+++ b/apps/web/src/components/hq/HQSections.test.tsx
@@ -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 }) => (
+ {children}
+ ),
+}))
+
+// 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()
+ expect(screen.getByText('Waiting for Input')).toBeInTheDocument()
+ })
+
+ it('renders agent name and truncated question text', () => {
+ const longQuestion = 'A'.repeat(150)
+ render(
+
+ )
+ 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(
+
+ )
+ expect(screen.getByText('waiting 5 minutes ago')).toBeInTheDocument()
+ })
+
+ it('clicking "Answer" calls navigate to /inbox', () => {
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: /answer/i }))
+ expect(mockNavigate).toHaveBeenCalledWith({ to: '/inbox' })
+ })
+
+ it('shows initiative name when non-null', () => {
+ render(
+
+ )
+ expect(screen.getByText(/My Initiative/)).toBeInTheDocument()
+ })
+
+ it('hides initiative name when null', () => {
+ render(
+
+ )
+ // 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()
+ expect(screen.getByText('Needs Review')).toBeInTheDocument()
+ })
+
+ it('2a: shows initiative name, "Content ready for review", "Review" CTA navigates correctly', () => {
+ render(
+
+ )
+ 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(
+
+ )
+ 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(
+
+ )
+ 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(
+
+ )
+ 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()
+ expect(screen.getByText('Needs Approval to Continue')).toBeInTheDocument()
+ })
+
+ it('shows singular phase count: "1 phase awaiting approval"', () => {
+ render(
+
+ )
+ expect(screen.getByText('Plan ready — 1 phase awaiting approval')).toBeInTheDocument()
+ })
+
+ it('shows plural phase count: "3 phases awaiting approval"', () => {
+ render(
+
+ )
+ expect(screen.getByText('Plan ready — 3 phases awaiting approval')).toBeInTheDocument()
+ })
+
+ it('"Review Plan" CTA navigates to /initiatives/$id?tab=plan', () => {
+ render(
+
+ )
+ 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()
+ expect(screen.getByText('Blocked')).toBeInTheDocument()
+ })
+
+ it('shows initiative › phase with "Blocked" badge', () => {
+ render(
+
+ )
+ 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(
+
+ )
+ expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
+ })
+
+ it('omits lastMessage when null', () => {
+ render(
+
+ )
+ expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument()
+ })
+
+ it('"View" CTA navigates to /initiatives/$id?tab=execution', () => {
+ render(
+
+ )
+ 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()
+ expect(screen.getByText('All clear.')).toBeInTheDocument()
+ })
+
+ it('renders "Browse active work" link pointing to /initiatives', () => {
+ render()
+ const link = screen.getByRole('link', { name: /browse active work/i })
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveAttribute('href', '/initiatives')
+ })
+})
diff --git a/apps/web/src/components/hq/HQWaitingForInputSection.tsx b/apps/web/src/components/hq/HQWaitingForInputSection.tsx
new file mode 100644
index 0000000..6a23e33
--- /dev/null
+++ b/apps/web/src/components/hq/HQWaitingForInputSection.tsx
@@ -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 (
+
+
+ Waiting for Input
+
+
+ {items.map((item) => {
+ const truncated =
+ item.questionText.slice(0, 120) +
+ (item.questionText.length > 120 ? '…' : '')
+
+ return (
+
+
+
+ {item.agentName}
+ {item.initiativeName && (
+ · {item.initiativeName}
+ )}
+
+
+
+
+ {truncated}
+
+ {item.questionText}
+
+
+
+ waiting {formatRelativeTime(item.waitingSince)}
+
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/web/src/components/hq/types.ts b/apps/web/src/components/hq/types.ts
new file mode 100644
index 0000000..d59a26c
--- /dev/null
+++ b/apps/web/src/components/hq/types.ts
@@ -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]
diff --git a/apps/web/src/lib/trpc.ts b/apps/web/src/lib/trpc.ts
index d0a6fd5..ec1d9a5 100644
--- a/apps/web/src/lib/trpc.ts
+++ b/apps/web/src/lib/trpc.ts
@@ -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();
+export type RouterOutputs = inferRouterOutputs;
export function createTRPCClient() {
return trpc.createClient({