Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/agent-forge/src/screens/agentList/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -115,7 +116,7 @@ export function AgentCard({ agent, onOpen, onToggleEnabled, toggling = false, no
return (
<li className="agent-card-item">
<article
className={['agent-card', 'agent-tile-host', enabled ? undefined : 'agent-card-off'].filter(Boolean).join(' ')}
className={['agent-card', 'tile-card', 'agent-tile-host', enabled ? undefined : 'agent-card-off'].filter(Boolean).join(' ')}
aria-labelledby={nameId}
>
<div className="agent-card-body">
Expand Down
44 changes: 6 additions & 38 deletions apps/agent-forge/src/screens/agentList/AgentListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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. */
Expand All @@ -44,38 +42,6 @@ function SkeletonCard() {
);
}

/** The first-load shimmer grid: skeleton cards plus one hidden status line. */
function LoadingGrid() {
return (
<ul className="agent-grid" aria-label="Agents" aria-busy="true">
<li role="status" className="visually-hidden">
Loading agents
</li>
{Array.from({ length: SKELETON_CARDS }, (_, index) => (
<SkeletonCard key={index} />
))}
</ul>
);
}

/** The error card with Retry (`refresh`). */
function ErrorCard({ error, onRetry }: { error: string; onRetry(): void }) {
return (
<Card as="div">
<EmptyState
icon={CircleAlert}
title="Could not load agents"
hint={error}
action={
<button type="button" className="btn" onClick={onRetry}>
Retry
</button>
}
/>
</Card>
);
}

/** The loaded grid: one AgentCard per visible item. */
function AgentGrid({
items,
Expand Down Expand Up @@ -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 <ErrorCard error={list.error} onRetry={list.refresh} />;
return <LoadErrorCard title="Could not load agents" error={list.error} onRetry={list.refresh} />;
}
if (list.items === undefined) {
return <LoadingGrid className="agent-grid" label="Agents" status="Loading agents" skeleton={SkeletonCard} />;
}
if (list.items === undefined) return <LoadingGrid />;
if (items.length === 0) {
return (
<EmptyState
Expand Down
18 changes: 0 additions & 18 deletions apps/agent-forge/src/screens/agentList/agentCard.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,6 @@
min-width: 0;
display: flex;
flex-direction: column;
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);
}

.agent-card:hover {
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
box-shadow: var(--shadow);
}

.agent-card:has(.agent-card-link:focus-visible) {
Expand Down Expand Up @@ -176,9 +164,3 @@

/* A disabled agent greys only the tile (U11 .agent-tile-muted); no opacity
fade, which would drop the card's text below AA contrast. */

@media (prefers-reduced-motion: reduce) {
.agent-card {
transition: none;
}
}
48 changes: 48 additions & 0 deletions apps/agent-forge/src/screens/listStates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { ComponentType } from 'react';
import { CircleAlert } from 'lucide-react';
import { Card } from '../ui/Card';
import { EmptyState } from '../ui/EmptyState';

const SKELETON_CARDS = 6;

/** A card list's first-load grid: six skeleton cards plus one hidden status line. */
export function LoadingGrid({
className,
label,
status,
skeleton: SkeletonCard,
}: {
className: string;
label: string;
status: string;
skeleton: ComponentType;
}) {
return (
<ul className={className} aria-label={label} aria-busy="true">
<li role="status" className="visually-hidden">
{status}
</li>
{Array.from({ length: SKELETON_CARDS }, (_, index) => (
<SkeletonCard key={index} />
))}
</ul>
);
}

/** A card list's load-error card with Retry. */
export function LoadErrorCard({ title, error, onRetry }: { title: string; error: string; onRetry(): void }) {
return (
<Card as="div">
<EmptyState
icon={CircleAlert}
title={title}
hint={error}
action={
<button type="button" className="btn" onClick={onRetry}>
Retry
</button>
}
/>
</Card>
);
}
3 changes: 2 additions & 1 deletion apps/agent-forge/src/screens/templates/TemplateCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import { LayoutTemplate } from 'lucide-react';
import type { TemplateSummary } from '../../../shared/wireTypes';
import '../../ui/tileCard.css';
import { requiresLine } from './templatesModel';

/** At most this many tags show on a card. */
Expand All @@ -14,7 +15,7 @@ export function TemplateCard({ summary, onOpen }: { summary: TemplateSummary; on
const titleId = `template-card-title-${summary.id}`;
const needs = requiresLine(summary.requires);
return (
<article className="template-card agent-tile-host" aria-labelledby={titleId}>
<article className="template-card tile-card agent-tile-host" aria-labelledby={titleId}>
<span className="template-card-tile">
<LayoutTemplate size={18} strokeWidth={1.75} aria-hidden="true" />
</span>
Expand Down
86 changes: 51 additions & 35 deletions apps/agent-forge/src/screens/templates/TemplateSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,9 @@ export interface TemplateSheetProps {
onClose(): void;
}

export function TemplateSheet({ templateId, summary, onClose }: TemplateSheetProps) {
const toast = useToast();
function useTemplateDetail(templateId: string | null) {
const [detailState, setDetailState] = useState<DetailState>({ for: '', status: 'loading' });
const [reload, setReload] = useState(0);
const [creating, setCreating] = useState(false);

useEffect(() => {
if (templateId === null) return;
Expand All @@ -62,14 +60,19 @@ export function TemplateSheet({ templateId, summary, onClose }: TemplateSheetPro
// Until the fetch for THIS id lands, the sheet shows its loading state - a
// previous template's detail never leaks into the next sheet.
const current = templateId !== null && detailState.for === templateId ? detailState : undefined;
const status = current?.status ?? 'loading';
const detail = current?.status === 'loaded' ? current.detail : undefined;
const detailError = current?.status === 'error' ? current.message : undefined;
return {
status: current?.status ?? 'loading',
detail: current?.status === 'loaded' ? current.detail : undefined,
detailError: current?.status === 'error' ? current.message : undefined,
retry: () => setReload((n) => n + 1),
};
}

const title = detail?.summary.title ?? summary?.title ?? templateId ?? '';
const category = detail?.summary.category ?? summary?.category;
function useCreateFromTemplate(templateId: string | null, title: string) {
const toast = useToast();
const [creating, setCreating] = useState(false);

const useTemplate = () => {
const create = () => {
if (templateId === null || creating) return;
const id = templateId;
setCreating(true);
Expand All @@ -83,7 +86,42 @@ export function TemplateSheet({ templateId, summary, onClose }: TemplateSheetPro
.finally(() => setCreating(false));
};

const requires = detail?.summary.requires ?? [];
return { creating, create };
}

function TemplateSheetDetail({ detail }: { detail: TemplateDetail }) {
const requires = detail.summary.requires ?? [];
return (
<>
<section className="template-sheet-section" aria-label="Requirements">
<h3 className="overline">What it needs</h3>
{requires.length === 0 ? (
<p className="template-sheet-note">Nothing extra. It runs with your default model.</p>
) : (
<ul className="template-requirements">
{requires.map((kind) => (
<li key={kind}>
<CircleAlert size={14} strokeWidth={1.75} aria-hidden="true" />
{REQUIRE_LABELS[kind]}
</li>
))}
</ul>
)}
</section>
<section className="template-sheet-section" aria-label="About">
<h3 className="overline">About</h3>
<pre className="template-readme">{detail.readme}</pre>
</section>
</>
);
}

export function TemplateSheet({ templateId, summary, onClose }: TemplateSheetProps) {
const { status, detail, detailError, retry } = useTemplateDetail(templateId);

const title = detail?.summary.title ?? summary?.title ?? templateId ?? '';
const category = detail?.summary.category ?? summary?.category;
const { creating, create } = useCreateFromTemplate(templateId, title);

return (
<Sheet
Expand All @@ -97,7 +135,7 @@ export function TemplateSheet({ templateId, summary, onClose }: TemplateSheetPro
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button type="button" className="btn btn-primary" disabled={creating} onClick={useTemplate}>
<button type="button" className="btn btn-primary" disabled={creating} onClick={create}>
{creating ? 'Creating…' : 'Use template'}
</button>
</>
Expand All @@ -109,34 +147,12 @@ export function TemplateSheet({ templateId, summary, onClose }: TemplateSheetPro
<p className="template-sheet-error" role="alert">
{detailError}
</p>
<button type="button" className="btn btn-sm" onClick={() => setReload((n) => n + 1)}>
<button type="button" className="btn btn-sm" onClick={retry}>
Retry
</button>
</div>
)}
{detail !== undefined && (
<>
<section className="template-sheet-section" aria-label="Requirements">
<h3 className="overline">What it needs</h3>
{requires.length === 0 ? (
<p className="template-sheet-note">Nothing extra. It runs with your default model.</p>
) : (
<ul className="template-requirements">
{requires.map((kind) => (
<li key={kind}>
<CircleAlert size={14} strokeWidth={1.75} aria-hidden="true" />
{REQUIRE_LABELS[kind]}
</li>
))}
</ul>
)}
</section>
<section className="template-sheet-section" aria-label="About">
<h3 className="overline">About</h3>
<pre className="template-readme">{detail.readme}</pre>
</section>
</>
)}
{detail !== undefined && <TemplateSheetDetail detail={detail} />}
</Sheet>
);
}
Loading
Loading