Merge branch 'cw/headquarters' into cw-merge-1772810307192
This commit is contained in:
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