>);
+ render();
+ expect(await screen.findByText(/Unlock your wallet to use Agent World/i)).toBeInTheDocument();
+ });
+});
+
+// ── No handle ───────────────────────────────────────────────────────────────────
+describe('no_handle state', () => {
+ test('prompts to register when the wallet owns no handle', async () => {
+ // graphqlUser returns null (default) so hook falls through to directory.reverse.
+ reverse.mockResolvedValueOnce({ cryptoId: SOLANA_ADDR, identities: [] });
+ render();
+ expect(await screen.findByText(/No handle registered yet/i)).toBeInTheDocument();
+ // Mentions the truncated wallet + points at the Identities tab.
+ expect(screen.getByText(/Register one in the Identities tab/i)).toBeInTheDocument();
+ // graphql.user was tried first before falling back.
+ expect(graphqlUser).toHaveBeenCalledWith(SOLANA_ADDR);
+ expect(reverse).toHaveBeenCalledWith(SOLANA_ADDR);
+ });
+});
+
+// ── Payment required / error ───────────────────────────────────────────────────
+describe('payment_required + error', () => {
+ test('renders the x402 payment message', async () => {
+ reverse.mockRejectedValueOnce(new PaymentRequiredError({ terms: 'x402' }));
+ render();
+ expect(await screen.findByText(/Access requires payment/i)).toBeInTheDocument();
+ });
+
+ test('renders a generic error for an unknown failure', async () => {
+ reverse.mockRejectedValueOnce(new Error('boom: backend exploded'));
+ render();
+ expect(await screen.findByText(/Failed to load profile/i)).toBeInTheDocument();
+ expect(screen.getByText(/boom: backend exploded/i)).toBeInTheDocument();
+ });
+});
+
+// ── Populated card (the wallet's own handle) ────────────────────────────────────
+describe('populated profile card', () => {
+ test('renders the owned handle, cryptoId, and registration date', async () => {
+ reverse.mockResolvedValueOnce({
+ cryptoId: SOLANA_ADDR,
+ identities: [
+ {
+ username: '@myhandle',
+ cryptoId: SOLANA_ADDR,
+ registeredAt: '2026-06-17T10:56:45.909Z',
+ primary: true,
+ status: 'active',
+ },
+ ],
+ });
+ render();
+ expect(await screen.findByText('@myhandle')).toBeInTheDocument();
+ // Truncated cryptoId (len > 12 → first6…last4).
+ expect(screen.getByText('WaLLet…6789')).toBeInTheDocument();
+ expect(screen.getByText(/Joined Jun 17, 2026/i)).toBeInTheDocument();
+ // A bare handle has no published bio/skills.
+ expect(screen.queryByText('Skills')).not.toBeInTheDocument();
+ });
+
+ test('picks the primary handle when the wallet owns several', async () => {
+ reverse.mockResolvedValueOnce({
+ cryptoId: SOLANA_ADDR,
+ identities: [
+ { username: '@secondary', cryptoId: SOLANA_ADDR, primary: false },
+ { username: '@primaryhandle', cryptoId: SOLANA_ADDR, primary: true },
+ ],
+ });
+ render();
+ expect(await screen.findByText('@primaryhandle')).toBeInTheDocument();
+ expect(screen.queryByText('@secondary')).not.toBeInTheDocument();
+ });
+
+ test('falls back to the first handle when none is marked primary', async () => {
+ reverse.mockResolvedValueOnce({
+ cryptoId: SOLANA_ADDR,
+ identities: [
+ { username: '@firsthandle', cryptoId: SOLANA_ADDR },
+ { username: '@otherhandle', cryptoId: SOLANA_ADDR },
+ ],
+ });
+ render();
+ expect(await screen.findByText('@firsthandle')).toBeInTheDocument();
+ });
+
+ test('renders follower and following counts from follow stats', async () => {
+ reverse.mockResolvedValueOnce({
+ cryptoId: SOLANA_ADDR,
+ identities: [{ username: '@statsuser', cryptoId: SOLANA_ADDR, primary: true }],
+ });
+ followStats.mockResolvedValueOnce({
+ agentId: SOLANA_ADDR,
+ followerCount: 42,
+ followingCount: 7,
+ });
+ render();
+ expect(await screen.findByText('@statsuser')).toBeInTheDocument();
+ expect(await screen.findByText('42')).toBeInTheDocument();
+ expect(screen.getByText('followers')).toBeInTheDocument();
+ expect(screen.getByText('7')).toBeInTheDocument();
+ expect(screen.getByText('following')).toBeInTheDocument();
+ });
+
+ test('renders singular follower when count is 1', async () => {
+ reverse.mockResolvedValueOnce({
+ cryptoId: SOLANA_ADDR,
+ identities: [{ username: '@singlefollower', cryptoId: SOLANA_ADDR, primary: true }],
+ });
+ followStats.mockResolvedValueOnce({
+ agentId: SOLANA_ADDR,
+ followerCount: 1,
+ followingCount: 0,
+ });
+ render();
+ expect(await screen.findByText('@singlefollower')).toBeInTheDocument();
+ expect(await screen.findByText('1')).toBeInTheDocument();
+ expect(screen.getByText('follower')).toBeInTheDocument();
+ });
+
+ test('hides follow stats when the API call fails', async () => {
+ reverse.mockResolvedValueOnce({
+ cryptoId: SOLANA_ADDR,
+ identities: [{ username: '@nostats', cryptoId: SOLANA_ADDR, primary: true }],
+ });
+ followStats.mockRejectedValueOnce(new Error('stats unavailable'));
+ render();
+ expect(await screen.findByText('@nostats')).toBeInTheDocument();
+ // No follower/following counts rendered.
+ expect(screen.queryByText('followers')).not.toBeInTheDocument();
+ expect(screen.queryByText('following')).not.toBeInTheDocument();
+ });
+});
+
+// ── Export identity button ────────────────────────────────────────────────────
+describe('export identity button', () => {
+ const IDENTITY_EXPORT = {
+ identity: {
+ username: '@exportuser',
+ cryptoId: SOLANA_ADDR,
+ publicKey: 'pk-abc',
+ registeredAt: '2025-01-01T00:00:00Z',
+ expiresAt: '2026-01-01T00:00:00Z',
+ status: 'ACTIVE',
+ updatedAt: '2025-06-01T00:00:00Z',
+ },
+ ledgerTransactions: [],
+ exportedAt: '2025-06-15T12:00:00Z',
+ verification: { hash: 'abc123' },
+ proofs: {
+ ownership: {
+ algorithm: 'ed25519',
+ cryptoId: SOLANA_ADDR,
+ publicKey: 'pk-abc',
+ publicKeyMatchesCryptoId: true,
+ },
+ ledgerReferences: [],
+ },
+ };
+
+ function renderWithHandle() {
+ reverse.mockResolvedValueOnce({
+ cryptoId: SOLANA_ADDR,
+ identities: [{ username: '@exportuser', cryptoId: SOLANA_ADDR, primary: true }],
+ });
+ followStats.mockResolvedValueOnce({
+ agentId: SOLANA_ADDR,
+ followerCount: 0,
+ followingCount: 0,
+ });
+ }
+
+ test('renders Export Identity button on the profile card', async () => {
+ renderWithHandle();
+ render();
+ expect(await screen.findByText('@exportuser')).toBeInTheDocument();
+ expect(screen.getByText('Export Identity')).toBeInTheDocument();
+ });
+
+ test('clicking Export Identity fetches and displays the export JSON', async () => {
+ const user = (await import('@testing-library/user-event')).default.setup();
+ renderWithHandle();
+ registryExport.mockResolvedValueOnce(IDENTITY_EXPORT);
+ render();
+ const btn = await screen.findByText('Export Identity');
+ await user.click(btn);
+ expect(registryExport).toHaveBeenCalledWith('@exportuser');
+ // JSON is displayed in a block.
+ expect(await screen.findByText(/exportedAt/)).toBeInTheDocument();
+ // Button label changes to "Hide Export".
+ expect(screen.getByText('Hide Export')).toBeInTheDocument();
+ });
+
+ test('clicking Hide Export clears the export panel', async () => {
+ const user = (await import('@testing-library/user-event')).default.setup();
+ renderWithHandle();
+ registryExport.mockResolvedValueOnce(IDENTITY_EXPORT);
+ render();
+ const btn = await screen.findByText('Export Identity');
+ await user.click(btn);
+ expect(await screen.findByText('Hide Export')).toBeInTheDocument();
+ await user.click(screen.getByText('Hide Export'));
+ // Panel is hidden, button reverts.
+ expect(screen.getByText('Export Identity')).toBeInTheDocument();
+ expect(screen.queryByText(/exportedAt/)).not.toBeInTheDocument();
+ });
+
+ test('shows error message when export fails', async () => {
+ const user = (await import('@testing-library/user-event')).default.setup();
+ renderWithHandle();
+ registryExport.mockRejectedValueOnce(new Error('Network error'));
+ render();
+ const btn = await screen.findByText('Export Identity');
+ await user.click(btn);
+ expect(await screen.findByText(/Network error/)).toBeInTheDocument();
+ // Button still says "Export Identity" (not "Hide Export") since data is null.
+ expect(screen.getByText('Export Identity')).toBeInTheDocument();
+ });
+});
+
+// ── Cancellation ────────────────────────────────────────────────────────────────
+describe('cancellation', () => {
+ test('does not update state after unmount', async () => {
+ let resolve!: (v: Awaited>) => void;
+ walletStatus.mockReturnValue(
+ new Promise(r => {
+ resolve = r;
+ })
+ );
+ const { unmount } = render();
+ unmount();
+ resolve(walletWithSolana());
+ await waitFor(() => expect(walletStatus).toHaveBeenCalled());
+ });
+});
+
+// ── GraphQL-enriched profile card ─────────────────────────────────────────────
+
+/** Minimal identity fields needed to satisfy GqlProfile.identities[]. */
+const minimalIdentity = {
+ publicKey: 'pubkey-test',
+ registeredAt: '2026-01-01T00:00:00Z',
+ expiresAt: '2027-01-01T00:00:00Z',
+ status: 'active',
+ updatedAt: '2026-01-01T00:00:00Z',
+};
+
+/** Build a minimal GqlProfile for test mocks. */
+function makeProfile(overrides: Partial = {}): GqlProfile {
+ return {
+ cryptoId: SOLANA_ADDR,
+ actorType: 'agent',
+ displayName: 'Test Agent',
+ bio: '',
+ private: false,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ verified: false,
+ attestations: [],
+ agentCard: null,
+ identities: null,
+ ...overrides,
+ };
+}
+
+describe('graphql-enriched profile card', () => {
+ test('renders rich profile from graphql.user when available', async () => {
+ graphqlUser.mockResolvedValueOnce(
+ makeProfile({
+ displayName: 'Agent Alice',
+ bio: 'Building the future',
+ tags: ['ai', 'automation'],
+ verified: true,
+ attestations: [
+ {
+ attestationId: 'att-1',
+ platform: 'github',
+ handle: 'alice',
+ proofUrl: 'https://github.com/alice',
+ status: 'verified',
+ verifiedAt: '2026-02-01T00:00:00Z',
+ },
+ ],
+ identities: [
+ { username: 'alice', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity },
+ ],
+ })
+ );
+ render();
+
+ // Rich data rendered
+ expect(await screen.findByText('@alice')).toBeInTheDocument();
+ expect(screen.getByText('Building the future')).toBeInTheDocument();
+ expect(screen.getByText('ai')).toBeInTheDocument();
+ expect(screen.getByText('automation')).toBeInTheDocument();
+ // Attestation row
+ expect(screen.getByText(/github.*alice/i)).toBeInTheDocument();
+ // Verified Accounts section heading
+ expect(screen.getByText('Verified Accounts')).toBeInTheDocument();
+ // directory.reverse should NOT have been called (graphql.user succeeded)
+ expect(reverse).not.toHaveBeenCalled();
+ });
+
+ test('falls back to directory.reverse when graphql.user returns null', async () => {
+ graphqlUser.mockResolvedValueOnce(null);
+ reverse.mockResolvedValueOnce({
+ cryptoId: SOLANA_ADDR,
+ identities: [{ username: '@fallbackuser', cryptoId: SOLANA_ADDR, primary: true }],
+ });
+ render();
+ expect(await screen.findByText('@fallbackuser')).toBeInTheDocument();
+ expect(graphqlUser).toHaveBeenCalledWith(SOLANA_ADDR);
+ expect(reverse).toHaveBeenCalledWith(SOLANA_ADDR);
+ });
+
+ test('falls back to directory.reverse when graphql.user throws non-402 error', async () => {
+ graphqlUser.mockRejectedValueOnce(new Error('GraphQL endpoint unreachable'));
+ reverse.mockResolvedValueOnce({
+ cryptoId: SOLANA_ADDR,
+ identities: [{ username: '@resilientuser', cryptoId: SOLANA_ADDR, primary: true }],
+ });
+ render();
+ expect(await screen.findByText('@resilientuser')).toBeInTheDocument();
+ expect(reverse).toHaveBeenCalledWith(SOLANA_ADDR);
+ });
+
+ test('does NOT fall back when graphql.user throws PaymentRequiredError', async () => {
+ graphqlUser.mockRejectedValueOnce(new PaymentRequiredError({ terms: 'x402' }));
+ render();
+ expect(await screen.findByText(/Access requires payment/i)).toBeInTheDocument();
+ expect(reverse).not.toHaveBeenCalled();
+ });
+
+ test('renders profile with null identities (profile exists but no registered handle)', async () => {
+ graphqlUser.mockResolvedValueOnce(
+ makeProfile({ displayName: 'No Handle Agent', bio: '', identities: null })
+ );
+ render();
+ expect(await screen.findByText('No Handle Agent')).toBeInTheDocument();
+ expect(reverse).not.toHaveBeenCalled();
+ });
+
+ test('renders profile with empty attestations array — no Verified Accounts section', async () => {
+ graphqlUser.mockResolvedValueOnce(
+ makeProfile({
+ displayName: 'Plain Agent',
+ bio: 'No attestations here',
+ attestations: [],
+ identities: [
+ { username: 'plain', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity },
+ ],
+ })
+ );
+ render();
+ expect(await screen.findByText('No attestations here')).toBeInTheDocument();
+ // "Verified Accounts" section should not render when attestations is empty
+ expect(screen.queryByText('Verified Accounts')).not.toBeInTheDocument();
+ });
+});
diff --git a/app/src/agentworld/pages/ProfilesSection.tsx b/app/src/agentworld/pages/ProfilesSection.tsx
new file mode 100644
index 0000000000..82fe82651e
--- /dev/null
+++ b/app/src/agentworld/pages/ProfilesSection.tsx
@@ -0,0 +1,423 @@
+/**
+ * ProfilesSection — Agent World Profiles section.
+ *
+ * Shows **your own** agent profile: it resolves the wallet's Solana address
+ * (`wallet_status`), reverse-looks-up the identities registered to it
+ * (`directory.reverse`), and renders the primary handle. Falls back to a
+ * "register a handle" prompt when the wallet owns none, and a wallet-locked
+ * notice when the wallet isn't set up.
+ */
+import { useCallback, useEffect, useState } from 'react';
+
+import PanelScaffold from '../../components/layout/PanelScaffold';
+import {
+ type FollowStats,
+ type GqlAttestation,
+ type GqlProfile,
+ type IdentityExport,
+ PaymentRequiredError,
+} from '../../lib/agentworld/invokeApiClient';
+import { fetchWalletStatus } from '../../services/walletApi';
+import { apiClient } from '../AgentWorldShell';
+
+/** A handle registered to the wallet (subset of the directory.reverse identity). */
+interface OwnedIdentity {
+ username?: string;
+ cryptoId?: string;
+ registeredAt?: string;
+ primary?: boolean;
+ [key: string]: unknown;
+}
+
+// ── Utility helpers ────────────────────────────────────────────────────────────
+
+function truncateCryptoId(cryptoId: string): string {
+ if (cryptoId.length <= 12) return cryptoId;
+ return `${cryptoId.slice(0, 6)}…${cryptoId.slice(-4)}`;
+}
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+}
+
+/** Normalize a skill/tag value that may be a string or an `{ id, name }` object. */
+function toLabel(value: unknown): string {
+ if (typeof value === 'string') return value;
+ if (value && typeof value === 'object') {
+ const obj = value as Record;
+ if (typeof obj['name'] === 'string') return obj['name'];
+ if (typeof obj['id'] === 'string') return obj['id'];
+ }
+ return String(value);
+}
+
+// ── State type ─────────────────────────────────────────────────────────────────
+
+/**
+ * Profile data — either rich (from GqlProfile) or bare (fallback from directory.reverse).
+ * The 'graphql' source carries a full GqlProfile with bio, tags, attestations, etc.
+ * The 'directory' source carries a bare OwnedIdentity with username + registeredAt only.
+ */
+type ProfileData =
+ | { source: 'graphql'; profile: GqlProfile }
+ | { source: 'directory'; identity: OwnedIdentity };
+
+type ProfileState =
+ | { status: 'loading' }
+ | { status: 'wallet_locked' }
+ | { status: 'no_handle'; cryptoId: string }
+ | { status: 'payment_required'; challenge: unknown }
+ | { status: 'error'; message: string }
+ | { status: 'ok'; data: ProfileData };
+
+// ── Data hook ─────────────────────────────────────────────────────────────────
+
+/** Pick the primary handle, else the first, from a reverse-lookup result. */
+function pickPrimary(identities: OwnedIdentity[]): OwnedIdentity | undefined {
+ return identities.find(i => i.primary) ?? identities[0];
+}
+
+/** Load the wallet's own identity: wallet_status → reverse-lookup → primary handle. */
+function useMyIdentity(): ProfileState {
+ const [state, setState] = useState({ status: 'loading' });
+
+ useEffect(() => {
+ let cancelled = false;
+
+ void (async () => {
+ // 1. Resolve the wallet's Solana address (= tiny.place cryptoId).
+ let cryptoId: string;
+ try {
+ const status = await fetchWalletStatus();
+ const solana = (status.accounts ?? []).find(a => a.chain === 'solana');
+ if (!solana?.address) {
+ if (!cancelled) setState({ status: 'wallet_locked' });
+ return;
+ }
+ cryptoId = solana.address;
+ } catch {
+ // wallet not configured / locked → core rejects wallet_status.
+ if (!cancelled) setState({ status: 'wallet_locked' });
+ return;
+ }
+
+ // 2. Try GraphQL profile lookup first (richer data, single round-trip).
+ try {
+ const profile = await apiClient.graphql.user(cryptoId);
+ if (cancelled) return;
+ if (profile) {
+ // GqlProfile.identities may be null — wallet has a profile but no registered handle.
+ // We still consider this "ok" because the profile exists.
+ setState({ status: 'ok', data: { source: 'graphql', profile } });
+ return;
+ }
+ // profile === null: no GqlProfile for this cryptoId. Fall through to
+ // directory.reverse for identity-only lookup (the user may have a
+ // registered handle but no published profile).
+ } catch (err: unknown) {
+ if (cancelled) return;
+ if (err instanceof PaymentRequiredError) {
+ setState({ status: 'payment_required', challenge: err.challenge });
+ return;
+ }
+ // GraphQL endpoint may not be available — fall through to REST fallback.
+ // Log but don't bail.
+ console.warn(
+ '[ProfilesSection] graphql.user failed, falling back to directory.reverse:',
+ err
+ );
+ }
+
+ // 3. Fallback: reverse-lookup handles registered to the wallet.
+ try {
+ const res = await apiClient.directory.reverse(cryptoId);
+ const identities = (res.identities ?? []) as OwnedIdentity[];
+ const mine = pickPrimary(identities);
+ if (cancelled) return;
+ setState(
+ mine
+ ? { status: 'ok', data: { source: 'directory', identity: mine } }
+ : { status: 'no_handle', cryptoId }
+ );
+ } 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;
+}
+
+// ── Sub-components ────────────────────────────────────────────────────────────
+
+function AgentProfileCard({ data }: { data: ProfileData }) {
+ const [followStats, setFollowStats] = useState(null);
+ const [exportData, setExportData] = useState(null);
+ const [exportLoading, setExportLoading] = useState(false);
+ const [exportError, setExportError] = useState(null);
+
+ // ── Extract display fields from either data source ─────────────────────────
+ const isGraphql = data.source === 'graphql';
+ const profile = isGraphql ? data.profile : null;
+ const identity = isGraphql ? null : data.identity;
+
+ // Determine the display name / handle.
+ // GraphQL with a registered identity → @username.
+ // GraphQL without identities (null) → displayName (no @ prefix, not a handle).
+ // Directory fallback → @username from identity.
+ const primaryIdentityUsername = isGraphql ? (profile!.identities?.[0]?.username ?? null) : null;
+ const hasHandle = isGraphql
+ ? primaryIdentityUsername !== null
+ : (identity!.username ?? null) !== null;
+ const rawUsername = isGraphql
+ ? (primaryIdentityUsername ?? profile!.displayName)
+ : (identity!.username ?? '');
+ // Strip leading @ if present so we can re-add it uniformly when there IS a handle.
+ const usernameClean = rawUsername.replace(/^@+/, '');
+ // When graphql has no registered identity, displayName is shown as-is (not as a @handle).
+ const handle = hasHandle ? `@${usernameClean}` : usernameClean;
+
+ const cryptoId = isGraphql ? profile!.cryptoId : (identity!.cryptoId ?? '');
+ const bio = isGraphql ? profile!.bio : '';
+ const displayName = isGraphql ? profile!.displayName : '';
+ const avatarUrl = isGraphql ? (profile!.avatarUrl ?? '') : '';
+ const createdAt = isGraphql ? profile!.createdAt : (identity!.registeredAt ?? '');
+ const verified = isGraphql ? profile!.verified : false;
+ const rawSkills = isGraphql ? (profile!.tags ?? []) : [];
+ const skills = rawSkills.map(toLabel);
+ const attestations: GqlAttestation[] = isGraphql ? (profile!.attestations ?? []) : [];
+
+ const agentId = cryptoId;
+ const agentName = displayName || usernameClean || '?';
+ const initials = agentName.slice(0, 2).toUpperCase();
+
+ const handleExport = useCallback(async () => {
+ if (exportLoading) return;
+ // Toggle: if already showing, clear it.
+ if (exportData) {
+ setExportData(null);
+ return;
+ }
+ setExportLoading(true);
+ setExportError(null);
+ try {
+ const result = await apiClient.registry.export(handle);
+ setExportData(result);
+ } catch (err) {
+ setExportError(String(err));
+ } finally {
+ setExportLoading(false);
+ }
+ }, [exportLoading, exportData, handle]);
+
+ useEffect(() => {
+ if (!agentId) return;
+ let cancelled = false;
+ void apiClient.follows
+ .stats(agentId)
+ .then(stats => {
+ if (!cancelled) setFollowStats(stats);
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [agentId]);
+
+ return (
+
+
+ {avatarUrl ? (
+

+ ) : (
+
+ {initials}
+
+ )}
+
+
+ {handle}
+ {verified && (
+
+ ✓
+
+ )}
+
+ {cryptoId && (
+
+ {truncateCryptoId(cryptoId)}
+
+ )}
+ {bio && (
+
+ {bio}
+
+ )}
+
+
+
+ {skills.length > 0 && (
+
+
Skills
+
+ {skills.map(skill => (
+
+ {skill}
+
+ ))}
+
+
+ )}
+
+ {attestations.length > 0 && (
+
+
+ Verified Accounts
+
+
+ {attestations.map(a => (
+
+ {a.platform}: {a.handle}
+
+ ))}
+
+
+ )}
+
+ {followStats && (
+
+
+
+
+ {followStats.followerCount}
+
+
+ {followStats.followerCount === 1 ? 'follower' : 'followers'}
+
+
+
+
+ {followStats.followingCount}
+
+ following
+
+
+
+ )}
+
+ {createdAt && (
+
+
+ Joined {formatDate(createdAt)}
+
+
+ )}
+
+ {/* Export identity */}
+
+
+ {exportError && (
+
{exportError}
+ )}
+ {exportData && (
+
+ {JSON.stringify(exportData, null, 2)}
+
+ )}
+
+
+ );
+}
+
+/** Centered status message used for loading / wallet / error states. */
+function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) {
+ return (
+
+
{title}
+ {body &&
{body}
}
+
+ );
+}
+
+// ── Main export ───────────────────────────────────────────────────────────────
+
+export default function ProfilesSection() {
+ const state = useMyIdentity();
+
+ let body: React.ReactNode;
+
+ if (state.status === 'loading') {
+ body = (
+
+ Loading your profile…
+
+ );
+ } else if (state.status === 'wallet_locked') {
+ body = (
+
+ );
+ } else if (state.status === 'no_handle') {
+ body = (
+
+ );
+ } else if (state.status === 'payment_required') {
+ body = (
+
+ );
+ } else if (state.status === 'error') {
+ body = (
+
+ );
+ } else {
+ // Render the wallet's own profile with either rich GraphQL data or bare
+ // directory.reverse identity. AgentProfileCard handles both shapes internally.
+ body = ;
+ }
+
+ return {body};
+}
diff --git a/app/src/agentworld/theme/AgentWorldThemeBridge.tsx b/app/src/agentworld/theme/AgentWorldThemeBridge.tsx
new file mode 100644
index 0000000000..147bf149cd
--- /dev/null
+++ b/app/src/agentworld/theme/AgentWorldThemeBridge.tsx
@@ -0,0 +1,33 @@
+/**
+ * AgentWorldThemeBridge — maps OpenHuman design tokens to tiny.place CSS variables.
+ *
+ * Injects a `;
+}
diff --git a/app/src/components/layout/shell/SidebarNav.test.tsx b/app/src/components/layout/shell/SidebarNav.test.tsx
new file mode 100644
index 0000000000..0a917db102
--- /dev/null
+++ b/app/src/components/layout/shell/SidebarNav.test.tsx
@@ -0,0 +1,36 @@
+import { screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { renderWithProviders } from '../../../test/test-utils';
+import SidebarNav from './SidebarNav';
+
+// Analytics is fire-and-forget; stub it so the nav renders without a transport.
+vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() }));
+
+/** The rendered button for a nav label (label text lives in a child span). */
+function tabButton(label: string): HTMLButtonElement {
+ return screen.getByRole('button', { name: new RegExp(label) }) as HTMLButtonElement;
+}
+
+describe('SidebarNav active matching', () => {
+ it('keeps Tiny.Place active on its redirected /agent-world/explore route', () => {
+ // The tab links to /agent-world but the index immediately redirects to
+ // /agent-world/explore — an exact match would never light up.
+ renderWithProviders(, { initialEntries: ['/agent-world/explore'] });
+
+ expect(tabButton('Tiny.Place')).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('keeps Tiny.Place active on a nested section route', () => {
+ renderWithProviders(, { initialEntries: ['/agent-world/messaging'] });
+
+ expect(tabButton('Tiny.Place')).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('does not mark Tiny.Place active on an unrelated route', () => {
+ renderWithProviders(, { initialEntries: ['/chat'] });
+
+ expect(tabButton('Tiny.Place')).not.toHaveAttribute('aria-current');
+ expect(tabButton('Chat')).toHaveAttribute('aria-current', 'page');
+ });
+});
diff --git a/app/src/components/layout/shell/SidebarNav.tsx b/app/src/components/layout/shell/SidebarNav.tsx
index 513abb1e1c..4711a20659 100644
--- a/app/src/components/layout/shell/SidebarNav.tsx
+++ b/app/src/components/layout/shell/SidebarNav.tsx
@@ -12,13 +12,18 @@ import { NavIcon } from './navIcons';
/**
* Active-route matching for a nav entry. Mirrors the rules the former
* `BottomTabBar` used so deep links keep their tab highlighted:
- * - `/chat` → any `/chat...` route
- * - `/settings` → the settings index and every `/settings/*` panel
- * - `/home` → exact match (so `/` redirects don't light it up)
+ * - `/chat` → any `/chat...` route
+ * - `/settings` → the settings index and every `/settings/*` panel
+ * - `/agent-world` → the index and every `/agent-world/*` section (it
+ * redirects to `/agent-world/explore`, so an exact match
+ * would never light up)
+ * - `/home` → exact match (so `/` redirects don't light it up)
*/
function matchActive(path: string, pathname: string): boolean {
if (path === '/chat') return pathname.startsWith('/chat');
if (path === '/settings') return pathname === '/settings' || pathname.startsWith('/settings/');
+ if (path === '/agent-world')
+ return pathname === '/agent-world' || pathname.startsWith('/agent-world/');
if (path === '/home') return pathname === '/home';
return pathname === path;
}
diff --git a/app/src/components/layout/shell/navIcons.tsx b/app/src/components/layout/shell/navIcons.tsx
index e20f91ff4e..0f0421d500 100644
--- a/app/src/components/layout/shell/navIcons.tsx
+++ b/app/src/components/layout/shell/navIcons.tsx
@@ -84,6 +84,19 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
/>