diff --git a/apps/agent-forge/src/screens/agentList/AgentCard.tsx b/apps/agent-forge/src/screens/agentList/AgentCard.tsx index 401cfc57..0037768f 100644 --- a/apps/agent-forge/src/screens/agentList/AgentCard.tsx +++ b/apps/agent-forge/src/screens/agentList/AgentCard.tsx @@ -7,6 +7,7 @@ import { StatusBadge, StatusDot } from '../../ui/StatusBadge'; import { statusKey } from '../../ui/status'; import { Switch } from '../../ui/controls'; import { agentTabPath } from '../agents/agentTabs'; +import '../../ui/tileCard.css'; import './agentCard.css'; /** @@ -115,7 +116,7 @@ export function AgentCard({ agent, onOpen, onToggleEnabled, toggling = false, no return (
  • diff --git a/apps/agent-forge/src/screens/agentList/AgentListScreen.tsx b/apps/agent-forge/src/screens/agentList/AgentListScreen.tsx index 50b1a701..8855e9c9 100644 --- a/apps/agent-forge/src/screens/agentList/AgentListScreen.tsx +++ b/apps/agent-forge/src/screens/agentList/AgentListScreen.tsx @@ -7,15 +7,15 @@ * `useAgentList`. */ import { useCallback, useMemo } from 'react'; -import { Bot, CircleAlert, SearchX } from 'lucide-react'; +import { Bot, SearchX } from 'lucide-react'; import type { AgentListItem } from '../../../shared/wireTypes'; import { navigate } from '../../router/router'; import { ScreenHeader } from '../../shell/ScreenHeader'; -import { Card } from '../../ui/Card'; import { EmptyState } from '../../ui/EmptyState'; import { FilterBar, type FacetDef } from '../../ui/FilterBar'; import { Skeleton } from '../../ui/Skeleton'; import { facet } from '../listFilters'; +import { LoadErrorCard, LoadingGrid } from '../listStates'; import { agentTabPath } from '../agents/agentTabs'; import { AgentCard } from './AgentCard'; import { filterAgents, labelOptions, useAgentListFilter, type AgentListFilter } from './agentListUrl'; @@ -24,8 +24,6 @@ import { useNewAgentActions, type NewAgentActions } from './newAgent'; import { useAgentList, type AgentListState } from './useAgentList'; import './agentList.css'; -const SKELETON_CARDS = 6; - const plural = (n: number): string => (n === 1 ? 'agent' : 'agents'); /** One shimmer card standing in for an agent while the list loads. */ @@ -44,38 +42,6 @@ function SkeletonCard() { ); } -/** The first-load shimmer grid: skeleton cards plus one hidden status line. */ -function LoadingGrid() { - return ( -
      -
    • - Loading agents -
    • - {Array.from({ length: SKELETON_CARDS }, (_, index) => ( - - ))} -
    - ); -} - -/** The error card with Retry (`refresh`). */ -function ErrorCard({ error, onRetry }: { error: string; onRetry(): void }) { - return ( - - - Retry - - } - /> - - ); -} - /** The loaded grid: one AgentCard per visible item. */ function AgentGrid({ items, @@ -116,9 +82,11 @@ interface ListBodyProps { /** Exactly one of: error card, loading grid, empty state, no-match state, card grid. */ function AgentListBody({ list, items, visible, newAgent, onClearFilters, onOpen, onToggle }: ListBodyProps) { if (list.error !== undefined && list.items === undefined) { - return ; + return ; + } + if (list.items === undefined) { + return ; } - if (list.items === undefined) return ; if (items.length === 0) { return (
    )} - {detail !== undefined && ( - <> -
    -

    What it needs

    - {requires.length === 0 ? ( -

    Nothing extra. It runs with your default model.

    - ) : ( -
      - {requires.map((kind) => ( -
    • -
    • - ))} -
    - )} -
    -
    -

    About

    -
    {detail.readme}
    -
    - - )} + {detail !== undefined && } ); } diff --git a/apps/agent-forge/src/screens/templates/TemplatesScreen.tsx b/apps/agent-forge/src/screens/templates/TemplatesScreen.tsx index d2f156b9..1b7a86b2 100644 --- a/apps/agent-forge/src/screens/templates/TemplatesScreen.tsx +++ b/apps/agent-forge/src/screens/templates/TemplatesScreen.tsx @@ -6,21 +6,19 @@ * and the open sheet (`?template=`) live in the hash query. */ import { useEffect, useMemo, useState } from 'react'; -import { CircleAlert, LayoutTemplate } from 'lucide-react'; +import { LayoutTemplate } from 'lucide-react'; import type { TemplateSummary } from '../../../shared/wireTypes'; import { runtimeClient } from '../../runtime/runtimeClient'; import { useQueryState } from '../../router/useRoute'; import { ScreenHeader } from '../../shell/ScreenHeader'; -import { Card } from '../../ui/Card'; import { EmptyState } from '../../ui/EmptyState'; import { Skeleton } from '../../ui/Skeleton'; -import { categoryCounts } from './templatesModel'; +import { LoadErrorCard, LoadingGrid } from '../listStates'; +import { categoryCounts, type CategoryCount } from './templatesModel'; import { TemplateCard } from './TemplateCard'; import { TemplateSheet } from './TemplateSheet'; import './templates.css'; -const SKELETON_CARDS = 6; - const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); /** `?category=`/`?template=` are plain string keys; '' means absent. */ @@ -40,38 +38,6 @@ function SkeletonCard() { ); } -/** The first-load shimmer grid: skeleton cards plus one hidden status line. */ -function LoadingGrid() { - return ( -
      -
    • - Loading templates -
    • - {Array.from({ length: SKELETON_CARDS }, (_, index) => ( - - ))} -
    - ); -} - -/** The error card with Retry (`reload`). */ -function ErrorCard({ error, onRetry }: { error: string; onRetry(): void }) { - return ( - - - Retry - - } - /> - - ); -} - /** The loaded grid: one TemplateCard per visible template. */ function TemplateGrid({ items, onOpen }: { items: TemplateSummary[]; onOpen(id: string): void }) { return ( @@ -85,12 +51,10 @@ function TemplateGrid({ items, onOpen }: { items: TemplateSummary[]; onOpen(id: ); } -export function TemplatesScreen() { +function useTemplateList() { const [items, setItems] = useState(); const [error, setError] = useState(); const [reload, setReload] = useState(0); - const [category, setCategory] = useQueryState('category', parseKey, formatKey); - const [templateId, setTemplateId] = useQueryState('template', parseKey, formatKey); useEffect(() => { let cancelled = false; @@ -109,6 +73,78 @@ export function TemplatesScreen() { }; }, [reload]); + const onRetry = () => setReload((n) => n + 1); + return { items, error, onRetry }; +} + +function CategoryPills({ + counts, + activeCategory, + onSelect, +}: { + counts: CategoryCount[]; + activeCategory: string; + onSelect(category: string): void; +}) { + return ( +
    + {counts.map((c) => { + const pressed = c.value === undefined ? activeCategory === '' : c.value === activeCategory; + return ( + + ); + })} +
    + ); +} + +function TemplatesBody({ + items, + error, + visible, + onRetry, + onOpen, +}: { + items: TemplateSummary[] | undefined; + error: string | undefined; + visible: TemplateSummary[]; + onRetry(): void; + onOpen(id: string): void; +}) { + if (error !== undefined && items === undefined) { + return ; + } + if (items === undefined) { + return ( + + ); + } + if (items.length === 0) { + return ( + + ); + } + return ; +} + +export function TemplatesScreen() { + const { items, error, onRetry } = useTemplateList(); + const [category, setCategory] = useQueryState('category', parseKey, formatKey); + const [templateId, setTemplateId] = useQueryState('template', parseKey, formatKey); + const counts = useMemo(() => (items === undefined ? [] : categoryCounts(items)), [items]); // A `?category=` that names no shipped category falls back to All. const activeCategory = counts.some((c) => c.value === category) ? category : ''; @@ -120,7 +156,6 @@ export function TemplatesScreen() { const onOpen = (id: string) => setTemplateId(id); const onCloseSheet = () => setTemplateId(''); - const onRetry = () => setReload((n) => n + 1); return ( <> @@ -130,37 +165,9 @@ export function TemplatesScreen() { />
    {items !== undefined && items.length > 0 && ( -
    - {counts.map((c) => { - const pressed = c.value === undefined ? activeCategory === '' : c.value === activeCategory; - return ( - - ); - })} -
    - )} - {error !== undefined && items === undefined ? ( - - ) : items === undefined ? ( - - ) : items.length === 0 ? ( - - ) : ( - + )} + { expect(buttonNamed(document.body, 'Retry')).toBeDefined(); }); + it('falls back to All for an unknown ?category= and clears the filter from the All pill', async () => { + const host = await mountAt('#/templates?category=Nope'); + expect(pillNamed(host, 'All')!.getAttribute('aria-pressed')).toBe('true'); + expect(host.querySelectorAll('article.template-card')).toHaveLength(6); + + await act(async () => pillNamed(host, 'Research')!.click()); + await flush(); + expect(host.querySelectorAll('article.template-card')).toHaveLength(1); + + await act(async () => pillNamed(host, 'All')!.click()); + await flush(); + expect(window.location.hash).toBe('#/templates'); + expect(host.querySelectorAll('article.template-card')).toHaveLength(6); + }); + + it('shows the no-requirements note, an unknown id error with the id as title, and Cancel closes the sheet', async () => { + api.getTemplate.mockResolvedValueOnce({ ...PR_DETAIL, summary: TEMPLATES[1] } as TemplateDetail); + await mountAt('#/templates?template=code-architect'); + expect(sheet().querySelector('.sheet-title')?.textContent).toBe('Code architect'); + expect(sheet().querySelector('.template-sheet-note')?.textContent).toBe( + 'Nothing extra. It runs with your default model.' + ); + expect(sheet().querySelector('.template-requirements')).toBeNull(); + + await act(async () => buttonNamed(document.body, 'Cancel')!.click()); + await flush(); + expect(window.location.hash).toBe('#/templates'); + + api.getTemplate.mockResolvedValueOnce(undefined); + window.history.replaceState(null, '', '/#/templates?template=ghost'); + await act(async () => window.dispatchEvent(new HashChangeEvent('hashchange'))); + await flush(); + expect(sheet().querySelector('.sheet-title')?.textContent).toBe('ghost'); + expect(sheet().querySelector('.sheet-description')).toBeNull(); + expect(document.body.querySelector('.template-sheet-error[role="alert"]')?.textContent).toBe( + "No template 'ghost'." + ); + }); + it('retries the detail fetch from the sheet', async () => { api.getTemplate.mockRejectedValueOnce(new Error('spec missing')); await mountAt('#/templates?template=pr-reviewer'); diff --git a/apps/agent-forge/src/screens/templates/templates.css b/apps/agent-forge/src/screens/templates/templates.css index b5378986..07c89174 100644 --- a/apps/agent-forge/src/screens/templates/templates.css +++ b/apps/agent-forge/src/screens/templates/templates.css @@ -74,18 +74,6 @@ flex: 1; height: 100%; padding: var(--space-5); - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - transition: - border-color var(--dur-fast) var(--ease), - box-shadow var(--dur-fast) var(--ease); -} - -.template-card:hover { - border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); - box-shadow: var(--shadow); } .template-card:has(.template-card-open:focus-visible) { @@ -231,9 +219,3 @@ font: 13px/1.6 var(--font-ui); color: var(--text); } - -@media (prefers-reduced-motion: reduce) { - .template-card { - transition: none; - } -} diff --git a/apps/agent-forge/src/ui/tileCard.css b/apps/agent-forge/src/ui/tileCard.css new file mode 100644 index 00000000..fe3527a1 --- /dev/null +++ b/apps/agent-forge/src/ui/tileCard.css @@ -0,0 +1,20 @@ +.tile-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} + +.tile-card:hover { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + box-shadow: var(--shadow); +} + +@media (prefers-reduced-motion: reduce) { + .tile-card { + transition: none; + } +}