feat(agent-world): show live data on the Explore page (#3839)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Cyrus Gray
2026-06-19 09:22:43 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 4f6b856b6d
commit bec1f4fd5d
18 changed files with 1521 additions and 174 deletions
-174
View File
@@ -1,174 +0,0 @@
/**
* ExploreSection — Agent World "Explore" overview.
*
* Calls `explorer.overview()` via the invoke API client (native Rust core →
* tiny.place SDK) and renders the network overview as stat cards inside the
* standard `PanelScaffold` chrome, matching the rest of the app. Handles
* loading / wallet-locked / payment / error states.
*/
import { useEffect, useState } from 'react';
import PanelScaffold from '../../components/layout/PanelScaffold';
import { type ExplorerOverview, PaymentRequiredError } from '../../lib/agentworld/invokeApiClient';
import { apiClient } from '../AgentWorldShell';
type State =
| { status: 'loading' }
| { status: 'payment_required'; challenge: unknown }
| { status: 'error'; message: string }
| { status: 'ok'; data: ExplorerOverview };
/** Defensive view of the explorer overview payload (fields are best-effort). */
interface OverviewShape {
allTime?: { feesUsd?: string; registeredAgents?: number; volumeUsd?: string };
last24h?: { feesUsd?: string; transactions?: number; uniqueAgents?: number; volumeUsd?: string };
ledger?: { totalEntries?: number; latestTxId?: string; latestTimestamp?: string };
}
function useExplorerOverview(): State {
const [state, setState] = useState<State>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
void apiClient.explorer
.overview()
.then(data => {
if (!cancelled) setState({ status: 'ok', data });
})
.catch((err: unknown) => {
if (cancelled) return;
if (err instanceof PaymentRequiredError) {
setState({ status: 'payment_required', challenge: err.challenge });
} else {
setState({ status: 'error', message: String(err) });
}
});
return () => {
cancelled = true;
};
}, []);
return state;
}
function usd(value?: string): string {
if (value == null) return '—';
const n = Number(value);
if (Number.isNaN(n)) return `$${value}`;
return `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function num(value?: number): string {
return value == null ? '—' : value.toLocaleString();
}
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<div className="text-[11px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{label}
</div>
<div className="mt-1.5 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{value}
</div>
{sub && <div className="mt-0.5 text-xs text-stone-400 dark:text-neutral-500">{sub}</div>}
</div>
);
}
/** Centered status message used for loading / wallet / error states. */
function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2 text-center">
<p className={`text-base font-medium ${tone}`}>{title}</p>
{body && <p className="max-w-md text-sm text-stone-500 dark:text-neutral-400">{body}</p>}
</div>
);
}
export default function ExploreSection() {
const state = useExplorerOverview();
let body: React.ReactNode;
if (state.status === 'loading') {
body = (
<div className="flex h-64 items-center justify-center text-stone-400 dark:text-neutral-500">
<span className="animate-pulse text-sm">Loading network overview</span>
</div>
);
} else if (state.status === 'payment_required') {
body = (
<StatusBlock
tone="text-amber-600 dark:text-amber-400"
title="Access requires payment"
body="Your wallet will be used to fulfill the x402 payment challenge."
/>
);
} else if (state.status === 'error') {
const isWalletLocked =
state.message.includes('wallet is not configured') ||
state.message.includes('wallet secret material is missing');
body = isWalletLocked ? (
<StatusBlock
tone="text-stone-700 dark:text-neutral-200"
title="Unlock your wallet to use Agent World"
body="Agent World uses your wallet identity. Import your recovery phrase in Settings to continue."
/>
) : (
<StatusBlock
tone="text-red-600 dark:text-red-400"
title="Failed to load Agent World"
body={state.message}
/>
);
} else {
const ov = state.data as unknown as OverviewShape;
body = (
<>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
All time
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatCard label="Registered agents" value={num(ov.allTime?.registeredAgents)} />
<StatCard label="Volume" value={usd(ov.allTime?.volumeUsd)} />
<StatCard label="Fees" value={usd(ov.allTime?.feesUsd)} />
</div>
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
Last 24 hours
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Transactions" value={num(ov.last24h?.transactions)} />
<StatCard label="Active agents" value={num(ov.last24h?.uniqueAgents)} />
<StatCard label="Volume" value={usd(ov.last24h?.volumeUsd)} />
<StatCard label="Fees" value={usd(ov.last24h?.feesUsd)} />
</div>
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
Ledger
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatCard label="Total entries" value={num(ov.ledger?.totalEntries)} />
<StatCard
label="Latest tx"
value={ov.ledger?.latestTxId ?? '—'}
sub={
ov.ledger?.latestTimestamp
? new Date(ov.ledger.latestTimestamp).toLocaleString()
: undefined
}
/>
</div>
</div>
</>
);
}
return <PanelScaffold description="Network overview">{body}</PanelScaffold>;
}
@@ -0,0 +1,575 @@
/**
* Tests for ExploreSection — Agent World Explore page.
*
* The page renders:
* 1. Network stats via apiClient.explorer.overview() — full StatusBlock states
* (loading / payment_required / wallet_locked / generic error / ok).
* 2. Four independent live sections:
* - Trending Communities → apiClient.groups.list()
* - Active Jobs → apiClient.graphql.jobs()
* - Featured Bounties → apiClient.bounties.list()
* - New Agents → apiClient.directory.listAgents()
* Each section independently handles loading (skeleton) / ok / empty / error
* (silent degrade — section hidden). Mocks prevent real RPC calls.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { PaymentRequiredError } from '../../../lib/agentworld/invokeApiClient';
import { apiClient } from '../../AgentWorldShell';
import ExploreSection from './index';
// ── Mock apiClient ────────────────────────────────────────────────────────────
vi.mock('../../AgentWorldShell', () => ({
apiClient: {
explorer: { overview: vi.fn() },
groups: { list: vi.fn() },
graphql: { jobs: vi.fn() },
bounties: { list: vi.fn() },
directory: { listAgents: vi.fn() },
},
}));
// ── Mock react-router-dom useNavigate ─────────────────────────────────────────
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
// ── Convenience typed mocks ───────────────────────────────────────────────────
const mockOverview = vi.mocked(apiClient.explorer.overview);
const mockGroupsList = vi.mocked(apiClient.groups.list);
const mockGraphqlJobs = vi.mocked(apiClient.graphql.jobs);
const mockBountiesList = vi.mocked(apiClient.bounties.list);
const mockListAgents = vi.mocked(apiClient.directory.listAgents);
// ── Sample fixtures ───────────────────────────────────────────────────────────
const OVERVIEW_OK = {
allTime: { registeredAgents: 42, volumeUsd: '1000.00', feesUsd: '10.00' },
last24h: { transactions: 5, uniqueAgents: 3, volumeUsd: '100.00', feesUsd: '1.00' },
ledger: { totalEntries: 99, latestTxId: 'tx-abc', latestTimestamp: '2024-01-01T00:00:00Z' },
};
const GROUPS_OK = [
{
groupId: 'g-1',
name: 'Alpha Community',
description: 'First community',
createdBy: 'user1',
createdAt: '2024-01-01T00:00:00Z',
membershipPolicy: 'open',
memberCount: 100,
membershipEpoch: 1,
tags: ['defi', 'ai'],
},
{
groupId: 'g-2',
name: 'Beta Community',
description: 'Second community',
createdBy: 'user2',
createdAt: '2024-01-02T00:00:00Z',
membershipPolicy: 'open',
memberCount: 50,
membershipEpoch: 1,
},
];
const JOBS_OK = {
jobs: [
{
jobId: 'job-1',
client: 'client-addr',
title: 'Build a DeFi Dashboard',
description: 'Need a developer',
skills: ['React', 'TypeScript'],
budget: { amount: '500', asset: 'USDC' },
status: 'open',
proposalCount: 3,
createdAt: '2024-01-15T00:00:00Z',
updatedAt: '2024-01-15T00:00:00Z',
clientProfile: {
handle: 'client1',
cryptoId: 'addr1',
displayName: 'Client One',
verified: false,
},
},
],
count: 1,
};
const BOUNTIES_OK = {
bounties: [
{
bountyId: 'b-1',
creator: 'creator-addr',
title: 'Fix Critical Bug',
description: 'Critical bug in our system',
reward: { amount: '250', asset: 'SOL' },
status: 'open',
submissionCount: 2,
commentCount: 0,
createdAt: '2024-01-10T00:00:00Z',
updatedAt: '2024-01-10T00:00:00Z',
deadline: '2024-03-01T00:00:00Z',
},
],
};
const AGENTS_OK = {
agents: [
{ agentId: 'agent-1', name: 'Nexus', username: 'nexus', description: 'An AI research agent' },
{ agentId: 'agent-2', username: '@aurora', description: 'A creative agent' },
],
};
// ── Helper: render inside MemoryRouter ────────────────────────────────────────
function renderExplore() {
return render(
<MemoryRouter>
<ExploreSection />
</MemoryRouter>
);
}
// ── beforeEach: resolve all calls with success data ───────────────────────────
beforeEach(() => {
vi.clearAllMocks();
mockNavigate.mockReset();
mockOverview.mockResolvedValue(
OVERVIEW_OK as unknown as Awaited<ReturnType<typeof mockOverview>>
);
mockGroupsList.mockResolvedValue(GROUPS_OK);
mockGraphqlJobs.mockResolvedValue(JOBS_OK);
mockBountiesList.mockResolvedValue(
BOUNTIES_OK as unknown as Awaited<ReturnType<typeof mockBountiesList>>
);
mockListAgents.mockResolvedValue(AGENTS_OK);
});
// ── Stats + all sections render ───────────────────────────────────────────────
describe('fully populated state', () => {
test('renders network overview section heading', async () => {
renderExplore();
// "Network Overview" appears both as the PanelScaffold description and the <h3>;
// use findAllByText so the duplicate doesn't throw.
const matches = await screen.findAllByText('Network Overview');
expect(matches.length).toBeGreaterThanOrEqual(1);
});
test('renders all-time stat card with registered agents', async () => {
renderExplore();
expect(await screen.findByText('42')).toBeInTheDocument();
});
test('renders trending communities section heading', async () => {
renderExplore();
expect(await screen.findByText('Trending Communities')).toBeInTheDocument();
});
test('renders community cards sorted by member count desc', async () => {
renderExplore();
expect(await screen.findByText('Alpha Community')).toBeInTheDocument();
expect(screen.getByText('Beta Community')).toBeInTheDocument();
// Alpha (100 members) should appear before Beta (50 members) — test ordering.
const allNames = screen.getAllByText(/Community/);
expect(allNames[0].textContent).toBe('Alpha Community');
});
test('renders community member count and tags', async () => {
renderExplore();
expect(await screen.findByText('100 members')).toBeInTheDocument();
expect(screen.getByText('defi')).toBeInTheDocument();
expect(screen.getByText('ai')).toBeInTheDocument();
});
test('renders active jobs section heading', async () => {
renderExplore();
expect(await screen.findByText('Active Jobs')).toBeInTheDocument();
});
test('renders job card with title, budget and skills', async () => {
renderExplore();
expect(await screen.findByText('Build a DeFi Dashboard')).toBeInTheDocument();
expect(screen.getByText('500 USDC')).toBeInTheDocument();
expect(screen.getByText('React')).toBeInTheDocument();
expect(screen.getByText('TypeScript')).toBeInTheDocument();
});
test('renders job client display name', async () => {
renderExplore();
expect(await screen.findByText(/Client One/)).toBeInTheDocument();
});
test('renders featured bounties section heading', async () => {
renderExplore();
expect(await screen.findByText('Featured Bounties')).toBeInTheDocument();
});
test('renders bounty card with title, reward and submission count', async () => {
renderExplore();
expect(await screen.findByText('Fix Critical Bug')).toBeInTheDocument();
expect(screen.getByText('250 SOL')).toBeInTheDocument();
expect(screen.getByText(/2 submissions/)).toBeInTheDocument();
});
test('renders new agents section heading', async () => {
renderExplore();
expect(await screen.findByText('New Agents')).toBeInTheDocument();
});
test('renders agent mini cards with handles', async () => {
renderExplore();
expect(await screen.findByText('@nexus')).toBeInTheDocument();
expect(screen.getByText('@aurora')).toBeInTheDocument();
});
test('renders agent description', async () => {
renderExplore();
expect(await screen.findByText('An AI research agent')).toBeInTheDocument();
});
test('renders "View all" buttons for each live section', async () => {
renderExplore();
// Wait for sections to load.
await screen.findByText('Trending Communities');
const viewAllButtons = screen.getAllByText('View all');
expect(viewAllButtons.length).toBeGreaterThanOrEqual(4);
});
});
// ── Loading state ─────────────────────────────────────────────────────────────
describe('loading state', () => {
test('renders skeleton placeholders for communities while loading', () => {
mockOverview.mockReturnValue(new Promise(() => {}));
mockGroupsList.mockReturnValue(new Promise(() => {}));
mockGraphqlJobs.mockReturnValue(new Promise(() => {}));
mockBountiesList.mockReturnValue(new Promise(() => {}));
mockListAgents.mockReturnValue(new Promise(() => {}));
const { container } = renderExplore();
const skeletons = container.querySelectorAll('.animate-pulse');
// 4 sections × 4-8 skeletons each, at least one group exists.
expect(skeletons.length).toBeGreaterThan(0);
});
test('shows network loading text while overview loads', () => {
mockOverview.mockReturnValue(new Promise(() => {}));
mockGroupsList.mockResolvedValue([]);
mockGraphqlJobs.mockResolvedValue({ jobs: [], count: 0 });
mockBountiesList.mockResolvedValue({ bounties: [] } as unknown as Awaited<
ReturnType<typeof mockBountiesList>
>);
mockListAgents.mockResolvedValue({ agents: [] });
renderExplore();
expect(screen.getByText(/Loading network overview/i)).toBeInTheDocument();
});
});
// ── Empty sections ────────────────────────────────────────────────────────────
describe('empty sections', () => {
beforeEach(() => {
mockGroupsList.mockResolvedValue([]);
mockGraphqlJobs.mockResolvedValue({ jobs: [], count: 0 });
mockBountiesList.mockResolvedValue({ bounties: [] } as unknown as Awaited<
ReturnType<typeof mockBountiesList>
>);
mockListAgents.mockResolvedValue({ agents: [] });
});
test('shows no-communities message when groups list is empty', async () => {
renderExplore();
expect(await screen.findByText('No communities yet')).toBeInTheDocument();
});
test('shows no-jobs message when jobs list is empty', async () => {
renderExplore();
expect(await screen.findByText('No active jobs')).toBeInTheDocument();
});
test('shows no-bounties message when bounties list is empty', async () => {
renderExplore();
expect(await screen.findByText('No open bounties')).toBeInTheDocument();
});
test('shows no-agents message when directory returns no agents', async () => {
renderExplore();
expect(await screen.findByText('No agents registered')).toBeInTheDocument();
});
test('still renders stats section when entity sections are empty', async () => {
renderExplore();
// Stats resolve fine.
expect(await screen.findByText('42')).toBeInTheDocument();
// Empty messages present.
expect(await screen.findByText('No communities yet')).toBeInTheDocument();
});
test('hides community section entirely when groups.list returns []', async () => {
renderExplore();
// Wait for resolution.
await screen.findByText('No communities yet');
// No community card names appear.
expect(screen.queryByText('Alpha Community')).not.toBeInTheDocument();
});
});
// ── Bounties open-only client-side filter ─────────────────────────────────────
describe('bounties client-side status filter', () => {
test('filters out non-open bounties returned by the server', async () => {
mockBountiesList.mockResolvedValue({
bounties: [
{
bountyId: 'b-open',
creator: 'c',
title: 'Open Bounty',
description: 'desc',
reward: { amount: '100', asset: 'SOL' },
status: 'open',
submissionCount: 0,
commentCount: 0,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
bountyId: 'b-closed',
creator: 'c',
title: 'Closed Bounty',
description: 'desc',
reward: { amount: '50', asset: 'SOL' },
status: 'closed',
submissionCount: 1,
commentCount: 0,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
],
} as unknown as Awaited<ReturnType<typeof mockBountiesList>>);
renderExplore();
expect(await screen.findByText('Open Bounty')).toBeInTheDocument();
expect(screen.queryByText('Closed Bounty')).not.toBeInTheDocument();
});
test('shows empty state when all returned bounties are non-open', async () => {
mockBountiesList.mockResolvedValue({
bounties: [
{
bountyId: 'b-closed',
creator: 'c',
title: 'Closed Only',
description: 'desc',
reward: { amount: '50', asset: 'SOL' },
status: 'closed',
submissionCount: 0,
commentCount: 0,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
],
} as unknown as Awaited<ReturnType<typeof mockBountiesList>>);
renderExplore();
expect(await screen.findByText('No open bounties')).toBeInTheDocument();
expect(screen.queryByText('Closed Only')).not.toBeInTheDocument();
});
});
// ── Section error graceful degrade ────────────────────────────────────────────
describe('section error: graceful degrade', () => {
test('hides communities section on groups.list error; other sections still render', async () => {
mockGroupsList.mockRejectedValue(new Error('communities API down'));
renderExplore();
// Other sections still resolve.
expect(await screen.findByText('Build a DeFi Dashboard')).toBeInTheDocument();
expect(await screen.findByText('Fix Critical Bug')).toBeInTheDocument();
expect(await screen.findByText('@nexus')).toBeInTheDocument();
// Communities section entirely hidden.
expect(screen.queryByText('Trending Communities')).not.toBeInTheDocument();
expect(screen.queryByText('Alpha Community')).not.toBeInTheDocument();
expect(screen.queryByText('No communities yet')).not.toBeInTheDocument();
});
test('hides jobs section on graphql.jobs error; other sections still render', async () => {
mockGraphqlJobs.mockRejectedValue(new Error('jobs API down'));
renderExplore();
expect(await screen.findByText('Alpha Community')).toBeInTheDocument();
expect(await screen.findByText('Fix Critical Bug')).toBeInTheDocument();
expect(await screen.findByText('@nexus')).toBeInTheDocument();
expect(screen.queryByText('Active Jobs')).not.toBeInTheDocument();
});
test('hides bounties section on bounties.list error; other sections still render', async () => {
mockBountiesList.mockRejectedValue(new Error('bounties API down'));
renderExplore();
expect(await screen.findByText('Alpha Community')).toBeInTheDocument();
expect(await screen.findByText('Build a DeFi Dashboard')).toBeInTheDocument();
expect(await screen.findByText('@nexus')).toBeInTheDocument();
expect(screen.queryByText('Featured Bounties')).not.toBeInTheDocument();
});
test('hides agents section on directory.listAgents error; other sections still render', async () => {
mockListAgents.mockRejectedValue(new Error('directory API down'));
renderExplore();
expect(await screen.findByText('Alpha Community')).toBeInTheDocument();
expect(await screen.findByText('Build a DeFi Dashboard')).toBeInTheDocument();
expect(await screen.findByText('Fix Critical Bug')).toBeInTheDocument();
expect(screen.queryByText('New Agents')).not.toBeInTheDocument();
});
test('page does not crash when all entity sections error', async () => {
mockGroupsList.mockRejectedValue(new Error('fail'));
mockGraphqlJobs.mockRejectedValue(new Error('fail'));
mockBountiesList.mockRejectedValue(new Error('fail'));
mockListAgents.mockRejectedValue(new Error('fail'));
renderExplore();
// Stats still render.
expect(await screen.findByText('42')).toBeInTheDocument();
// No section headings for entity sections.
await waitFor(() => {
expect(screen.queryByText('Trending Communities')).not.toBeInTheDocument();
expect(screen.queryByText('Active Jobs')).not.toBeInTheDocument();
expect(screen.queryByText('Featured Bounties')).not.toBeInTheDocument();
expect(screen.queryByText('New Agents')).not.toBeInTheDocument();
});
});
});
// ── Stats section error states ────────────────────────────────────────────────
describe('stats section: wallet locked', () => {
test('shows wallet-locked StatusBlock when overview errors with wallet message', async () => {
mockOverview.mockRejectedValue(new Error('the wallet is not configured'));
renderExplore();
expect(await screen.findByText('Unlock your wallet to use Agent World')).toBeInTheDocument();
expect(screen.getByText(/Import your recovery phrase in Settings/i)).toBeInTheDocument();
});
test('shows wallet-locked StatusBlock for missing wallet secret material', async () => {
mockOverview.mockRejectedValue(new Error('wallet secret material is missing'));
renderExplore();
expect(await screen.findByText('Unlock your wallet to use Agent World')).toBeInTheDocument();
});
test('shows generic error StatusBlock for unknown overview failure', async () => {
mockOverview.mockRejectedValue(new Error('network timeout'));
renderExplore();
expect(await screen.findByText('Failed to load Agent World')).toBeInTheDocument();
expect(screen.getByText(/network timeout/i)).toBeInTheDocument();
});
});
describe('stats section: payment required', () => {
test('shows payment-required StatusBlock when overview throws PaymentRequiredError', async () => {
mockOverview.mockRejectedValue(new PaymentRequiredError({ terms: 'x402-v1' }));
renderExplore();
expect(await screen.findByText('Access requires payment')).toBeInTheDocument();
expect(screen.getByText(/Your wallet will be used to fulfill/i)).toBeInTheDocument();
});
});
// ── Agent display name edge cases ─────────────────────────────────────────────
describe('agent handle derivation', () => {
test('strips leading @ from username before re-adding it', async () => {
mockListAgents.mockResolvedValue({ agents: [{ agentId: 'a-1', username: '@preexisting' }] });
renderExplore();
// Should show @preexisting not @@preexisting.
expect(await screen.findByText('@preexisting')).toBeInTheDocument();
expect(screen.queryByText('@@preexisting')).not.toBeInTheDocument();
});
test('falls back to name when username is absent', async () => {
mockListAgents.mockResolvedValue({ agents: [{ agentId: 'a-2', name: 'SolAgent' }] });
renderExplore();
expect(await screen.findByText('@SolAgent')).toBeInTheDocument();
});
test('falls back to first 8 chars of agentId when name and username absent', async () => {
mockListAgents.mockResolvedValue({ agents: [{ agentId: 'xyzabc1234567890' }] });
renderExplore();
// displayName = agentId.slice(0,8) = 'xyzabc12'
expect(await screen.findByText('@xyzabc12')).toBeInTheDocument();
});
});
// ── Communities member count sort ─────────────────────────────────────────────
describe('communities sort by memberCount', () => {
test('sorts communities by memberCount descending', async () => {
mockGroupsList.mockResolvedValue([
{
groupId: 'low',
name: 'Small Group',
description: 'small',
createdBy: 'u1',
createdAt: '2024-01-01T00:00:00Z',
membershipPolicy: 'open',
memberCount: 5,
membershipEpoch: 1,
},
{
groupId: 'high',
name: 'Big Group',
description: 'big',
createdBy: 'u2',
createdAt: '2024-01-01T00:00:00Z',
membershipPolicy: 'open',
memberCount: 500,
membershipEpoch: 1,
},
]);
renderExplore();
await screen.findByText('Big Group');
// In the DOM, Big Group (500 members) should appear before Small Group (5 members).
const bigIdx = document.body.innerHTML.indexOf('Big Group');
const smallIdx = document.body.innerHTML.indexOf('Small Group');
expect(bigIdx).toBeLessThan(smallIdx);
});
});
// ── Cancellation ──────────────────────────────────────────────────────────────
describe('cancellation on unmount', () => {
test('does not update state after unmount (no act warning)', async () => {
let resolveGroups!: (v: typeof GROUPS_OK) => void;
mockGroupsList.mockReturnValue(
new Promise(r => {
resolveGroups = r;
})
);
const { unmount } = renderExplore();
unmount();
resolveGroups(GROUPS_OK);
await waitFor(() => expect(mockGroupsList).toHaveBeenCalled());
// No error — the cancelled flag swallowed the state update.
});
});
@@ -0,0 +1,788 @@
/**
* ExploreSection — Agent World "Explore" overview.
*
* Renders network stat cards at the top (via explorer.overview()) followed by
* four live-data sections that each fetch independently:
* - Trending Communities → apiClient.groups.list({ limit: 12 })
* - Active Jobs → apiClient.graphql.jobs({ status: 'OPEN', limit: 6 })
* - Featured Bounties → apiClient.bounties.list({ status: 'open', limit: 6 })
* - New Agents → apiClient.directory.listAgents({ limit: 8 })
*
* Each live section handles loading / empty / error independently; a failure in
* one section never crashes the page. The stats section uses a StatusBlock for
* wallet-locked / payment-required / hard error states.
*/
import debugFactory from 'debug';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PanelScaffold from '../../../components/layout/PanelScaffold';
import {
type AgentCard,
type Bounty,
type ExplorerOverview,
type GqlJobPosting,
type GroupMetadata,
PaymentRequiredError,
} from '../../../lib/agentworld/invokeApiClient';
import { useT } from '../../../lib/i18n/I18nContext';
import { apiClient } from '../../AgentWorldShell';
const debug = debugFactory('agentworld:explore');
// ── Shared card style ─────────────────────────────────────────────────────────
const CARD_CLASS =
'rounded-lg border border-stone-200 bg-white dark:border-neutral-800 dark:bg-neutral-900';
// ── Stats section types & hook ────────────────────────────────────────────────
type StatsState =
| { status: 'loading' }
| { status: 'payment_required'; challenge: unknown }
| { status: 'error'; message: string }
| { status: 'ok'; data: ExplorerOverview };
// OverviewShape removed — ExplorerOverview now carries typed allTime/last24h/ledger fields.
function useExplorerOverview(): StatsState {
const [state, setState] = useState<StatsState>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
debug('fetching explorer overview');
void apiClient.explorer
.overview()
.then(data => {
if (cancelled) return;
debug('loaded explorer overview');
setState({ status: 'ok', data });
})
.catch((err: unknown) => {
if (cancelled) return;
if (err instanceof PaymentRequiredError) {
debug('explorer overview: 402 payment_required');
setState({ status: 'payment_required', challenge: err.challenge });
} else {
debug('explorer overview: error: %s', String(err));
setState({ status: 'error', message: String(err) });
}
});
return () => {
cancelled = true;
};
}, []);
return state;
}
// ── Live section state type ───────────────────────────────────────────────────
type SectionState<T> =
| { status: 'loading' }
| { status: 'ok'; data: T[] }
| { status: 'empty' }
| { status: 'error' };
// ── Communities hook ──────────────────────────────────────────────────────────
function useExploreCommunities(): SectionState<GroupMetadata> {
const [state, setState] = useState<SectionState<GroupMetadata>>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
debug('fetching explore communities');
void apiClient.groups
.list({ limit: 12 })
.then(raw => {
if (cancelled) return;
// Sort client-side by memberCount desc (no server-side sort param).
const sorted = [...raw].sort((a, b) => (b.memberCount ?? 0) - (a.memberCount ?? 0));
if (sorted.length === 0) {
debug('communities section: empty, hiding');
setState({ status: 'empty' });
} else {
debug('loaded %d communities', sorted.length);
setState({ status: 'ok', data: sorted });
}
})
.catch(err => {
if (cancelled) return;
debug('communities fetch failed: %s', String(err));
setState({ status: 'error' });
});
return () => {
cancelled = true;
};
}, []);
return state;
}
// ── Jobs hook ─────────────────────────────────────────────────────────────────
function useExploreJobs(): SectionState<GqlJobPosting> {
const [state, setState] = useState<SectionState<GqlJobPosting>>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
debug('fetching explore jobs');
void apiClient.graphql
.jobs({ status: 'OPEN', limit: 6 })
.then(result => {
if (cancelled) return;
const jobs = result.jobs ?? [];
if (jobs.length === 0) {
debug('jobs section: empty, hiding');
setState({ status: 'empty' });
} else {
debug('loaded %d jobs', jobs.length);
setState({ status: 'ok', data: jobs });
}
})
.catch(err => {
if (cancelled) return;
debug('jobs fetch failed: %s', String(err));
setState({ status: 'error' });
});
return () => {
cancelled = true;
};
}, []);
return state;
}
// ── Bounties hook ─────────────────────────────────────────────────────────────
function useExploreBounties(): SectionState<Bounty> {
const [state, setState] = useState<SectionState<Bounty>>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
debug('fetching explore bounties');
void apiClient.bounties
.list({ status: 'open', limit: 6 })
.then(result => {
if (cancelled) return;
// Client-side filter to open status in case the server ignores the param.
const open = (result.bounties ?? []).filter(b => b.status === 'open');
if (open.length === 0) {
debug('bounties section: empty, hiding');
setState({ status: 'empty' });
} else {
debug('loaded %d bounties', open.length);
setState({ status: 'ok', data: open });
}
})
.catch(err => {
if (cancelled) return;
debug('bounties fetch failed: %s', String(err));
setState({ status: 'error' });
});
return () => {
cancelled = true;
};
}, []);
return state;
}
// ── Agents hook ───────────────────────────────────────────────────────────────
function useExploreAgents(): SectionState<AgentCard> {
const [state, setState] = useState<SectionState<AgentCard>>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
debug('fetching explore agents');
void apiClient.directory
.listAgents({ limit: 8 })
.then(result => {
if (cancelled) return;
const agents = result.agents ?? [];
if (agents.length === 0) {
debug('agents section: empty, hiding');
setState({ status: 'empty' });
} else {
debug('loaded %d agents', agents.length);
setState({ status: 'ok', data: agents });
}
})
.catch(err => {
if (cancelled) return;
debug('agents fetch failed: %s', String(err));
setState({ status: 'error' });
});
return () => {
cancelled = true;
};
}, []);
return state;
}
// ── Stat helper functions ─────────────────────────────────────────────────────
function usd(value?: string): string {
if (value == null) return '—';
const n = Number(value);
if (Number.isNaN(n)) return `$${value}`;
return `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function num(value?: number): string {
return value == null ? '—' : value.toLocaleString();
}
// ── Shared primitive components ───────────────────────────────────────────────
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className={`p-4 ${CARD_CLASS}`}>
<div className="text-[11px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{label}
</div>
<div className="mt-1.5 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{value}
</div>
{sub && <div className="mt-0.5 text-xs text-stone-400 dark:text-neutral-500">{sub}</div>}
</div>
);
}
/** Centered status message for loading / wallet / hard error states of the stats block. */
function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2 text-center">
<p className={`text-base font-medium ${tone}`}>{title}</p>
{body && <p className="max-w-md text-sm text-stone-500 dark:text-neutral-400">{body}</p>}
</div>
);
}
/** Section heading row with optional "View all" link. */
function SectionHeader({
title,
viewAllLabel,
onViewAll,
}: {
title: string;
viewAllLabel: string;
onViewAll: () => void;
}) {
return (
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800 dark:text-neutral-200">{title}</h3>
<button
type="button"
onClick={onViewAll}
className="text-xs text-ocean-500 hover:underline dark:text-blue-400">
{viewAllLabel}
</button>
</div>
);
}
/** Inline empty message rendered inside a section when it has no data. */
function SectionEmpty({ message }: { message: string }) {
return <p className="py-4 text-center text-sm text-stone-400 dark:text-neutral-500">{message}</p>;
}
// ── Communities section ───────────────────────────────────────────────────────
function CommunitySkeletonGrid() {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={`animate-pulse p-3 ${CARD_CLASS}`}>
<div className="space-y-2">
<div className="h-4 w-3/4 rounded bg-stone-200 dark:bg-neutral-800" />
<div className="h-3 w-full rounded bg-stone-200 dark:bg-neutral-800" />
<div className="h-3 w-1/3 rounded bg-stone-200 dark:bg-neutral-800" />
</div>
</div>
))}
</div>
);
}
function CommunityCard({ group }: { group: GroupMetadata }) {
const tags = group.tags ?? [];
return (
<div className={`p-3 ${CARD_CLASS}`}>
<div className="font-medium text-stone-900 dark:text-neutral-100">{group.name}</div>
{group.description && (
<p className="mt-1 line-clamp-2 text-xs text-stone-500 dark:text-neutral-400">
{group.description}
</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-1">
<span className="text-xs text-stone-400 dark:text-neutral-500">
{group.memberCount} members
</span>
{tags.slice(0, 3).map(tag => (
<span
key={tag}
className="rounded-full bg-stone-100 px-2 py-0.5 text-[11px] text-stone-600 dark:bg-neutral-800 dark:text-neutral-400">
{tag}
</span>
))}
</div>
</div>
);
}
function ExploreCommunitiesGrid({
state,
title,
viewAllLabel,
emptyMessage,
onViewAll,
}: {
state: SectionState<GroupMetadata>;
title: string;
viewAllLabel: string;
emptyMessage: string;
onViewAll: () => void;
}) {
if (state.status === 'error') return null;
return (
<section>
<SectionHeader title={title} viewAllLabel={viewAllLabel} onViewAll={onViewAll} />
{state.status === 'loading' && <CommunitySkeletonGrid />}
{state.status === 'empty' && <SectionEmpty message={emptyMessage} />}
{state.status === 'ok' && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{state.data.map(group => (
<CommunityCard key={group.groupId} group={group} />
))}
</div>
)}
</section>
);
}
// ── Jobs section ──────────────────────────────────────────────────────────────
function JobSkeletonList() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`animate-pulse p-3 ${CARD_CLASS}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-2">
<div className="h-4 w-2/3 rounded bg-stone-200 dark:bg-neutral-800" />
<div className="h-3 w-1/3 rounded bg-stone-200 dark:bg-neutral-800" />
</div>
<div className="h-5 w-16 rounded bg-stone-200 dark:bg-neutral-800" />
</div>
</div>
))}
</div>
);
}
function relativeTime(isoDate: string): string {
const delta = Date.now() - new Date(isoDate).getTime();
const mins = Math.floor(delta / 60_000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function JobRow({ job }: { job: GqlJobPosting }) {
const skills = job.skills ?? [];
return (
<div className={`p-3 ${CARD_CLASS}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-stone-900 dark:text-neutral-100">{job.title}</div>
<div className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{job.clientProfile.displayName} &middot; {relativeTime(job.createdAt)}
</div>
{skills.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{skills.slice(0, 4).map(skill => (
<span
key={skill}
className="rounded-full bg-stone-100 px-2 py-0.5 text-[11px] text-stone-600 dark:bg-neutral-800 dark:text-neutral-400">
{skill}
</span>
))}
</div>
)}
</div>
<div className="flex-shrink-0 text-right">
<div className="text-sm font-semibold text-stone-800 dark:text-neutral-200">
{job.budget.amount} {job.budget.asset}
</div>
</div>
</div>
</div>
);
}
function ExploreJobsList({
state,
title,
viewAllLabel,
emptyMessage,
onViewAll,
}: {
state: SectionState<GqlJobPosting>;
title: string;
viewAllLabel: string;
emptyMessage: string;
onViewAll: () => void;
}) {
if (state.status === 'error') return null;
return (
<section>
<SectionHeader title={title} viewAllLabel={viewAllLabel} onViewAll={onViewAll} />
{state.status === 'loading' && <JobSkeletonList />}
{state.status === 'empty' && <SectionEmpty message={emptyMessage} />}
{state.status === 'ok' && (
<div className="space-y-2">
{state.data.map(job => (
<JobRow key={job.jobId} job={job} />
))}
</div>
)}
</section>
);
}
// ── Bounties section ──────────────────────────────────────────────────────────
function BountySkeletonList() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`animate-pulse p-3 ${CARD_CLASS}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-2">
<div className="h-4 w-2/3 rounded bg-stone-200 dark:bg-neutral-800" />
<div className="h-3 w-1/4 rounded bg-stone-200 dark:bg-neutral-800" />
</div>
<div className="h-5 w-20 rounded bg-stone-200 dark:bg-neutral-800" />
</div>
</div>
))}
</div>
);
}
function BountyRow({ bounty }: { bounty: Bounty }) {
return (
<div className={`p-3 ${CARD_CLASS}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-stone-900 dark:text-neutral-100">{bounty.title}</div>
<div className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{bounty.submissionCount} submission{bounty.submissionCount !== 1 ? 's' : ''}
{bounty.deadline ? ` · deadline ${new Date(bounty.deadline).toLocaleDateString()}` : ''}
</div>
</div>
<div className="flex-shrink-0 text-right">
<div className="text-sm font-semibold text-stone-800 dark:text-neutral-200">
{bounty.reward.amount} {bounty.reward.asset}
</div>
</div>
</div>
</div>
);
}
function ExploreBountiesList({
state,
title,
viewAllLabel,
emptyMessage,
onViewAll,
}: {
state: SectionState<Bounty>;
title: string;
viewAllLabel: string;
emptyMessage: string;
onViewAll: () => void;
}) {
if (state.status === 'error') return null;
return (
<section>
<SectionHeader title={title} viewAllLabel={viewAllLabel} onViewAll={onViewAll} />
{state.status === 'loading' && <BountySkeletonList />}
{state.status === 'empty' && <SectionEmpty message={emptyMessage} />}
{state.status === 'ok' && (
<div className="space-y-2">
{state.data.map(bounty => (
<BountyRow key={bounty.bountyId} bounty={bounty} />
))}
</div>
)}
</section>
);
}
// ── Agents section ────────────────────────────────────────────────────────────
const AVATAR_COLORS = [
'bg-blue-500',
'bg-purple-500',
'bg-pink-500',
'bg-emerald-500',
'bg-amber-500',
'bg-cyan-500',
'bg-rose-500',
'bg-violet-500',
];
function agentAvatarColor(agentId: string): string {
let total = 0;
for (let i = 0; i < agentId.length; i++) {
total += agentId.charCodeAt(i);
}
return AVATAR_COLORS[total % AVATAR_COLORS.length] ?? 'bg-blue-500';
}
function agentDisplayName(agent: AgentCard): string {
return agent.username ?? agent.name ?? agent.agentId.slice(0, 8);
}
function AgentSkeletonGrid() {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={`animate-pulse p-3 ${CARD_CLASS}`}>
<div className="flex flex-col items-center gap-2 text-center">
<div className="h-10 w-10 rounded-full bg-stone-200 dark:bg-neutral-800" />
<div className="h-3 w-16 rounded bg-stone-200 dark:bg-neutral-800" />
<div className="h-3 w-full rounded bg-stone-200 dark:bg-neutral-800" />
</div>
</div>
))}
</div>
);
}
function AgentMiniCard({ agent }: { agent: AgentCard }) {
const displayName = agentDisplayName(agent);
const handle = '@' + displayName.replace(/^@+/, '');
const initials = displayName.replace(/^@+/, '').slice(0, 2).toUpperCase();
const colorClass = agentAvatarColor(agent.agentId);
return (
<div className={`p-3 ${CARD_CLASS}`}>
<div className="flex flex-col items-center gap-1.5 text-center">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full text-xs font-bold text-white ${colorClass}`}>
{initials}
</div>
<div className="text-xs font-medium text-stone-800 dark:text-neutral-200">{handle}</div>
{agent.description && (
<p className="line-clamp-2 text-[11px] text-stone-400 dark:text-neutral-500">
{agent.description}
</p>
)}
</div>
</div>
);
}
function ExploreAgentsGrid({
state,
title,
viewAllLabel,
emptyMessage,
onViewAll,
}: {
state: SectionState<AgentCard>;
title: string;
viewAllLabel: string;
emptyMessage: string;
onViewAll: () => void;
}) {
if (state.status === 'error') return null;
return (
<section>
<SectionHeader title={title} viewAllLabel={viewAllLabel} onViewAll={onViewAll} />
{state.status === 'loading' && <AgentSkeletonGrid />}
{state.status === 'empty' && <SectionEmpty message={emptyMessage} />}
{state.status === 'ok' && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{state.data.map(agent => (
<AgentMiniCard key={agent.agentId} agent={agent} />
))}
</div>
)}
</section>
);
}
// ── Network stats section (existing, preserved) ───────────────────────────────
function NetworkStatsSection({ state }: { state: StatsState }) {
if (state.status === 'loading') {
return (
<div className="flex h-40 items-center justify-center text-stone-400 dark:text-neutral-500">
<span className="animate-pulse text-sm">Loading network overview</span>
</div>
);
}
if (state.status === 'payment_required') {
return (
<StatusBlock
tone="text-amber-600 dark:text-amber-400"
title="Access requires payment"
body="Your wallet will be used to fulfill the x402 payment challenge."
/>
);
}
if (state.status === 'error') {
const isWalletLocked =
state.message.includes('wallet is not configured') ||
state.message.includes('wallet secret material is missing');
return isWalletLocked ? (
<StatusBlock
tone="text-stone-700 dark:text-neutral-200"
title="Unlock your wallet to use Agent World"
body="Agent World uses your wallet identity. Import your recovery phrase in Settings to continue."
/>
) : (
<StatusBlock
tone="text-red-600 dark:text-red-400"
title="Failed to load Agent World"
body={state.message}
/>
);
}
const ov = state.data;
return (
<div className="space-y-4">
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
All time
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatCard label="Registered agents" value={num(ov.allTime?.registeredAgents)} />
<StatCard label="Volume" value={usd(ov.allTime?.volumeUsd)} />
<StatCard label="Fees" value={usd(ov.allTime?.feesUsd)} />
</div>
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
Last 24 hours
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Transactions" value={num(ov.last24h?.transactions)} />
<StatCard label="Active agents" value={num(ov.last24h?.uniqueAgents)} />
<StatCard label="Volume" value={usd(ov.last24h?.volumeUsd)} />
<StatCard label="Fees" value={usd(ov.last24h?.feesUsd)} />
</div>
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
Ledger
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatCard label="Total entries" value={num(ov.ledger?.totalEntries)} />
<StatCard
label="Latest tx"
value={ov.ledger?.latestTxId ?? '—'}
sub={
ov.ledger?.latestTimestamp
? new Date(ov.ledger.latestTimestamp).toLocaleString()
: undefined
}
/>
</div>
</div>
</div>
);
}
// ── Root component ─────────────────────────────────────────────────────────────
export default function ExploreSection() {
const { t } = useT();
const navigate = useNavigate();
const statsState = useExplorerOverview();
const communitiesState = useExploreCommunities();
const jobsState = useExploreJobs();
const bountiesState = useExploreBounties();
const agentsState = useExploreAgents();
return (
<PanelScaffold description={t('explore.networkOverview')}>
<div className="space-y-8">
{/* ── Network stats (top, always first) ── */}
<section>
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('explore.networkOverview')}
</h3>
<NetworkStatsSection state={statsState} />
</section>
{/* ── Live data sections ── */}
<ExploreCommunitiesGrid
state={communitiesState}
title={t('explore.trendingCommunities')}
viewAllLabel={t('explore.viewAll')}
emptyMessage={t('explore.noCommunities')}
onViewAll={() => {
// Navigate to Messaging (Groups tab) — no standalone communities route yet.
navigate('/agent-world/messaging');
}}
/>
<ExploreJobsList
state={jobsState}
title={t('explore.activeJobs')}
viewAllLabel={t('explore.viewAll')}
emptyMessage={t('explore.noJobs')}
onViewAll={() => {
navigate('/agent-world/jobs');
}}
/>
<ExploreBountiesList
state={bountiesState}
title={t('explore.featuredBounties')}
viewAllLabel={t('explore.viewAll')}
emptyMessage={t('explore.noBounties')}
onViewAll={() => {
navigate('/agent-world/bounties');
}}
/>
<ExploreAgentsGrid
state={agentsState}
title={t('explore.newAgents')}
viewAllLabel={t('explore.viewAll')}
emptyMessage={t('explore.noAgents')}
onViewAll={() => {
navigate('/agent-world/directory');
}}
/>
</div>
</PanelScaffold>
);
}
@@ -88,6 +88,7 @@ export interface AgentCard {
agentId: string;
name?: string;
description?: string;
username?: string;
[key: string]: unknown;
}
@@ -97,6 +98,9 @@ export interface ListAgentsResponse {
}
export interface ExplorerOverview {
allTime?: { feesUsd?: string; registeredAgents?: number; volumeUsd?: string };
last24h?: { feesUsd?: string; transactions?: number; uniqueAgents?: number; volumeUsd?: string };
ledger?: { totalEntries?: number; latestTxId?: string; latestTimestamp?: string };
[key: string]: unknown;
}
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'الملفات الشخصية',
'agentWorld.marketplace': 'السوق',
'agentWorld.messaging': 'الرسائل',
// Agent World — Explore section live data
'explore.networkOverview': 'نظرة عامة على الشبكة',
'explore.trendingCommunities': 'المجتمعات الرائجة',
'explore.activeJobs': 'الوظائف النشطة',
'explore.featuredBounties': 'المكافآت المميزة',
'explore.newAgents': 'وكلاء جدد',
'explore.viewAll': 'عرض الكل',
'explore.noCommunities': 'لا توجد مجتمعات بعد',
'explore.noJobs': 'لا توجد وظائف نشطة',
'explore.noBounties': 'لا توجد مكافآت مفتوحة',
'explore.noAgents': 'لم يتم تسجيل أي وكلاء',
'agentWorld.jobs.deadlineFuture': 'يجب أن يكون الموعد النهائي للمقترح في المستقبل',
'nav.avatarMenu.account': 'الحساب',
'nav.avatarMenu.billing': 'الفواتير',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'প্রোফাইল',
'agentWorld.marketplace': 'মার্কেটপ্লেস',
'agentWorld.messaging': 'বার্তা',
// Agent World — Explore section live data
'explore.networkOverview': 'নেটওয়ার্ক ওভারভিউ',
'explore.trendingCommunities': 'ট্রেন্ডিং কমিউনিটি',
'explore.activeJobs': 'সক্রিয় চাকরি',
'explore.featuredBounties': 'ফিচার্ড বাউন্টি',
'explore.newAgents': 'নতুন এজেন্ট',
'explore.viewAll': 'সব দেখুন',
'explore.noCommunities': 'এখনো কোনো কমিউনিটি নেই',
'explore.noJobs': 'কোনো সক্রিয় চাকরি নেই',
'explore.noBounties': 'কোনো খোলা বাউন্টি নেই',
'explore.noAgents': 'কোনো এজেন্ট নিবন্ধিত নেই',
'agentWorld.jobs.deadlineFuture': 'প্রস্তাবের সময়সীমা ভবিষ্যতে হতে হবে',
'nav.avatarMenu.account': 'অ্যাকাউন্ট',
'nav.avatarMenu.billing': 'বিলিং',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profile',
'agentWorld.marketplace': 'Marktplatz',
'agentWorld.messaging': 'Nachrichten',
// Agent World — Explore section live data
'explore.networkOverview': 'Netzwerkübersicht',
'explore.trendingCommunities': 'Trending-Communities',
'explore.activeJobs': 'Aktive Jobs',
'explore.featuredBounties': 'Empfohlene Bounties',
'explore.newAgents': 'Neue Agenten',
'explore.viewAll': 'Alle anzeigen',
'explore.noCommunities': 'Noch keine Communities',
'explore.noJobs': 'Keine aktiven Jobs',
'explore.noBounties': 'Keine offenen Bounties',
'explore.noAgents': 'Keine Agenten registriert',
'agentWorld.jobs.deadlineFuture': 'Die Angebotsfrist muss in der Zukunft liegen',
'nav.avatarMenu.account': 'Konto',
'nav.avatarMenu.billing': 'Abrechnung',
+11
View File
@@ -37,6 +37,17 @@ const en: TranslationMap = {
'agentWorld.profiles': 'Profiles',
'agentWorld.marketplace': 'Marketplace',
'agentWorld.messaging': 'Messages',
// Agent World — Explore section live data
'explore.networkOverview': 'Network Overview',
'explore.trendingCommunities': 'Trending Communities',
'explore.activeJobs': 'Active Jobs',
'explore.featuredBounties': 'Featured Bounties',
'explore.newAgents': 'New Agents',
'explore.viewAll': 'View all',
'explore.noCommunities': 'No communities yet',
'explore.noJobs': 'No active jobs',
'explore.noBounties': 'No open bounties',
'explore.noAgents': 'No agents registered',
'agentWorld.jobs.deadlineFuture': 'Proposal deadline must be in the future',
// Agent World — Settings section UI
'nav.avatarMenu.account': 'Account',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Perfiles',
'agentWorld.marketplace': 'Mercado',
'agentWorld.messaging': 'Mensajes',
// Agent World — Explore section live data
'explore.networkOverview': 'Resumen de red',
'explore.trendingCommunities': 'Comunidades en tendencia',
'explore.activeJobs': 'Trabajos activos',
'explore.featuredBounties': 'Recompensas destacadas',
'explore.newAgents': 'Nuevos agentes',
'explore.viewAll': 'Ver todo',
'explore.noCommunities': 'Aún no hay comunidades',
'explore.noJobs': 'No hay trabajos activos',
'explore.noBounties': 'No hay recompensas abiertas',
'explore.noAgents': 'No hay agentes registrados',
'agentWorld.jobs.deadlineFuture': 'La fecha límite de la propuesta debe ser en el futuro',
'nav.avatarMenu.account': 'Cuenta',
'nav.avatarMenu.billing': 'Facturación',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profils',
'agentWorld.marketplace': 'Marché',
'agentWorld.messaging': 'Messages',
// Agent World — Explore section live data
'explore.networkOverview': 'Aperçu du réseau',
'explore.trendingCommunities': 'Communautés tendance',
'explore.activeJobs': 'Emplois actifs',
'explore.featuredBounties': 'Primes en vedette',
'explore.newAgents': 'Nouveaux agents',
'explore.viewAll': 'Voir tout',
'explore.noCommunities': 'Pas encore de communautés',
'explore.noJobs': 'Aucun emploi actif',
'explore.noBounties': 'Aucune prime ouverte',
'explore.noAgents': 'Aucun agent enregistré',
'agentWorld.jobs.deadlineFuture': 'La date limite de la proposition doit être dans le futur',
'nav.avatarMenu.account': 'Compte',
'nav.avatarMenu.billing': 'Facturation',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'प्रोफ़ाइल',
'agentWorld.marketplace': 'मार्केटप्लेस',
'agentWorld.messaging': 'संदेश',
// Agent World — Explore section live data
'explore.networkOverview': 'नेटवर्क अवलोकन',
'explore.trendingCommunities': 'ट्रेंडिंग समुदाय',
'explore.activeJobs': 'सक्रिय नौकरियां',
'explore.featuredBounties': 'विशेष इनाम',
'explore.newAgents': 'नए एजेंट',
'explore.viewAll': 'सब देखें',
'explore.noCommunities': 'अभी तक कोई समुदाय नहीं',
'explore.noJobs': 'कोई सक्रिय नौकरी नहीं',
'explore.noBounties': 'कोई खुला इनाम नहीं',
'explore.noAgents': 'कोई एजेंट पंजीकृत नहीं',
'agentWorld.jobs.deadlineFuture': 'प्रस्ताव की समय सीमा भविष्य में होनी चाहिए',
'nav.avatarMenu.account': 'खाता',
'nav.avatarMenu.billing': 'बिलिंग',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profil',
'agentWorld.marketplace': 'Pasar',
'agentWorld.messaging': 'Pesan',
// Agent World — Explore section live data
'explore.networkOverview': 'Ikhtisar Jaringan',
'explore.trendingCommunities': 'Komunitas Trending',
'explore.activeJobs': 'Pekerjaan Aktif',
'explore.featuredBounties': 'Bounty Unggulan',
'explore.newAgents': 'Agen Baru',
'explore.viewAll': 'Lihat semua',
'explore.noCommunities': 'Belum ada komunitas',
'explore.noJobs': 'Tidak ada pekerjaan aktif',
'explore.noBounties': 'Tidak ada bounty terbuka',
'explore.noAgents': 'Tidak ada agen terdaftar',
'agentWorld.jobs.deadlineFuture': 'Batas waktu proposal harus di masa depan',
'nav.avatarMenu.account': 'Akun',
'nav.avatarMenu.billing': 'Tagihan',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profili',
'agentWorld.marketplace': 'Mercato',
'agentWorld.messaging': 'Messaggi',
// Agent World — Explore section live data
'explore.networkOverview': 'Panoramica della rete',
'explore.trendingCommunities': 'Comunità di tendenza',
'explore.activeJobs': 'Lavori attivi',
'explore.featuredBounties': 'Taglie in evidenza',
'explore.newAgents': 'Nuovi agenti',
'explore.viewAll': 'Vedi tutto',
'explore.noCommunities': 'Nessuna comunità ancora',
'explore.noJobs': 'Nessun lavoro attivo',
'explore.noBounties': 'Nessuna taglia aperta',
'explore.noAgents': 'Nessun agente registrato',
'agentWorld.jobs.deadlineFuture': 'La scadenza della proposta deve essere nel futuro',
'nav.avatarMenu.account': 'Account',
'nav.avatarMenu.billing': 'Fatturazione',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': '프로필',
'agentWorld.marketplace': '마켓플레이스',
'agentWorld.messaging': '메시지',
// Agent World — Explore section live data
'explore.networkOverview': '네트워크 개요',
'explore.trendingCommunities': '트렌딩 커뮤니티',
'explore.activeJobs': '활성 일자리',
'explore.featuredBounties': '추천 현상금',
'explore.newAgents': '새 에이전트',
'explore.viewAll': '전체 보기',
'explore.noCommunities': '아직 커뮤니티가 없습니다',
'explore.noJobs': '활성 일자리 없음',
'explore.noBounties': '열린 현상금 없음',
'explore.noAgents': '등록된 에이전트 없음',
'agentWorld.jobs.deadlineFuture': '제안 마감일은 미래여야 합니다',
'nav.avatarMenu.account': '계정',
'nav.avatarMenu.billing': '결제',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Profile',
'agentWorld.marketplace': 'Rynek',
'agentWorld.messaging': 'Wiadomości',
// Agent World — Explore section live data
'explore.networkOverview': 'Przegląd sieci',
'explore.trendingCommunities': 'Popularne społeczności',
'explore.activeJobs': 'Aktywne oferty pracy',
'explore.featuredBounties': 'Wyróżnione nagrody',
'explore.newAgents': 'Nowi agenci',
'explore.viewAll': 'Zobacz wszystkie',
'explore.noCommunities': 'Brak społeczności',
'explore.noJobs': 'Brak aktywnych ofert',
'explore.noBounties': 'Brak otwartych nagród',
'explore.noAgents': 'Brak zarejestrowanych agentów',
'agentWorld.jobs.deadlineFuture': 'Termin złożenia oferty musi być w przyszłości',
'nav.avatarMenu.account': 'Konto',
'nav.avatarMenu.billing': 'Rozliczenia',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Perfis',
'agentWorld.marketplace': 'Mercado',
'agentWorld.messaging': 'Mensagens',
// Agent World — Explore section live data
'explore.networkOverview': 'Visão geral da rede',
'explore.trendingCommunities': 'Comunidades em alta',
'explore.activeJobs': 'Empregos ativos',
'explore.featuredBounties': 'Recompensas em destaque',
'explore.newAgents': 'Novos agentes',
'explore.viewAll': 'Ver tudo',
'explore.noCommunities': 'Ainda sem comunidades',
'explore.noJobs': 'Nenhum emprego ativo',
'explore.noBounties': 'Nenhuma recompensa aberta',
'explore.noAgents': 'Nenhum agente registrado',
'agentWorld.jobs.deadlineFuture': 'O prazo da proposta deve ser no futuro',
'nav.avatarMenu.account': 'Conta',
'nav.avatarMenu.billing': 'Faturamento',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': 'Профили',
'agentWorld.marketplace': 'Маркетплейс',
'agentWorld.messaging': 'Сообщения',
// Agent World — Explore section live data
'explore.networkOverview': 'Обзор сети',
'explore.trendingCommunities': 'Трендовые сообщества',
'explore.activeJobs': 'Активные вакансии',
'explore.featuredBounties': 'Рекомендуемые награды',
'explore.newAgents': 'Новые агенты',
'explore.viewAll': 'Смотреть все',
'explore.noCommunities': 'Пока нет сообществ',
'explore.noJobs': 'Нет активных вакансий',
'explore.noBounties': 'Нет открытых наград',
'explore.noAgents': 'Нет зарегистрированных агентов',
'agentWorld.jobs.deadlineFuture': 'Срок подачи предложения должен быть в будущем',
'nav.avatarMenu.account': 'Аккаунт',
'nav.avatarMenu.billing': 'Оплата',
+11
View File
@@ -52,6 +52,17 @@ const messages: TranslationMap = {
'agentWorld.profiles': '档案',
'agentWorld.marketplace': '市场',
'agentWorld.messaging': '消息',
// Agent World — Explore section live data
'explore.networkOverview': '网络概览',
'explore.trendingCommunities': '热门社区',
'explore.activeJobs': '活跃职位',
'explore.featuredBounties': '精选赏金',
'explore.newAgents': '新代理',
'explore.viewAll': '查看全部',
'explore.noCommunities': '暂无社区',
'explore.noJobs': '暂无活跃职位',
'explore.noBounties': '暂无开放赏金',
'explore.noAgents': '暂无注册代理',
'agentWorld.jobs.deadlineFuture': '提案截止日期必须是将来的日期',
'nav.avatarMenu.account': '账户',
'nav.avatarMenu.billing': '账单',