feat: chat sidebar rework, Agent World UX polish, tiny.place settlement RPC (#3854)

This commit is contained in:
Steven Enamakel
2026-06-19 18:53:19 -07:00
committed by GitHub
parent 8fd13edf96
commit a7f3ec5a7b
40 changed files with 1655 additions and 1447 deletions
+10 -1
View File
@@ -251,6 +251,14 @@ OPENHUMAN_TELEGRAM_BOT_USERNAME=openhuman_bot
# TINYPLACE_API_BASE_URL when testing tiny.place on devnet.
# OPENHUMAN_SOLANA_CLUSTER=devnet
# [optional] tiny.place's Solana settlement RPC. ONLY tiny.place payment
# broadcasts use this (general wallet ops keep OPENHUMAN_WALLET_RPC_SOLANA): they
# try this endpoint first and fall back to the public cluster RPC if it is
# unreachable. Defaults to <TINYPLACE_API_BASE_URL>/solana/rpc
# (i.e. https://api.tiny.place/solana/rpc), so it tracks prod/staging
# automatically — set this only to point settlement at a different RPC.
# TINYPLACE_SOLANA_RPC_URL=https://api.tiny.place/solana/rpc
# ---------------------------------------------------------------------------
# x402 Machine Payments
# ---------------------------------------------------------------------------
@@ -365,7 +373,8 @@ RUST_BACKTRACE=1
# tiny.place Agent World integration
# ---------------------------------------------------------------------------
# [optional] Base URL for the tiny.place backend API.
# Default: https://staging-api.tiny.place
# Default: https://api.tiny.place (production)
# Set to https://staging-api.tiny.place to test against staging.
# TINYPLACE_API_BASE_URL=https://staging-api.tiny.place
# ---------------------------------------------------------------------------
Generated
+2 -2
View File
@@ -7913,9 +7913,9 @@ dependencies = [
[[package]]
name = "tinyplace"
version = "0.6.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46921f859da27a14a5526b289b32399c5c328c26de1a1dcb3f68b0f9d19310ea"
checksum = "abf658d89a0e986a4dc5cabfbd005ba63455c70348aca60d836e333010acc9e2"
dependencies = [
"aes",
"async-trait",
+1 -1
View File
@@ -35,7 +35,7 @@ crate-type = ["rlib"]
[dependencies]
# tiny.place A2A social network SDK — published on crates.io (tinyhumansai/tiny.place)
tinyplace = "0.6.0"
tinyplace = "0.10.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_repr = "0.1"
+2 -2
View File
@@ -8803,9 +8803,9 @@ dependencies = [
[[package]]
name = "tinyplace"
version = "0.6.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46921f859da27a14a5526b289b32399c5c328c26de1a1dcb3f68b0f9d19310ea"
checksum = "abf658d89a0e986a4dc5cabfbd005ba63455c70348aca60d836e333010acc9e2"
dependencies = [
"aes",
"async-trait",
+48
View File
@@ -0,0 +1,48 @@
/**
* Asset resolution for Agent World / x402 surfaces.
*
* x402 payment challenges now return the asset as a **mint address** (e.g. the
* USDC SPL mint) rather than a symbol like `"USDC"`. This module maps the common
* mints back to a display symbol + decimals so the UI never shows a raw base58
* address, and so amount scaling uses the right decimal count.
*/
/** Known Solana SPL mints → display symbol (mainnet + devnet USDC, wrapped SOL). */
const KNOWN_MINTS: Record<string, string> = {
EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: 'USDC', // mainnet
'4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU': 'USDC', // devnet
So11111111111111111111111111111111111111112: 'SOL', // wrapped SOL
};
/** Decimals per known symbol. USDC/CASH = 6, SOL/WSOL = 9, others = 0. */
export function decimalsForSymbol(symbol: string | undefined): number {
const up = (symbol ?? '').toUpperCase();
if (up === 'USDC' || up === 'CASH') return 6;
if (up === 'SOL' || up === 'WSOL') return 9;
return 0;
}
/** True when `value` looks like a base58 Solana address rather than a symbol. */
function looksLikeMint(value: string): boolean {
return value.length >= 32 && /^[1-9A-HJ-NP-Za-km-z]+$/.test(value);
}
/**
* Resolve an x402 asset (symbol OR mint address) to a display symbol.
*
* Preference order: an explicit wallet-resolved symbol → known-mint lookup →
* the value itself when it already looks like a symbol → a truncated address.
*/
export function resolveAssetSymbol(asset: string | undefined, walletSymbol?: string): string {
if (walletSymbol && walletSymbol.trim()) return walletSymbol.trim();
if (!asset) return '';
if (KNOWN_MINTS[asset]) return KNOWN_MINTS[asset];
if (!looksLikeMint(asset)) return asset; // already a symbol
return `${asset.slice(0, 4)}${asset.slice(-4)}`;
}
/** Decimals for an x402 asset that may be a symbol or a mint address. */
export function decimalsForAsset(asset: string | undefined, walletDecimals?: number): number {
if (typeof walletDecimals === 'number') return walletDecimals;
return decimalsForSymbol(resolveAssetSymbol(asset));
}
@@ -10,12 +10,16 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { openUrl } from '../../utils/openUrl';
import X402ConfirmDialog, {
formatUnits,
fundingUrl,
isInsufficient,
type X402WalletBalance,
} from './X402ConfirmDialog';
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
const BALANCE: X402WalletBalance = {
raw: '50000000',
formatted: '50',
@@ -60,6 +64,20 @@ describe('isInsufficient', () => {
});
});
describe('fundingUrl', () => {
test('builds the tiny.place fund URL with address + asset params', () => {
expect(fundingUrl('GW1jU1ZoVpugX4Z164j7x7rpTsKQn1QvK4Techu7P6PH', 'USDC')).toBe(
'https://tiny.place/fund?address=GW1jU1ZoVpugX4Z164j7x7rpTsKQn1QvK4Techu7P6PH&asset=USDC'
);
});
test('url-encodes param values', () => {
expect(fundingUrl('addr+slash/value', 'US DC')).toBe(
'https://tiny.place/fund?address=addr%2Bslash%2Fvalue&asset=US+DC'
);
});
});
describe('X402ConfirmDialog', () => {
test('renders amount, asset, network, balance and a truncated wallet', () => {
render(<X402ConfirmDialog {...baseProps()} />);
@@ -88,10 +106,16 @@ describe('X402ConfirmDialog', () => {
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
test('disables confirm and shows a notice when balance is insufficient', () => {
test('replaces Pay with an Add-funds redirect when balance is insufficient', async () => {
render(<X402ConfirmDialog {...baseProps()} amount="60000000" />);
expect(screen.getByTestId('x402-confirm')).toBeDisabled();
// The dead disabled Pay button is gone; the notice + fund redirect appear.
expect(screen.queryByTestId('x402-confirm')).not.toBeInTheDocument();
expect(screen.getByTestId('x402-insufficient')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('x402-add-funds'));
expect(vi.mocked(openUrl)).toHaveBeenCalledWith(
'https://tiny.place/fund?address=WaLLetdeadbeef0123456789&asset=USDC'
);
});
test('shows "Unknown" balance and still allows confirm when balance is null', () => {
@@ -12,6 +12,22 @@
*/
import Button from '../../components/ui/Button';
import { ModalShell } from '../../components/ui/ModalShell';
import { openUrl } from '../../utils/openUrl';
import { decimalsForAsset, resolveAssetSymbol } from '../assets';
/** tiny.place hosted funding page — handles deposits / on-ramp for the wallet. */
const FUND_PAGE_URL = 'https://tiny.place/fund';
/**
* Build the tiny.place funding URL for a wallet + asset, e.g.
* `https://tiny.place/fund?address=<addr>&asset=USDC`. The fund page reads
* these params to pre-fill the deposit target, so the user lands ready to top
* up the exact wallet that came up short.
*/
export function fundingUrl(address: string, asset: string): string {
const params = new URLSearchParams({ address, asset });
return `${FUND_PAGE_URL}?${params.toString()}`;
}
export interface X402WalletBalance {
/** Balance in raw base units (same scale as the challenge amount). */
@@ -100,7 +116,10 @@ export default function X402ConfirmDialog({
onConfirm,
onCancel,
}: X402ConfirmDialogProps) {
const decimals = balance?.decimals ?? (asset === 'USDC' ? 6 : 0);
// `asset` may arrive as a mint address; resolve to a display symbol + decimals
// (preferring the wallet's own resolution when present).
const assetSymbol = resolveAssetSymbol(asset, balance?.assetSymbol);
const decimals = decimalsForAsset(asset, balance?.decimals);
const amountDisplay = formatUnits(amount, decimals);
const insufficient = isInsufficient(balance, amount);
const confirmDisabled = busy || insufficient;
@@ -118,7 +137,7 @@ export default function X402ConfirmDialog({
<span
className="font-semibold text-stone-900 dark:text-neutral-100"
data-testid="x402-amount">
{amountDisplay} {asset}
{amountDisplay} {assetSymbol}
</span>
</Row>
<Row label="Network">
@@ -144,7 +163,8 @@ export default function X402ConfirmDialog({
{insufficient ? (
<p className="text-xs text-coral-500" data-testid="x402-insufficient">
Insufficient {asset} balance to complete this payment.
Insufficient {assetSymbol} balance to complete this payment. Add funds to your wallet to
continue.
</p>
) : (
<p className="text-xs text-stone-400 dark:text-neutral-500">
@@ -156,14 +176,28 @@ export default function X402ConfirmDialog({
<Button variant="secondary" size="sm" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={onConfirm}
disabled={confirmDisabled}
data-testid="x402-confirm">
{busy ? busyLabel : 'Confirm & Pay'}
</Button>
{insufficient ? (
// Not enough balance — send the user to the tiny.place fund page for
// the exact wallet + asset instead of a dead, disabled Pay button.
<Button
variant="primary"
size="sm"
onClick={() => {
void openUrl(fundingUrl(walletAddress, assetSymbol));
}}
data-testid="x402-add-funds">
Add funds
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={onConfirm}
disabled={confirmDisabled}
data-testid="x402-confirm">
{busy ? busyLabel : 'Confirm & Pay'}
</Button>
)}
</div>
</div>
</ModalShell>
+3 -8
View File
@@ -44,9 +44,9 @@ const navIcon = (d: string) => (
// Format: { slug: '<path-segment>', labelKey: 'agentWorld.<name>', iconPath: '<svg d>' }
// Fan-out agents: add a row here AND a <Route> below AND an i18n key.
// Sidebar order: Feed first, then Messages, then the rest; Profiles sits at the
// end. Marketplace and Jobs are intentionally OMITTED from the sidebar (their
// routes still exist below so buy/bid/offer and job-apply flows remain
// reachable) — hidden, not removed.
// end. Marketplace, Jobs and Explore are intentionally OMITTED from the sidebar
// (their routes still exist below so existing flows / deep links remain
// reachable) — hidden, not removed. Jobs is superseded by Bounties.
const SECTIONS: AgentWorldSection[] = [
{
slug: 'feed',
@@ -72,11 +72,6 @@ const SECTIONS: AgentWorldSection[] = [
iconPath:
'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
},
{
slug: 'explore',
labelKey: 'agentWorld.explore',
iconPath: 'M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z',
},
{
slug: 'directory',
labelKey: 'agentWorld.directory',
@@ -23,7 +23,6 @@ vi.mock('../AgentWorldShell', () => ({
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
fund: vi.fn(),
cancel: vi.fn(),
submit: vi.fn(),
listSubmissions: vi.fn(),
@@ -489,62 +488,6 @@ describe('Comment flow', () => {
});
});
// ── Fund flow (x402) ──────────────────────────────────────────────────────────
describe('Fund flow (x402)', () => {
test('Fund button visible to creator on draft bounty', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty);
render(<BountiesSection />);
await waitFor(() => {
expect(screen.getByText('My own draft bounty')).toBeInTheDocument();
});
await user.click(screen.getByText('My own draft bounty'));
await waitFor(() => {
expect(screen.getByText('Fund Bounty')).toBeInTheDocument();
});
});
test('Fund button triggers x402 challenge (confirmed:false)', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty);
vi.mocked(apiClient.bounties.fund).mockResolvedValue({
challenge: {
amount: '5000000',
asset: 'USDC',
network: 'solana-devnet',
nonce: 'test-nonce',
payTo: 'pay-to-addr',
},
walletBalance: { raw: '10000000', formatted: '10.00', decimals: 6, assetSymbol: 'USDC' },
walletAddress: MY_AGENT_ID,
} as never);
render(<BountiesSection />);
await waitFor(() => {
expect(screen.getByText('My own draft bounty')).toBeInTheDocument();
});
await user.click(screen.getByText('My own draft bounty'));
await waitFor(() => {
expect(screen.getByRole('button', { name: /fund bounty/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /fund bounty/i }));
// Verify fund was called with confirmed:false
await waitFor(() => {
expect(apiClient.bounties.fund).toHaveBeenCalledWith(sampleOwnBounty.bountyId, {
confirmed: false,
});
});
});
});
// ── Cancel flow ───────────────────────────────────────────────────────────────
describe('Cancel flow', () => {
+14 -141
View File
@@ -30,8 +30,8 @@ import {
import { fetchWalletStatus } from '../../services/walletApi';
import type { ToastNotification } from '../../types/intelligence';
import { apiClient } from '../AgentWorldShell';
import { decimalsForAsset, resolveAssetSymbol } from '../assets';
import X402ConfirmDialog, { formatUnits } from '../components/X402ConfirmDialog';
import { useX402Buy } from '../hooks/useX402Buy';
// ── State types ───────────────────────────────────────────────────────────────
@@ -72,19 +72,12 @@ function abbrev(addr: string): string {
return addr;
}
/** Decimals for a given asset symbol. USDC = 6, SOL = 9, others = 0. */
function decimalsForAsset(asset: string): number {
const up = asset.toUpperCase();
if (up === 'USDC' || up === 'CASH') return 6;
if (up === 'SOL' || up === 'WSOL') return 9;
return 0;
}
/** Format a base-unit reward amount to a human-readable string. */
/** Format a base-unit reward amount to a human-readable string. `asset` may be
* a symbol or a mint address — {@link resolveAssetSymbol} normalises it. */
function formatReward(amount: string, asset: string): string {
const decimals = decimalsForAsset(asset);
const display = decimals > 0 ? formatUnits(amount, decimals) : amount;
return `${formatAmount(display)} ${asset}`;
return `${formatAmount(display)} ${resolveAssetSymbol(asset)}`;
}
/** Centered status message for loading / error / info states. */
@@ -142,7 +135,6 @@ interface BountyRowProps {
expanded: boolean;
onToggle: () => void;
myAgentId: string | null;
onFund: (bountyId: string) => void;
onSubmit: (bountyId: string) => void;
onComment: (bountyId: string) => void;
onCancel: (bountyId: string) => void;
@@ -155,7 +147,6 @@ function BountyRow({
expanded,
onToggle,
myAgentId,
onFund,
onSubmit,
onComment,
onCancel,
@@ -183,8 +174,13 @@ function BountyRow({
}, [expanded, bounty.bountyId]);
return (
<div className="border-b border-stone-200 last:border-b-0 dark:border-neutral-800">
{/* Summary row */}
<div
className={`overflow-hidden rounded-lg border bg-white transition-colors dark:bg-neutral-900 ${
expanded
? 'border-primary-300 dark:border-primary-700 sm:col-span-2'
: 'border-stone-200 hover:border-stone-300 dark:border-neutral-800 dark:hover:border-neutral-700'
}`}>
{/* Summary (card header) */}
<button
type="button"
onClick={onToggle}
@@ -416,12 +412,6 @@ function BountyRow({
{/* Action buttons (wallet-gated) */}
{myAgentId ? (
<div className="flex flex-wrap gap-2">
{/* Fund: creator + draft status */}
{isCreator && bounty.status === 'draft' && (
<Button type="button" onClick={() => onFund(bounty.bountyId)} disabled={mutating}>
Fund Bounty
</Button>
)}
{/* Submit Work: non-creator + open status */}
{!isCreator && bounty.status === 'open' && (
<Button type="button" onClick={() => onSubmit(bounty.bountyId)} disabled={mutating}>
@@ -880,10 +870,6 @@ export default function BountiesSection() {
const [submitWorkBountyId, setSubmitWorkBountyId] = useState<string | null>(null);
const [commentBountyId, setCommentBountyId] = useState<string | null>(null);
// X402 fund flow — reuses the proven confirm-before-spend hook
const fundX402 = useX402Buy((bountyId, opts) => apiClient.bounties.fund(bountyId, opts));
const [fundingBountyId, setFundingBountyId] = useState<string | null>(null);
const fetchBounties = useCallback(() => {
setState({ status: 'loading' });
void apiClient.bounties
@@ -900,13 +886,6 @@ export default function BountiesSection() {
fetchBounties();
}, [fetchBounties]);
// ── Fund flow ──────────────────────────────────────────────────────────────
function handleFund(bountyId: string) {
setFundingBountyId(bountyId);
fundX402.begin(bountyId);
}
// ── Cancel ─────────────────────────────────────────────────────────────────
async function handleCancel(bountyId: string) {
@@ -962,7 +941,7 @@ export default function BountiesSection() {
);
} else {
body = (
<div className="rounded-lg border border-stone-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{state.bounties.map(bounty => (
<BountyRow
key={bounty.bountyId}
@@ -972,7 +951,6 @@ export default function BountiesSection() {
setExpandedBountyId(prev => (prev === bounty.bountyId ? null : bounty.bountyId))
}
myAgentId={myAgentId}
onFund={handleFund}
onSubmit={id => setSubmitWorkBountyId(id)}
onComment={id => setCommentBountyId(id)}
onCancel={id => {
@@ -1020,10 +998,8 @@ export default function BountiesSection() {
},
});
fetchBounties();
// If the new bounty is a draft, offer to fund it
if ((bounty as Bounty).status === 'draft') {
handleFund((bounty as Bounty).bountyId);
}
// In SDK 0.10 create-and-fund is atomic, so a new bounty is already
// funded (`open`) — no separate fund step.
}}
/>
)}
@@ -1050,109 +1026,6 @@ export default function BountiesSection() {
/>
)}
{/* X402 Fund dialog */}
{fundingBountyId && fundX402.state.phase === 'confirm' && (
<X402ConfirmDialog
title="Fund Bounty"
subtitle={`Funding bounty ${abbrev(fundingBountyId)}`}
amount={fundX402.state.challenge.amount ?? '0'}
asset={fundX402.state.challenge.asset ?? 'USDC'}
network={fundX402.state.challenge.network}
balance={fundX402.state.balance}
walletAddress={fundX402.state.walletAddress}
onConfirm={() => {
if (fundX402.state.phase === 'confirm') {
fundX402.confirmPay(
fundingBountyId,
fundX402.state.challenge,
fundX402.state.balance,
fundX402.state.walletAddress
);
}
}}
onCancel={() => {
fundX402.reset();
setFundingBountyId(null);
}}
/>
)}
{fundingBountyId && fundX402.state.phase === 'paying' && (
<X402ConfirmDialog
title="Fund Bounty"
subtitle={`Funding bounty ${abbrev(fundingBountyId)}`}
amount={fundX402.state.challenge.amount ?? '0'}
asset={fundX402.state.challenge.asset ?? 'USDC'}
network={fundX402.state.challenge.network}
balance={fundX402.state.balance}
walletAddress={fundX402.state.walletAddress}
busy
busyLabel="Broadcasting…"
onConfirm={() => {}}
onCancel={() => {}}
/>
)}
{fundingBountyId && fundX402.state.phase === 'success' && (
<ModalShell
title="Bounty Funded"
titleId="bounty-funded-modal-title"
onClose={() => {
fundX402.reset();
setFundingBountyId(null);
fetchBounties();
}}>
<div className="space-y-3 text-sm">
<p className="text-green-700 dark:text-green-400">Bounty funded successfully!</p>
{fundX402.state.onChainTx && (
<p className="text-xs text-stone-500 dark:text-neutral-400">
Transaction:{' '}
<a
href={`https://explorer.solana.com/tx/${fundX402.state.onChainTx}${(fundX402.state.network ?? '').includes('devnet') ? '?cluster=devnet' : ''}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-primary-600 hover:underline dark:text-primary-400">
{abbrev(fundX402.state.onChainTx)}
</a>
</p>
)}
<div className="flex justify-end">
<Button
onClick={() => {
fundX402.reset();
setFundingBountyId(null);
fetchBounties();
}}>
Close
</Button>
</div>
</div>
</ModalShell>
)}
{fundingBountyId && fundX402.state.phase === 'error' && (
<ModalShell
title="Fund Failed"
titleId="bounty-fund-failed-modal-title"
onClose={() => {
fundX402.reset();
setFundingBountyId(null);
}}>
<div className="space-y-3 text-sm">
<p className="text-red-600 dark:text-red-400">{fundX402.state.message}</p>
<div className="flex justify-end">
<Button
onClick={() => {
fundX402.reset();
setFundingBountyId(null);
}}>
Close
</Button>
</div>
</div>
</ModalShell>
)}
<ToastContainer notifications={toasts} onRemove={removeToast} />
</PanelScaffold>
);
@@ -22,7 +22,13 @@ import DirectorySection from './DirectorySection';
vi.mock('../AgentWorldShell', () => ({
apiClient: {
directory: { listAgents: vi.fn() },
follows: { stats: vi.fn(), followers: vi.fn(), follow: vi.fn(), unfollow: vi.fn() },
follows: {
stats: vi.fn(),
followers: vi.fn(),
following: vi.fn(),
follow: vi.fn(),
unfollow: vi.fn(),
},
},
}));
@@ -31,7 +37,7 @@ vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() }));
const listAgents = vi.mocked(apiClient.directory.listAgents);
const walletStatus = vi.mocked(fetchWalletStatus);
const followStats = vi.mocked(apiClient.follows.stats);
const followFollowers = vi.mocked(apiClient.follows.followers);
const followFollowing = vi.mocked(apiClient.follows.following);
const followFollow = vi.mocked(apiClient.follows.follow);
const followUnfollow = vi.mocked(apiClient.follows.unfollow);
@@ -41,9 +47,9 @@ beforeEach(() => {
walletStatus.mockResolvedValue({
accounts: [{ chain: 'solana', address: 'MyWaLLetAddr123' }],
} as unknown as Awaited<ReturnType<typeof fetchWalletStatus>>);
// Default: stats and followers return empty/zero.
// Default: stats return zero and we follow nobody (single batched lookup).
followStats.mockResolvedValue({ agentId: '', followerCount: 0, followingCount: 0 });
followFollowers.mockResolvedValue({ followers: [] });
followFollowing.mockResolvedValue({ following: [] });
});
// ── Loading state ──────────────────────────────────────────────────────────────
@@ -329,7 +335,7 @@ describe('follow button', () => {
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'other-agent-001', username: 'alice', name: 'Alice' }],
});
followFollowers.mockResolvedValueOnce({ followers: [] });
followFollowing.mockResolvedValueOnce({ following: [] });
followStats.mockResolvedValueOnce({
agentId: 'other-agent-001',
followerCount: 5,
@@ -347,8 +353,8 @@ describe('follow button', () => {
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'other-agent-002', username: 'bob', name: 'Bob' }],
});
followFollowers.mockResolvedValueOnce({
followers: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-002', createdAt: '' }],
followFollowing.mockResolvedValueOnce({
following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-002', createdAt: '' }],
});
followStats.mockResolvedValueOnce({
agentId: 'other-agent-002',
@@ -375,7 +381,7 @@ describe('follow button', () => {
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'other-agent-003', username: 'carol', name: 'Carol' }],
});
followFollowers.mockResolvedValueOnce({ followers: [] });
followFollowing.mockResolvedValueOnce({ following: [] });
followStats.mockResolvedValueOnce({
agentId: 'other-agent-003',
followerCount: 0,
@@ -393,13 +399,36 @@ describe('follow button', () => {
expect(await screen.findByText('Following')).toBeInTheDocument();
});
test('fetches the following-set once for the whole directory (no per-card N+1)', async () => {
listAgents.mockResolvedValueOnce({
agents: [
{ agentId: 'other-agent-a', username: 'a', name: 'A' },
{ agentId: 'other-agent-b', username: 'b', name: 'B' },
{ agentId: 'other-agent-c', username: 'c', name: 'C' },
],
});
followFollowing.mockResolvedValueOnce({
following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-b', createdAt: '' }],
});
render(<DirectorySection />);
// Card B resolves to Following from the single batched lookup.
expect(await screen.findByText('Following')).toBeInTheDocument();
// One batched following lookup regardless of card count; the old per-card
// followers lookup must not fire at all.
expect(followFollowing).toHaveBeenCalledTimes(1);
expect(followFollowing).toHaveBeenCalledWith('MyWaLLetAddr123', expect.anything());
expect(apiClient.follows.followers).not.toHaveBeenCalled();
});
test('clicking Following calls follows.unfollow and reverts to Follow', async () => {
const user = userEvent.setup();
listAgents.mockResolvedValueOnce({
agents: [{ agentId: 'other-agent-004', username: 'dave', name: 'Dave' }],
});
followFollowers.mockResolvedValueOnce({
followers: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-004', createdAt: '' }],
followFollowing.mockResolvedValueOnce({
following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-004', createdAt: '' }],
});
followStats.mockResolvedValueOnce({
agentId: 'other-agent-004',
+81 -27
View File
@@ -124,6 +124,48 @@ function useMyAgentId(): string | null {
return agentId;
}
// Max followees pulled in the single batch lookup below. The directory rarely
// exceeds this; anything beyond it falls back to "not following" (the user can
// still follow, which corrects the state optimistically).
const FOLLOWING_FETCH_LIMIT = 500;
/**
* Fetch the current agent's *following* list ONCE and expose it as a Set of
* followee ids. This replaces the previous per-card `follows.followers()` call
* (one request per directory card → N+1 / rate-limit pressure) with a single
* request, letting each card derive its follow-state locally.
*
* Returns `null` until myAgentId is known and the fetch resolves, so cards can
* distinguish "still loading" from "not following".
*/
function useMyFollowing(myAgentId: string | null): Set<string> | null {
const [following, setFollowing] = useState<Set<string> | null>(null);
useEffect(() => {
if (!myAgentId) return;
let cancelled = false;
debug('[tinyplace][ui] DirectorySection: fetching following-set for %s', myAgentId);
void apiClient.follows
.following(myAgentId, { limit: FOLLOWING_FETCH_LIMIT })
.then(res => {
if (cancelled) return;
const set = new Set(res.following.map(f => f.followee));
debug('[tinyplace][ui] DirectorySection: following-set size=%d', set.size);
setFollowing(set);
})
.catch((err: unknown) => {
if (cancelled) return;
// Treat a failed lookup as "follow nobody" so the UI still renders
// Follow buttons instead of getting stuck in the loading state.
debug('[tinyplace][ui] DirectorySection: following-set failed: %s', String(err));
setFollowing(new Set());
});
return () => {
cancelled = true;
};
}, [myAgentId]);
return following;
}
// ── Sub-components ────────────────────────────────────────────────────────────
const CARD_CLASS =
@@ -151,18 +193,42 @@ function LoadingSkeleton() {
);
}
function AgentCardItem({ agent, myAgentId }: { agent: AgentCard; myAgentId: string | null }) {
function AgentCardItem({
agent,
myAgentId,
followingSet,
}: {
agent: AgentCard;
myAgentId: string | null;
// Set of followee ids the current agent follows; null while still loading.
// Sourced once by the parent (see useMyFollowing) instead of per-card.
followingSet: Set<string> | null;
}) {
const [selected, setSelected] = useState(false);
const [followState, setFollowState] = useState<'unknown' | 'following' | 'not_following'>(
'unknown'
);
// Optimistic override applied after the user (un)follows from this card; null
// means "defer to followingSet". Avoids a re-fetch just to reflect our click.
const [localFollow, setLocalFollow] = useState<'following' | 'not_following' | null>(null);
const [followerCount, setFollowerCount] = useState<number | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const handle = getHandle(agent);
const skills = getSkills(agent);
const isSelf = myAgentId != null && agent.agentId === myAgentId;
// Follow-state derived from the batched following-set (or our optimistic
// override). 'unknown' = still loading, which hides the Follow button.
const followState: 'unknown' | 'following' | 'not_following' =
localFollow ??
(followingSet == null
? 'unknown'
: followingSet.has(agent.agentId)
? 'following'
: 'not_following');
// Fetch follow stats on mount.
// NOTE: follower COUNT is still one request per card — `directory.listAgents`
// doesn't return counts. Eliminating these needs a backend/GraphQL directory
// query that embeds followerCount (tracked separately); the per-card follower
// *list* lookup has already been removed in favour of the batched set above.
useEffect(() => {
let cancelled = false;
void apiClient.follows
@@ -179,25 +245,6 @@ function AgentCardItem({ agent, myAgentId }: { agent: AgentCard; myAgentId: stri
};
}, [agent.agentId]);
// Check if we are following this agent.
useEffect(() => {
if (!myAgentId || isSelf) return;
let cancelled = false;
void apiClient.follows
.followers(agent.agentId, { limit: 100 })
.then(res => {
if (cancelled) return;
const isFollowing = res.followers.some(f => f.follower === myAgentId);
setFollowState(isFollowing ? 'following' : 'not_following');
})
.catch(() => {
if (!cancelled) setFollowState('not_following');
});
return () => {
cancelled = true;
};
}, [agent.agentId, myAgentId, isSelf]);
const handleFollow = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -206,12 +253,12 @@ function AgentCardItem({ agent, myAgentId }: { agent: AgentCard; myAgentId: stri
try {
if (followState === 'following') {
await apiClient.follows.unfollow(agent.agentId);
setFollowState('not_following');
setFollowerCount(c => (c != null ? c - 1 : c));
setLocalFollow('not_following');
setFollowerCount(c => (c != null ? Math.max(0, c - 1) : c));
debug('unfollowed %s', agent.agentId);
} else {
await apiClient.follows.follow(agent.agentId);
setFollowState('following');
setLocalFollow('following');
setFollowerCount(c => (c != null ? c + 1 : c));
debug('followed %s', agent.agentId);
}
@@ -309,6 +356,8 @@ function StatusBlock({ tone, title, body }: { tone: string; title: string; body?
export default function DirectorySection() {
const state = useDirectoryAgents();
const myAgentId = useMyAgentId();
// One batched lookup of who we follow, shared by every card below.
const followingSet = useMyFollowing(myAgentId);
let body: React.ReactNode;
@@ -351,7 +400,12 @@ export default function DirectorySection() {
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{agents.map(agent => (
<AgentCardItem key={agent.agentId} agent={agent} myAgentId={myAgentId} />
<AgentCardItem
key={agent.agentId}
agent={agent}
myAgentId={myAgentId}
followingSet={followingSet}
/>
))}
</div>
);
+31 -137
View File
@@ -226,92 +226,6 @@ describe('Feed list', () => {
});
});
// ── Post detail drill-down ────────────────────────────────────────────────────
describe('Post detail drill-down', () => {
test('clicking a post card loads the post detail', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
await user.click(screen.getByText('Hello from the network'));
expect(vi.mocked(apiClient.graphql.post)).toHaveBeenCalledWith(
samplePost.author.handle,
samplePost.postId,
expect.objectContaining({ commentLimit: 20, likerLimit: 10 })
);
await waitFor(() => {
expect(screen.getByText('Great post!')).toBeInTheDocument();
});
expect(screen.getByText('Agent Beta')).toBeInTheDocument();
});
test('back button returns to the feed list', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
await user.click(screen.getByText('Hello from the network'));
await waitFor(() => {
expect(screen.getByText(/back to feed/i)).toBeInTheDocument();
});
await user.click(screen.getByText(/back to feed/i));
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
expect(screen.queryByText(/back to feed/i)).not.toBeInTheDocument();
});
test('shows empty comments and likers messages when post has none', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
vi.mocked(apiClient.graphql.post).mockResolvedValue({
...samplePost,
comments: [],
likers: [],
});
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
await user.click(screen.getByText('Hello from the network'));
await waitFor(() => {
expect(screen.getByText(/no comments yet/i)).toBeInTheDocument();
expect(screen.getByText(/no likes yet/i)).toBeInTheDocument();
});
});
test('shows error message when post detail fetch fails', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
vi.mocked(apiClient.graphql.post).mockRejectedValue(new Error('fetch failed'));
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
await user.click(screen.getByText('Hello from the network'));
await waitFor(() => {
expect(screen.getByText(/failed to load post details/i)).toBeInTheDocument();
});
});
});
// ── Follow/Unfollow ───────────────────────────────────────────────────────────
describe('Follow/Unfollow', () => {
@@ -500,14 +414,16 @@ describe('like toggle', () => {
// ── Comment composer ──────────────────────────────────────────────────────────
describe('comment composer', () => {
test('submitting comment calls feeds.addComment then refetches post detail', async () => {
// Comments expand inline under each post card (the comment-count toggle), then
// InlineComments fetches the thread via graphql.post.
test('submitting comment calls feeds.addComment then refetches comments', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
await user.click(screen.getByText('Hello from the network'));
await user.click(screen.getByRole('button', { name: '3 comments' }));
await waitFor(() => {
expect(screen.getByPlaceholderText(/write a comment/i)).toBeInTheDocument();
});
@@ -520,7 +436,7 @@ describe('comment composer', () => {
'My test comment'
);
});
// Refetch post detail after comment
// graphql.post: once on expand + once on refetch after comment.
expect(vi.mocked(apiClient.graphql.post)).toHaveBeenCalledTimes(2);
});
@@ -531,11 +447,8 @@ describe('comment composer', () => {
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
await user.click(screen.getByText('Hello from the network'));
await waitFor(() => {
expect(screen.getByPlaceholderText(/write a comment/i)).toBeInTheDocument();
});
const input = screen.getByPlaceholderText(/write a comment/i);
await user.click(screen.getByRole('button', { name: '3 comments' }));
const input = await screen.findByPlaceholderText(/write a comment/i);
await user.type(input, 'test');
await user.click(screen.getByRole('button', { name: /^comment$/i }));
await waitFor(() => {
@@ -544,15 +457,17 @@ describe('comment composer', () => {
});
test('comment composer hidden when wallet locked', async () => {
const user = userEvent.setup();
vi.mocked(fetchWalletStatus).mockRejectedValue(new Error('wallet locked'));
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
await userEvent.setup().click(screen.getByText('Hello from the network'));
// Expand comments — the composer must stay hidden with no agent id.
await user.click(screen.getByRole('button', { name: '3 comments' }));
await waitFor(() => {
expect(screen.getByText(/back to feed/i)).toBeInTheDocument();
expect(vi.mocked(apiClient.graphql.post)).toHaveBeenCalled();
});
expect(screen.queryByPlaceholderText(/write a comment/i)).not.toBeInTheDocument();
});
@@ -561,27 +476,20 @@ describe('comment composer', () => {
// ── Post composer ─────────────────────────────────────────────────────────────
describe('post composer', () => {
test('New Post button appears when wallet unlocked and feed loaded', async () => {
test('inline composer appears when wallet unlocked and feed loaded', async () => {
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument();
});
});
test('new post button opens modal and submitting calls feeds.createPost', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /new post/i }));
// Modal should appear
await waitFor(() => {
expect(screen.getByPlaceholderText(/what's on your mind/i)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(/what's on your mind/i), 'My new post');
});
test('typing and clicking Post calls feeds.createPost', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
const textarea = await screen.findByPlaceholderText(/what's on your mind/i);
await user.type(textarea, 'My new post');
await user.click(screen.getByRole('button', { name: /^post$/i }));
await waitFor(() => {
expect(vi.mocked(apiClient.feeds.createPost)).toHaveBeenCalledWith('My new post');
@@ -592,14 +500,8 @@ describe('post composer', () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /new post/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText(/what's on your mind/i)).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText(/what's on your mind/i), 'test post');
const textarea = await screen.findByPlaceholderText(/what's on your mind/i);
await user.type(textarea, 'test post');
await user.click(screen.getByRole('button', { name: /^post$/i }));
await waitFor(() => {
// homeFeed called once on mount + once after create
@@ -607,32 +509,24 @@ describe('post composer', () => {
});
});
test('cancel closes modal without posting', async () => {
test('Post button is disabled until a draft is entered', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /new post/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText(/what's on your mind/i)).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(screen.queryByPlaceholderText(/what's on your mind/i)).not.toBeInTheDocument();
});
expect(vi.mocked(apiClient.feeds.createPost)).not.toHaveBeenCalled();
const textarea = await screen.findByPlaceholderText(/what's on your mind/i);
expect(screen.getByRole('button', { name: /^post$/i })).toBeDisabled();
await user.type(textarea, 'hi');
expect(screen.getByRole('button', { name: /^post$/i })).toBeEnabled();
});
test('new post button hidden when wallet locked', async () => {
test('composer hidden when wallet locked', async () => {
vi.mocked(fetchWalletStatus).mockRejectedValue(new Error('wallet locked'));
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /new post/i })).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText(/what's on your mind/i)).not.toBeInTheDocument();
});
});
@@ -709,11 +603,11 @@ describe('delete actions', () => {
await waitFor(() => {
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
});
await user.click(screen.getByText('Hello from the network'));
// Expand the inline comment thread, then delete own comment.
await user.click(screen.getByRole('button', { name: '3 comments' }));
await waitFor(() => {
expect(screen.getByText('Great post!')).toBeInTheDocument();
});
// Delete button for own comment (use getByText to avoid matching parent wrappers)
const deleteBtn = screen.getByText('Delete');
await user.click(deleteBtn);
await waitFor(() => {
+169 -334
View File
@@ -9,21 +9,19 @@
* Phase A interactive features (wallet-gated):
* - Like / unlike toggle with optimistic update and server reconcile
* - Comment composer (adds comment, refetches detail via GraphQL)
* - New Post composer (ModalShell, refetches feed on success)
* - Inline post composer at the top of the feed (refetches feed on success)
* - Delete post / delete comment (own content only, with window.confirm)
*
* Pattern mirrors ExploreSection / MarketplaceSection: useState + useEffect
* fetch, PanelScaffold wrapper, StatusBlock for loading/error/empty states.
*/
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import PanelScaffold from '../../components/layout/PanelScaffold';
import { ModalShell } from '../../components/ui/ModalShell';
import {
type GqlComment,
type GqlHomeFeedItem,
type GqlPost,
type GqlPostDetail,
type LikeResult,
PaymentRequiredError,
} from '../../lib/agentworld/invokeApiClient';
@@ -38,11 +36,6 @@ type FeedState =
| { status: 'error'; message: string }
| { status: 'ok'; items: GqlHomeFeedItem[] };
type DetailState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ok'; detail: GqlPostDetail };
// ── Helpers ───────────────────────────────────────────────────────────────────
function relativeTime(iso: string): string {
@@ -155,26 +148,48 @@ function CommentComposer({
);
}
// ── PostComposerModal ─────────────────────────────────────────────────────────
// ── FeedComposer ──────────────────────────────────────────────────────────────
function PostComposerModal({
onClose,
onPostCreated,
}: {
onClose: () => void;
/** Max post length, mirrors the tiny.place website composer. */
const MAX_FEED_BODY_LENGTH = 500;
/**
* Always-visible inline composer at the top of the feed (replaces the old
* "New Post" modal) — matches the tiny.place website's home-feed composer:
* avatar + textarea + live character countdown + Post button.
*/
interface FeedComposerProps {
myAgentId: string;
onPostCreated: () => void;
}) {
const [body, setBody] = useState('');
}
function FeedComposer({ myAgentId, onPostCreated }: FeedComposerProps) {
const [draft, setDraft] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const remaining = MAX_FEED_BODY_LENGTH - draft.length;
const canPost = draft.trim().length > 0 && !submitting;
const nearLimit = remaining <= 40;
const handleSubmit = async () => {
if (!body.trim() || submitting) return;
// Auto-grow the textarea with its content (capped), so the composer expands
// naturally instead of scrolling inside two fixed rows.
const autoSize = (el: HTMLTextAreaElement) => {
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
};
const submit = async () => {
const body = draft.trim().slice(0, MAX_FEED_BODY_LENGTH);
if (!body || submitting) return;
setSubmitting(true);
setError(null);
try {
await apiClient.feeds.createPost(body.trim());
onClose();
await apiClient.feeds.createPost(body);
setDraft('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
onPostCreated();
} catch (err) {
setError(String(err));
@@ -184,48 +199,129 @@ function PostComposerModal({
};
return (
<ModalShell title="New Post" titleId="new-post-modal-title" onClose={onClose}>
<div className="space-y-3">
<div className="mb-3 rounded-xl border border-stone-200 bg-white p-3 dark:border-neutral-800 dark:bg-neutral-900">
<div className="flex gap-2.5">
<InitialAvatar name={myAgentId} />
<textarea
value={body}
onChange={e => setBody(e.target.value)}
ref={textareaRef}
value={draft}
onChange={e => {
setDraft(e.target.value);
autoSize(e.target);
}}
onKeyDown={e => {
// ⌘/Ctrl+Enter posts without reaching for the mouse.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void submit();
}
}}
placeholder="What's on your mind?"
rows={4}
rows={1}
maxLength={MAX_FEED_BODY_LENGTH}
disabled={submitting}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm
placeholder:text-stone-400 focus:border-primary-400 focus:outline-none
dark:border-neutral-700 dark:bg-neutral-800 dark:placeholder:text-neutral-500
dark:focus:border-primary-600 disabled:opacity-50"
aria-label="Write a post"
className="min-h-[2.25rem] w-full resize-none border-0 bg-transparent p-0 pt-1.5 text-sm leading-relaxed text-stone-900 shadow-none outline-none ring-0 placeholder:text-stone-400 focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none disabled:opacity-50 dark:text-neutral-100 dark:placeholder:text-neutral-500"
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
</div>
{error && <p className="mt-1 pl-[2.625rem] text-xs text-coral-500">{error}</p>}
<div className="mt-2 flex items-center justify-between gap-3 border-t border-stone-100 pl-[2.625rem] pt-2 dark:border-neutral-800">
<span className="hidden text-[11px] text-stone-400 dark:text-neutral-500 sm:inline">
<kbd className="rounded border border-stone-200 px-1 font-sans dark:border-neutral-700">
</kbd>
<kbd className="ml-0.5 rounded border border-stone-200 px-1 font-sans dark:border-neutral-700">
</kbd>{' '}
to post
</span>
<div className="ml-auto flex items-center gap-3">
{(nearLimit || draft.length > 0) && (
<span
className={`text-[11px] tabular-nums ${
remaining <= 20
? 'font-medium text-coral-500'
: 'text-stone-400 dark:text-neutral-500'
}`}>
{remaining}
</span>
)}
<button
type="button"
onClick={onClose}
className="rounded-lg border border-stone-300 px-4 py-2 text-sm font-medium
text-stone-700 hover:bg-stone-50 dark:border-neutral-600
dark:text-neutral-300 dark:hover:bg-neutral-800">
Cancel
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={!body.trim() || submitting}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white
hover:bg-primary-600 disabled:opacity-50 dark:bg-primary-600 dark:hover:bg-primary-500">
{submitting ? 'Posting...' : 'Post'}
onClick={() => void submit()}
disabled={!canPost}
className="rounded-full bg-primary-500 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:opacity-40 dark:bg-primary-600 dark:hover:bg-primary-500">
{submitting ? 'Posting…' : 'Post'}
</button>
</div>
</div>
</ModalShell>
</div>
);
}
// ── PostCard ──────────────────────────────────────────────────────────────────
/**
* Inline comment thread — fetched on demand when a post's comment toggle is
* opened. Mirrors the tiny.place website's in-card `CommentList` (replaces the
* old full-page drill-down).
*/
function InlineComments({ post, myAgentId }: { post: GqlPost; myAgentId: string | null }) {
const handle = post.author.handle;
const [comments, setComments] = useState<GqlComment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
void apiClient.graphql
.post(handle, post.postId, {
commentLimit: 50,
likerLimit: 0,
viewer: myAgentId ?? undefined,
})
.then(detail => {
setComments(detail?.comments ?? []);
setError(detail ? null : 'Post not found.');
})
.catch(err => setError(String(err)))
.finally(() => setLoading(false));
}, [handle, post.postId, myAgentId]);
useEffect(() => {
load();
}, [load]);
return (
<div className="mt-3 border-t border-stone-100 pt-2 dark:border-neutral-800">
{loading && (
<p className="animate-pulse py-2 text-xs text-stone-400 dark:text-neutral-500">
Loading comments
</p>
)}
{error && <p className="py-2 text-xs text-red-500">{error}</p>}
{!loading && !error && comments.length === 0 && (
<p className="py-2 text-xs text-stone-400 dark:text-neutral-500">No comments yet.</p>
)}
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
{comments.map(c => (
<CommentRow
key={c.commentId}
comment={c}
myAgentId={myAgentId}
handle={handle}
postId={post.postId}
onCommentDeleted={load}
/>
))}
</div>
{myAgentId && <CommentComposer handle={handle} postId={post.postId} onCommentAdded={load} />}
</div>
);
}
function PostCard({
item,
onClick,
myAgentId,
followState,
followLoading,
@@ -235,7 +331,6 @@ function PostCard({
onDeletePost,
}: {
item: GqlHomeFeedItem;
onClick: (post: GqlPost) => void;
myAgentId: string | null;
followState: Record<string, boolean>;
followLoading: Record<string, boolean>;
@@ -245,13 +340,10 @@ function PostCard({
onDeletePost: (post: GqlPost) => void;
}) {
const { post } = item;
const truncated = post.body.length > 300 ? post.body.slice(0, 300) + '…' : post.body;
const [showComments, setShowComments] = useState(false);
return (
<button
type="button"
onClick={() => onClick(post)}
className="w-full rounded-lg border border-stone-200 bg-white p-4 text-left transition-colors hover:border-primary-300 hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-primary-700 dark:hover:bg-neutral-800">
<article className="rounded-lg border border-stone-200 bg-white p-4 transition-colors hover:border-stone-300 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700">
{/* Author row */}
<div className="mb-2 flex items-center gap-2">
{post.author.avatarUrl ? (
@@ -285,29 +377,23 @@ function PostCard({
@{post.author.handle}
</span>
</div>
{myAgentId && item.post.author.cryptoId !== myAgentId && (
{myAgentId && post.author.cryptoId !== myAgentId && (
<button
type="button"
disabled={followLoading[item.post.author.cryptoId] ?? false}
onClick={e => {
e.stopPropagation();
onToggleFollow(item.post.author.cryptoId);
}}
disabled={followLoading[post.author.cryptoId] ?? false}
onClick={() => onToggleFollow(post.author.cryptoId)}
className={`ml-auto shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
followState[item.post.author.cryptoId]
followState[post.author.cryptoId]
? 'border-stone-300 text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800'
: 'border-primary-600 bg-primary-600 text-white hover:bg-primary-700 dark:border-primary-500 dark:bg-primary-500'
}`}>
{followState[item.post.author.cryptoId] ? 'Following' : 'Follow'}
{followState[post.author.cryptoId] ? 'Following' : 'Follow'}
</button>
)}
{myAgentId && post.author.cryptoId === myAgentId && (
<button
type="button"
onClick={e => {
e.stopPropagation();
onDeletePost(post);
}}
onClick={() => onDeletePost(post)}
className="ml-auto text-xs text-stone-400 hover:text-red-500 dark:text-neutral-500
dark:hover:text-red-400">
Delete
@@ -316,23 +402,28 @@ function PostCard({
</div>
{/* Post body */}
<p className="mb-3 text-sm leading-relaxed text-stone-800 dark:text-neutral-200">
{truncated}
<p className="mb-3 whitespace-pre-wrap text-sm leading-relaxed text-stone-800 dark:text-neutral-200">
{post.body}
</p>
{/* Metadata row */}
<div className="flex items-center gap-4 text-xs text-stone-400 dark:text-neutral-500">
<span>{relativeTime(post.createdAt)}</span>
<span>
{item.reason === 'recommended' && (
<span className="rounded-full bg-primary-50 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-300">
Recommended
</span>
)}
<button
type="button"
onClick={() => setShowComments(open => !open)}
className="hover:text-stone-600 dark:hover:text-neutral-300">
{post.commentCount} {post.commentCount === 1 ? 'comment' : 'comments'}
</span>
</button>
{myAgentId ? (
<button
type="button"
onClick={e => {
e.stopPropagation();
onToggleLike(post);
}}
onClick={() => onToggleLike(post)}
className={`flex items-center gap-1 ${
(likeState[post.postId]?.liked ?? post.viewerHasLiked)
? 'text-red-500'
@@ -353,11 +444,13 @@ function PostCard({
</span>
)}
</div>
</button>
{showComments && <InlineComments post={post} myAgentId={myAgentId} />}
</article>
);
}
// ── PostDetail ────────────────────────────────────────────────────────────────
// ── CommentRow ────────────────────────────────────────────────────────────────
function CommentRow({
comment,
@@ -414,202 +507,13 @@ function CommentRow({
);
}
function PostDetail({
post,
detailState,
setDetailState,
onBack,
myAgentId,
likeState,
onToggleLike,
}: {
post: GqlPost;
detailState: DetailState;
setDetailState: (s: DetailState) => void;
onBack: () => void;
myAgentId: string | null;
likeState: Record<string, { liked: boolean; count: number }>;
onToggleLike: (post: GqlPost) => void;
}) {
const refetchDetail = () => {
void apiClient.graphql
.post(post.author.handle, post.postId, {
commentLimit: 20,
likerLimit: 10,
viewer: myAgentId ?? undefined,
})
.then(detail => {
if (detail) setDetailState({ status: 'ok', detail });
})
.catch(err => console.error('[FeedSection] refetch detail failed:', err));
};
return (
<div className="space-y-4">
{/* Back button */}
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to feed
</button>
{/* Post body */}
<div className="rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<div className="mb-3 flex items-center gap-2">
{post.author.avatarUrl ? (
<img
src={post.author.avatarUrl}
alt={post.author.displayName}
className="h-9 w-9 rounded-full object-cover"
/>
) : (
<InitialAvatar name={post.author.displayName || post.author.handle} />
)}
<div>
<div className="flex items-center gap-1">
<span className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{post.author.displayName || post.author.handle}
</span>
{post.author.verified && (
<svg
className="h-3.5 w-3.5 text-primary-500"
fill="currentColor"
viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<span className="text-xs text-stone-400 dark:text-neutral-500">
@{post.author.handle} · {relativeTime(post.createdAt)}
</span>
</div>
</div>
<p className="text-sm leading-relaxed text-stone-800 dark:text-neutral-200">{post.body}</p>
<div className="mt-3 flex items-center gap-4 text-xs text-stone-400 dark:text-neutral-500">
<span>
{post.commentCount} {post.commentCount === 1 ? 'comment' : 'comments'}
</span>
{myAgentId ? (
<button
type="button"
onClick={() => onToggleLike(post)}
className={`flex items-center gap-1 ${
(likeState[post.postId]?.liked ?? post.viewerHasLiked)
? 'text-red-500'
: 'text-stone-400 dark:text-neutral-500 hover:text-red-400'
}`}>
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
clipRule="evenodd"
/>
</svg>
{likeState[post.postId]?.count ?? post.likeCount}
</button>
) : (
<span>
{post.likeCount} {post.likeCount === 1 ? 'like' : 'likes'}
</span>
)}
</div>
</div>
{/* Detail content */}
{detailState.status === 'loading' && (
<div className="flex h-32 items-center justify-center text-stone-400 dark:text-neutral-500">
<span className="animate-pulse text-sm">Loading post</span>
</div>
)}
{detailState.status === 'error' && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400">
Failed to load post details: {detailState.message}
</div>
)}
{detailState.status === 'ok' && (
<>
{/* Comments */}
<div>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
Comments
</h3>
<div className="divide-y divide-stone-100 rounded-lg border border-stone-200 bg-white px-4 dark:divide-neutral-800 dark:border-neutral-800 dark:bg-neutral-900">
{detailState.detail.comments.length === 0 ? (
<p className="py-6 text-center text-sm text-stone-400 dark:text-neutral-500">
No comments yet
</p>
) : (
detailState.detail.comments.map(c => (
<CommentRow
key={c.commentId}
comment={c}
myAgentId={myAgentId}
handle={post.author.handle}
postId={post.postId}
onCommentDeleted={refetchDetail}
/>
))
)}
</div>
{/* Comment composer */}
{myAgentId && (
<CommentComposer
handle={post.author.handle}
postId={post.postId}
onCommentAdded={refetchDetail}
/>
)}
</div>
{/* Likers */}
<div>
<h3 className="mb-1 text-xs font-semibond uppercase tracking-wider text-stone-500 dark:text-neutral-400">
Liked by
</h3>
<div className="rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
{detailState.detail.likers.length === 0 ? (
<p className="text-center text-sm text-stone-400 dark:text-neutral-500">
No likes yet
</p>
) : (
<div className="flex flex-wrap gap-2">
{detailState.detail.likers.map(l => (
<span
key={`${l.postId}-${l.actor.cryptoId}`}
className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-700 dark:bg-neutral-800 dark:text-neutral-300">
{l.actor.displayName || l.actor.handle}
</span>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
);
}
// ── FeedSection (main export) ─────────────────────────────────────────────────
export default function FeedSection() {
const [feedState, setFeedState] = useState<FeedState>({ status: 'loading' });
const [selectedPost, setSelectedPost] = useState<GqlPost | null>(null);
const [detailState, setDetailState] = useState<DetailState>({ status: 'loading' });
const [followState, setFollowState] = useState<Record<string, boolean>>({});
const [followLoading, setFollowLoading] = useState<Record<string, boolean>>({});
const [likeState, setLikeState] = useState<Record<string, { liked: boolean; count: number }>>({});
const [showComposer, setShowComposer] = useState(false);
const myAgentId = useMyAgentId();
@@ -663,37 +567,6 @@ export default function FeedSection() {
};
}, []);
// ── Fetch post detail when a post is selected ──────────────────────────────
useEffect(() => {
if (!selectedPost) return;
let cancelled = false;
setDetailState({ status: 'loading' });
void apiClient.graphql
.post(selectedPost.author.handle, selectedPost.postId, {
commentLimit: 20,
likerLimit: 10,
viewer: myAgentId ?? undefined,
})
.then(detail => {
if (cancelled) return;
if (detail) {
setDetailState({ status: 'ok', detail });
} else {
setDetailState({ status: 'error', message: 'Post not found.' });
}
})
.catch((err: unknown) => {
if (cancelled) return;
setDetailState({ status: 'error', message: String(err) });
});
return () => {
cancelled = true;
};
}, [selectedPost, myAgentId]);
// ── Follow / Unfollow ──────────────────────────────────────────────────────
const handleToggleFollow = async (cryptoId: string) => {
@@ -769,26 +642,6 @@ export default function FeedSection() {
// ── Render ─────────────────────────────────────────────────────────────────
// Post detail drill-down view
if (selectedPost) {
return (
<PanelScaffold description="Social feed">
<PostDetail
post={selectedPost}
detailState={detailState}
setDetailState={setDetailState}
onBack={() => setSelectedPost(null)}
myAgentId={myAgentId}
likeState={likeState}
onToggleLike={post => {
void handleToggleLike(post);
}}
/>
</PanelScaffold>
);
}
// Feed list view
let body: React.ReactNode;
if (feedState.status === 'loading') {
@@ -834,7 +687,6 @@ export default function FeedSection() {
<PostCard
key={item.post.postId}
item={item}
onClick={setSelectedPost}
myAgentId={myAgentId}
followState={followState}
followLoading={followLoading}
@@ -855,26 +707,9 @@ export default function FeedSection() {
return (
<PanelScaffold description="Social feed">
{myAgentId && feedState.status === 'ok' && (
<div className="mb-3 flex justify-end">
<button
type="button"
onClick={() => setShowComposer(true)}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white
hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500">
New Post
</button>
</div>
<FeedComposer myAgentId={myAgentId} onPostCreated={refetchFeed} />
)}
{body}
{showComposer && (
<PostComposerModal
onClose={() => setShowComposer(false)}
onPostCreated={() => {
setShowComposer(false);
refetchFeed();
}}
/>
)}
</PanelScaffold>
);
}
@@ -239,7 +239,7 @@ describe('Register tab — x402 registration', () => {
expect(screen.getByTestId('x402-confirm')).toBeEnabled();
});
test('insufficient balance disables the confirm button', async () => {
test('insufficient balance swaps Pay for an Add-funds redirect', async () => {
vi.mocked(apiClient.registry.register).mockResolvedValueOnce({
challenge: { amount: FREE_AMOUNT, asset: 'USDC', network: 'solana-devnet' },
walletBalance: { raw: '1000000', formatted: '1', decimals: 6, assetSymbol: 'USDC' },
@@ -249,7 +249,9 @@ describe('Register tab — x402 registration', () => {
await checkAndRegister('buyer');
expect(await screen.findByTestId('x402-insufficient')).toBeInTheDocument();
expect(screen.getByTestId('x402-confirm')).toBeDisabled();
// The disabled Pay button is replaced by an Add-funds action.
expect(screen.queryByTestId('x402-confirm')).not.toBeInTheDocument();
expect(screen.getByTestId('x402-add-funds')).toBeInTheDocument();
});
test('confirmed:true success renders the registered identity + explorer link', async () => {
@@ -13,7 +13,12 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { type GqlLedgerTransaction } from '../../lib/agentworld/invokeApiClient';
import { apiClient } from '../AgentWorldShell';
import LedgerSection, { abbreviateAddress, formatAmount, StatusBadge } from './LedgerSection';
import LedgerSection, {
abbreviateAddress,
formatAmount,
formatLedgerAmount,
StatusBadge,
} from './LedgerSection';
vi.mock('../AgentWorldShell', () => ({
apiClient: { graphql: { ledgerTransactions: vi.fn(), ledgerTransaction: vi.fn() } },
@@ -27,7 +32,8 @@ const sampleTransaction: GqlLedgerTransaction = {
type: 'REGISTRATION',
from: 'AAAA1111bbbb2222cccc3333dddd4444eeee5555',
to: 'FFFF6666gggg7777hhhh8888iiii9999jjjj0000',
amount: '0.50',
// Smallest base unit (USDC has 6 decimals): 500000 micro-USDC = 0.50 USDC.
amount: '500000',
asset: 'USDC',
network: 'solana-devnet',
timestamp: '2026-06-01T12:00:00Z',
@@ -71,7 +77,8 @@ describe('Ledger list', () => {
await waitFor(() => {
expect(screen.getByText('REGISTRATION')).toBeInTheDocument();
});
expect(screen.getByText('0.50 USDC')).toBeInTheDocument();
// 500000 micro-USDC scaled to display units → 0.5 USDC (trailing zero trimmed).
expect(screen.getByText('0.5 USDC')).toBeInTheDocument();
expect(screen.getByText('SETTLED')).toBeInTheDocument();
expect(screen.getByText('View on chain')).toBeInTheDocument();
// Network shown as a friendly label, not the raw genesis hash.
@@ -219,3 +226,30 @@ describe('formatAmount', () => {
expect(formatAmount('n/a')).toBe('n/a');
});
});
describe('formatLedgerAmount', () => {
test('scales USDC base units (6 decimals) to display units', () => {
// Regression: amounts were shown raw, reading ~1,000,000× too large.
expect(formatLedgerAmount('1000000', 'USDC')).toBe('1');
expect(formatLedgerAmount('500000', 'USDC')).toBe('0.5');
expect(formatLedgerAmount('123456789', 'USDC')).toBe('123.456789');
});
test('scales SOL base units (9 decimals) and groups the integer part', () => {
expect(formatLedgerAmount('2500000000', 'SOL')).toBe('2.5');
expect(formatLedgerAmount('1000000000000', 'SOL')).toBe('1,000');
});
test('preserves the sign for debits', () => {
expect(formatLedgerAmount('-500000', 'USDC')).toBe('-0.5');
});
test('leaves unknown / zero-decimal assets unscaled', () => {
expect(formatLedgerAmount('42', 'POINTS')).toBe('42');
expect(formatLedgerAmount('1000000', undefined)).toBe('1,000,000');
});
test('passes through empty', () => {
expect(formatLedgerAmount(undefined, 'USDC')).toBe('—');
});
});
+20 -3
View File
@@ -14,7 +14,8 @@ import { useEffect, useState } from 'react';
import PanelScaffold from '../../components/layout/PanelScaffold';
import { type GqlLedgerTransaction } from '../../lib/agentworld/invokeApiClient';
import { apiClient } from '../AgentWorldShell';
import { friendlyNetwork } from '../components/X402ConfirmDialog';
import { decimalsForAsset, resolveAssetSymbol } from '../assets';
import { formatUnits, friendlyNetwork } from '../components/X402ConfirmDialog';
import { explorerTxUrl } from '../hooks/useX402Buy';
// ── State types ───────────────────────────────────────────────────────────────
@@ -59,6 +60,22 @@ export function formatAmount(amount: string | undefined): string {
return negative ? `-${out}` : out;
}
/**
* Ledger amounts arrive in the asset's smallest base unit (e.g. USDC in 1e-6
* micro-units), so they must be scaled to display units before grouping —
* otherwise every value reads ~1,000,000× too large. `asset` may be a symbol or
* a mint address; {@link decimalsForAsset} resolves either.
*/
export function formatLedgerAmount(amount: string | undefined, asset: string | undefined): string {
if (!amount) return formatAmount(amount);
// `formatUnits` assumes an integer base-unit string; if the amount is already
// decimal/non-integer, pass it straight to grouping instead of mis-scaling it.
if (!/^-?\d+$/.test(amount)) return formatAmount(amount);
const decimals = decimalsForAsset(asset);
const display = decimals > 0 ? formatUnits(amount, decimals) : amount;
return formatAmount(display);
}
/** Centered status message for loading / error / info states. */
function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) {
return (
@@ -156,8 +173,8 @@ function TransactionRow({
{/* Line 1: amount + type + status */}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{formatAmount(tx.amount)}
{tx.asset ? ` ${tx.asset}` : ''}
{formatLedgerAmount(tx.amount, tx.asset)}
{tx.asset ? ` ${resolveAssetSymbol(tx.asset)}` : ''}
</span>
<TypeBadge type={tx.type} />
<StatusBadge status={tx.status} />
@@ -10,7 +10,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
import MessagingSection from './MessagingSection';
import MessagingSection, { TABS } from './MessagingSection';
// Typed helpers so vi.mocked() calls are terse below.
// These are resolved after the vi.mock factory runs.
@@ -178,12 +178,10 @@ beforeEach(() => {
// ── DMs panel (E2E enabled) ───────────────────────────────────────────────────
describe('DMs panel (E2E enabled)', () => {
test('renders DM compose UI with peer input when DMs tab is active', async () => {
test('renders DM compose UI with peer input by default (DMs-only surface)', async () => {
render(<MessagingSection />);
const dmsButton = screen.getByRole('button', { name: 'DMs' });
await userEvent.click(dmsButton);
// DMs is the default (and only) visible view now — no tab click needed.
// Should see the peer input, not the "coming soon" placeholder
expect(screen.getByPlaceholderText(/Recipient @handle/)).toBeInTheDocument();
expect(screen.queryByTestId('dms-coming-soon')).not.toBeInTheDocument();
@@ -204,7 +202,6 @@ describe('DMs panel (E2E enabled)', () => {
});
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
// Enter a handle (non-base58) — will be resolved to resolved-crypto-id
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
@@ -237,7 +234,6 @@ describe('DMs panel (E2E enabled)', () => {
});
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
await user.type(screen.getByPlaceholderText(/Recipient @handle/), 'peer123');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -256,7 +252,6 @@ describe('DMs panel (E2E enabled)', () => {
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, 'peerEmpty');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -272,7 +267,6 @@ describe('DMs panel (E2E enabled)', () => {
vi.mocked(apiClient.signal.sendMessage).mockRejectedValueOnce(new Error('encryption failed'));
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, 'peer456');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -310,7 +304,6 @@ describe('DMs panel (E2E enabled)', () => {
const user = userEvent.setup();
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, 'peer789');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -326,7 +319,6 @@ describe('DMs panel (E2E enabled)', () => {
const user = userEvent.setup();
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, 'peer999');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -342,7 +334,6 @@ describe('DMs panel (E2E enabled)', () => {
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, '@alice');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -361,7 +352,6 @@ describe('DMs panel (E2E enabled)', () => {
});
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, '@unknown-user');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -381,7 +371,6 @@ describe('DMs panel (E2E enabled)', () => {
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, 'bob.agent');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -395,7 +384,6 @@ describe('DMs panel (E2E enabled)', () => {
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
// A valid 44-char base58 string — should bypass resolution
await user.type(peerInput, '61KcG5aGLqpnJz2fXyzABCDEFGHJKLMNPQRSTUVWXY');
@@ -414,7 +402,6 @@ describe('DMs panel (E2E enabled)', () => {
);
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, '@broken-handle');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -447,7 +434,6 @@ describe('DMs panel (E2E enabled)', () => {
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, 'stevejobs');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -471,7 +457,6 @@ describe('DMs panel (E2E enabled)', () => {
);
render(<MessagingSection />);
await user.click(screen.getByRole('button', { name: 'DMs' }));
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
await user.type(peerInput, 'ghosthandle');
await user.click(screen.getByRole('button', { name: 'Open DM' }));
@@ -485,7 +470,6 @@ describe('DMs panel (E2E enabled)', () => {
test('renders DM compose UI with updated placeholder text', async () => {
render(<MessagingSection />);
await userEvent.click(screen.getByRole('button', { name: 'DMs' }));
expect(screen.getByPlaceholderText('Recipient @handle or wallet address')).toBeInTheDocument();
});
});
@@ -494,20 +478,20 @@ describe('DMs panel (E2E enabled)', () => {
describe('tab navigation', () => {
test('defaults to Channels tab', () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
const channelsBtn = screen.getByRole('button', { name: 'Channels' });
expect(channelsBtn).toHaveAttribute('data-active', 'true');
});
test('can switch to Groups tab', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
const groupsBtn = screen.getByRole('button', { name: 'Groups' });
await userEvent.click(groupsBtn);
expect(groupsBtn).toHaveAttribute('data-active', 'true');
});
test('can switch to Inbox tab', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
const inboxBtn = screen.getByRole('button', { name: 'Inbox' });
await userEvent.click(inboxBtn);
expect(inboxBtn).toHaveAttribute('data-active', 'true');
@@ -518,25 +502,25 @@ describe('tab navigation', () => {
describe('empty states', () => {
test('shows "No channels found" when channels list is empty', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
// Wait for the async fetch to settle
expect(await screen.findByText(/No channels found/i)).toBeInTheDocument();
});
test('shows "No groups found" when groups list is empty', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
expect(await screen.findByText(/No groups found/i)).toBeInTheDocument();
});
test('shows "No broadcasts found" when broadcasts list is empty', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Broadcasts' }));
expect(await screen.findByText(/No broadcasts found/i)).toBeInTheDocument();
});
test('shows "Your inbox is empty" when inbox is empty', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
expect(await screen.findByText(/Your inbox is empty/i)).toBeInTheDocument();
});
@@ -570,7 +554,7 @@ describe('inbox actions', () => {
});
async function openInbox() {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
await screen.findByText('Hello there');
}
@@ -624,7 +608,7 @@ describe('membership actions', () => {
},
],
});
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await screen.findByText('General');
await userEvent.click(screen.getByRole('button', { name: 'Join' }));
expect(apiClient.channels.join).toHaveBeenCalledWith('ch-1');
@@ -640,7 +624,7 @@ describe('membership actions', () => {
visibility: 'public',
},
]);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Broadcasts' }));
await screen.findByText('Updates');
await userEvent.click(screen.getByRole('button', { name: 'Subscribe' }));
@@ -661,7 +645,7 @@ describe('membership actions', () => {
setWallet('agent-abc');
// Both public and membership queries return the same group so button shows "Leave".
vi.mocked(apiClient.groups.list).mockResolvedValue([group]);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Builders');
await userEvent.click(screen.getByRole('button', { name: 'Leave' }));
@@ -692,7 +676,7 @@ describe('group membership-aware button rendering', () => {
};
async function openGroups() {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Alpha');
}
@@ -741,7 +725,7 @@ describe('group membership-aware button rendering', () => {
vi.mocked(apiClient.groups.join).mockResolvedValue(undefined);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Alpha');
@@ -769,7 +753,7 @@ describe('group membership-aware button rendering', () => {
vi.mocked(apiClient.groups.leave).mockResolvedValue(undefined);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Alpha');
@@ -788,7 +772,7 @@ describe('group membership-aware button rendering', () => {
setWallet('agent-solana-123');
vi.mocked(apiClient.groups.list).mockResolvedValue([]);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText(/No groups found/i);
@@ -820,7 +804,7 @@ describe('group invite management', () => {
});
test('renders "Invites" button on group cards', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
expect(screen.getByRole('button', { name: 'Invites' })).toBeInTheDocument();
@@ -828,7 +812,7 @@ describe('group invite management', () => {
test('clicking "Invites" opens GroupInvitesPanel and calls listInvites', async () => {
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
@@ -847,7 +831,7 @@ describe('group invite management', () => {
maxUses: 10,
},
]);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
@@ -858,7 +842,7 @@ describe('group invite management', () => {
test('Create Invite button calls groups.createInvite', async () => {
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
@@ -877,7 +861,7 @@ describe('group invite management', () => {
uses: 0,
},
]);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
@@ -888,7 +872,7 @@ describe('group invite management', () => {
test('Close button returns to the group list', async () => {
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
@@ -901,13 +885,13 @@ describe('group invite management', () => {
describe('redeem invite', () => {
test('renders "Redeem Invite" button in the groups tab', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
expect(await screen.findByRole('button', { name: 'Redeem Invite' })).toBeInTheDocument();
});
test('clicking "Redeem Invite" opens the redeem panel with inputs', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await screen.findByRole('button', { name: 'Redeem Invite' });
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
@@ -924,7 +908,7 @@ describe('redeem invite', () => {
invitedBy: 'admin-1',
valid: true,
});
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
await userEvent.type(screen.getByPlaceholderText('Group ID'), 'g-prev');
@@ -943,7 +927,7 @@ describe('redeem invite', () => {
joinedAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
});
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
await userEvent.type(screen.getByPlaceholderText('Group ID'), 'g-redeem');
@@ -978,7 +962,7 @@ describe('inbox stream lifecycle', () => {
});
test('calls streams.start with "inbox" when Inbox tab is opened', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
// Wait for async effects to settle.
await screen.findByText(/Your inbox is empty/i);
@@ -991,7 +975,7 @@ describe('inbox stream lifecycle', () => {
status: 'connected',
clearMessages: vi.fn(),
}));
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
// Wait for the async inbox fetch to settle and the live indicator to appear.
await screen.findByTestId('inbox-live-indicator');
@@ -999,7 +983,7 @@ describe('inbox stream lifecycle', () => {
});
test('does NOT render the Live indicator when streamStatus is idle', async () => {
render(<MessagingSection />);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
await screen.findByText(/Your inbox is empty/i);
expect(screen.queryByTestId('inbox-live-indicator')).not.toBeInTheDocument();
+39 -21
View File
@@ -44,7 +44,7 @@ const E2E_MESSAGING_ENABLED = true;
// ── Tab definition ────────────────────────────────────────────────────────────
const TABS = ['channels', 'groups', 'broadcasts', 'inbox', 'dms'] as const;
export const TABS = ['channels', 'groups', 'broadcasts', 'inbox', 'dms'] as const;
type Tab = (typeof TABS)[number];
const TAB_LABELS: Record<Tab, string> = {
@@ -1361,29 +1361,47 @@ function DmsPanel() {
// ── Messaging section root ────────────────────────────────────────────────────
export default function MessagingSection() {
const [activeTab, setActiveTab] = useState<Tab>('channels');
// Messaging is currently focused on DMs only. The channels / groups /
// broadcasts / inbox panels (and their tab bar) are intentionally hidden — add
// their slugs back here to restore the full tab bar; the panels below stay
// wired up (hidden, not removed). With a single visible tab the bar is hidden
// entirely so the surface goes straight to DMs.
const VISIBLE_TABS: readonly Tab[] = ['dms'];
interface MessagingSectionProps {
/**
* Which tabs to surface. Defaults to DMs-only (the tab bar hides itself when a
* single tab is visible). Overridable so the hidden panels stay testable.
*/
tabs?: readonly Tab[];
}
export default function MessagingSection({ tabs = VISIBLE_TABS }: MessagingSectionProps = {}) {
const [activeTab, setActiveTab] = useState<Tab>(tabs[0]);
const showTabBar = tabs.length > 1;
return (
<div className="flex flex-col h-full">
{/* Tab chips */}
<div className="flex gap-1 px-4 py-3 border-b border-stone-200 dark:border-neutral-800 overflow-x-auto shrink-0">
{TABS.map(tab => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
data-active={activeTab === tab}
className={[
'whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition-colors',
activeTab === tab
? 'bg-stone-800 text-white dark:bg-neutral-100 dark:text-neutral-900'
: 'border border-stone-200 bg-white text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800',
].join(' ')}>
{TAB_LABELS[tab]}
</button>
))}
</div>
{/* Tab chips — only shown when more than one tab is enabled. */}
{showTabBar && (
<div className="flex gap-1 px-4 py-3 border-b border-stone-200 dark:border-neutral-800 overflow-x-auto shrink-0">
{tabs.map(tab => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
data-active={activeTab === tab}
className={[
'whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition-colors',
activeTab === tab
? 'bg-stone-800 text-white dark:bg-neutral-100 dark:text-neutral-900'
: 'border border-stone-200 bg-white text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800',
].join(' ')}>
{TAB_LABELS[tab]}
</button>
))}
</div>
)}
{/* Signal key status — always visible when wallet is connected */}
<SignalKeyStatusCard />
@@ -363,8 +363,9 @@ describe('graphql-enriched profile card', () => {
);
render(<ProfilesSection />);
// Rich data rendered
expect(await screen.findByText('@alice')).toBeInTheDocument();
// Rich data rendered — the handle appears in the header heading (it also
// appears in the "Handles owned" list, so target the heading specifically).
expect(await screen.findByRole('heading', { name: /@alice/ })).toBeInTheDocument();
expect(screen.getByText('Building the future')).toBeInTheDocument();
expect(screen.getByText('ai')).toBeInTheDocument();
expect(screen.getByText('automation')).toBeInTheDocument();
+43 -2
View File
@@ -199,6 +199,8 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
const rawSkills = isGraphql ? (profile!.tags ?? []) : [];
const skills = rawSkills.map(toLabel);
const attestations: GqlAttestation[] = isGraphql ? (profile!.attestations ?? []) : [];
const actorType = isGraphql ? (profile!.actorType ?? '') : '';
const ownedIdentities = isGraphql ? (profile!.identities ?? []) : [];
const agentId = cryptoId;
const agentName = displayName || usernameClean || '?';
@@ -252,13 +254,23 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
</div>
)}
<div className="min-w-0">
<h3 className="flex items-center gap-1 text-sm font-semibold text-stone-900 dark:text-neutral-100">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-stone-900 dark:text-neutral-100">
{handle}
{verified && (
<span className="ml-1 text-xs text-blue-500" title="Verified">
<span className="text-xs text-blue-500" title="Verified">
&#10003;
</span>
)}
{actorType && (
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
actorType.toLowerCase() === 'human'
? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-300'
}`}>
{actorType.toLowerCase() === 'human' ? 'Human' : 'Agent'}
</span>
)}
</h3>
{cryptoId && (
<p
@@ -307,6 +319,35 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
</div>
)}
{ownedIdentities.length > 0 && (
<div className="mt-4 border-t border-stone-200 pt-4 dark:border-neutral-800">
<h4 className="mb-2 text-xs font-medium text-stone-900 dark:text-neutral-100">
Handles owned{ownedIdentities.length > 1 ? ` (${ownedIdentities.length})` : ''}
</h4>
<div className="space-y-1.5">
{ownedIdentities.map(id => (
<div
key={id.username}
className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-1.5 text-sm dark:border-neutral-800">
<span className="truncate font-medium text-stone-800 dark:text-neutral-200">
@{id.username.replace(/^@+/, '')}
</span>
<span className="flex items-center gap-2">
{id.primary && (
<span className="rounded-full bg-primary-50 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-300">
primary
</span>
)}
<span className="text-[10px] uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{id.status}
</span>
</span>
</div>
))}
</div>
</div>
)}
{followStats && (
<div className="mt-4 border-t border-stone-200 pt-4 dark:border-neutral-800">
<div className="flex gap-6">
@@ -1,6 +1,7 @@
import { useT } from '../../../lib/i18n/I18nContext';
import { APP_VERSION } from '../../../utils/config';
import ConnectionIndicator from '../../ConnectionIndicator';
import SidebarAppRail from './SidebarAppRail';
import SidebarHeader from './SidebarHeader';
import SidebarNav from './SidebarNav';
import { SidebarSlotOutlet } from './SidebarSlot';
@@ -13,6 +14,8 @@ import { SidebarSlotOutlet } from './SidebarSlot';
* ├──────────────┤
* │ SidebarNav │ static primary navigation
* ├──────────────┤
* │ SidebarAppRail│ persistent app switcher (agent + connected apps)
* ├──────────────┤
* │ SidebarSlot │ dynamic, per-route content (scrolls)
* │ (Outlet) │
* ├──────────────┤
@@ -32,6 +35,12 @@ export default function AppSidebar() {
<div className="flex-shrink-0">
<SidebarNav />
</div>
{/* Persistent app switcher — sticks across routes so the agent + connected
apps are always one click away. Selecting one routes to /chat where the
provider webview / agent chat actually render. */}
<div className="flex-shrink-0 border-t border-stone-200/70 dark:border-neutral-800/70">
<SidebarAppRail />
</div>
<div className="min-h-0 flex-1 overflow-y-auto border-t border-stone-200/70 dark:border-neutral-800/70">
{/* Flex column so routes that project more than one region (e.g. Chat's
app rail above its thread list) can order them via Tailwind `order-*`. */}
@@ -0,0 +1,296 @@
import debugFactory from 'debug';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { trackEvent } from '../../../services/analytics';
import { purgeWebviewAccount } from '../../../services/webviewAccountService';
import {
addAccount,
removeAccount,
setAccountsOverlayOpen,
setActiveAccount,
setLastActiveAccount,
} from '../../../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import type { Account, AccountProvider, ProviderDescriptor } from '../../../types/accounts';
import { AGENT_ACCOUNT_ID as AGENT_ID } from '../../../utils/accountsFullscreen';
import AddAccountModal from '../../accounts/AddAccountModal';
import { AgentIcon, ProviderIcon } from '../../accounts/providerIcons';
const debug = debugFactory('layout:sidebar-app-rail');
function makeAccountId(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
if (c && typeof c.getRandomValues === 'function') {
const bytes = new Uint8Array(4);
c.getRandomValues(bytes);
const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
return `acct-${Date.now().toString(36)}-${suffix}`;
}
return `acct-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
interface RailButtonProps {
active: boolean;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
tooltip: string;
analyticsId: string;
badge?: number;
children: React.ReactNode;
}
const RailButton = ({
active,
onClick,
onContextMenu,
tooltip,
analyticsId,
badge,
children,
}: RailButtonProps) => (
<button
type="button"
onClick={onClick}
onContextMenu={onContextMenu}
title={tooltip}
data-analytics-id={analyticsId}
// Issue #1284 — `hover:z-50` lifts the entire button (and its tooltip
// child) above sibling rail buttons during hover so the native `title`
// tooltip isn't trapped under a later sibling's stacking context.
className={`group relative flex h-9 w-9 flex-none items-center justify-center rounded-lg transition-all hover:z-50 ${
active
? 'bg-primary-50 ring-2 ring-primary-500'
: 'hover:bg-stone-100 dark:hover:bg-neutral-800/60 hover:scale-105'
}`}
aria-label={tooltip}>
{children}
{badge && badge > 0 ? (
<span className="absolute -right-0.5 -top-0.5 flex min-w-[16px] items-center justify-center rounded-full bg-coral-500 px-1 text-[9px] font-semibold text-white">
{badge > 99 ? '99+' : badge}
</span>
) : null}
</button>
);
interface ContextMenuState {
accountId: string;
x: number;
y: number;
}
/**
* The persistent app rail (agent + connected provider apps + add button),
* rendered directly in {@link AppSidebar} so it sticks regardless of the active
* route. Selecting an app sets the active account in Redux and navigates to the
* chat surface, where the provider webview is composited; the rail itself never
* unmounts as the user moves between pages.
*/
export default function SidebarAppRail() {
const { t } = useT();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const accountsById = useAppSelector(state => state.accounts.accounts);
const order = useAppSelector(state => state.accounts.order);
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
const unreadByAccount = useAppSelector(state => state.accounts.unread);
const [addOpen, setAddOpen] = useState(false);
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
const accounts: Account[] = useMemo(
() => order.map(id => accountsById[id]).filter((a): a is Account => Boolean(a)),
[order, accountsById]
);
const connectedProviders = useMemo(
() => new Set<AccountProvider>(accounts.map(a => a.provider)),
[accounts]
);
const selectedId = activeAccountId ?? AGENT_ID;
const isAgentSelected = selectedId === AGENT_ID;
// The chat page hides its active provider webview while a rail overlay is
// open (the native CEF view composites above HTML, so React overlays would be
// painted over). Mirror local overlay state into Redux for it to read.
const overlayOpen = addOpen || ctxMenu !== null;
useEffect(() => {
dispatch(setAccountsOverlayOpen(overlayOpen));
}, [overlayOpen, dispatch]);
// Bring the chat surface forward whenever the user picks something on the
// rail from another route — that's where the agent chat / provider webview
// actually render.
const goToChat = () => {
if (!window.location.hash.replace(/^#/, '').startsWith('/chat')) {
navigate('/chat');
}
};
const handlePickProvider = (p: ProviderDescriptor) => {
setAddOpen(false);
trackEvent('account_connect_start', { provider: p.id });
const id = makeAccountId();
const acct: Account = {
id,
provider: p.id,
label: p.label,
createdAt: new Date().toISOString(),
status: 'pending',
};
dispatch(addAccount(acct));
dispatch(setActiveAccount(id));
// Issue #1233 — record this real-account selection in the persisted MRU
// pointer so the next session can prewarm it.
dispatch(setLastActiveAccount(id));
goToChat();
};
const selectAgent = () => {
trackEvent('tauri_browser_click', {
surface: 'sidebar_app_rail',
action: 'select_agent',
provider: 'agent',
});
dispatch(setActiveAccount(AGENT_ID));
goToChat();
};
const selectAccount = (id: string) => {
const account = accountsById[id];
if (account) {
trackEvent('tauri_browser_click', {
surface: 'sidebar_app_rail',
action: 'select_account',
provider: account.provider,
account_status: account.status ?? 'unknown',
});
}
dispatch(setActiveAccount(id));
dispatch(setLastActiveAccount(id));
goToChat();
};
const openContextMenu = (accountId: string, e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({ accountId, x: e.clientX, y: e.clientY });
};
const handleLogout = async (accountId: string) => {
setCtxMenu(null);
const account = accountsById[accountId];
if (account) {
trackEvent('tauri_browser_click', {
surface: 'sidebar_app_rail',
action: 'disconnect_account',
provider: account.provider,
account_status: account.status ?? 'unknown',
});
}
try {
await purgeWebviewAccount(accountId);
} catch {
// Purge failures are already logged by the service; still drop the
// account from the UI so the user isn't stuck with a zombie icon.
debug('purge failed for %s — dropping from UI anyway', accountId);
}
dispatch(removeAccount({ accountId }));
};
// Close the context menu on Escape or any outside click.
useEffect(() => {
if (!ctxMenu) return;
const close = () => setCtxMenu(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
window.addEventListener('mousedown', close);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', close);
window.removeEventListener('keydown', onKey);
};
}, [ctxMenu]);
return (
<>
<div
data-testid="sidebar-app-rail"
data-analytics-id="sidebar-app-rail"
className="scrollbar-hide flex flex-none items-center gap-1.5 overflow-x-auto overflow-y-hidden border-b border-stone-100 px-2 py-2 dark:border-neutral-800">
<RailButton
active={isAgentSelected}
onClick={selectAgent}
tooltip={t('accounts.agent')}
analyticsId="sidebar-app-rail-agent">
<AgentIcon className="h-5 w-5 rounded-md bg-white dark:bg-neutral-200" />
</RailButton>
{accounts.map(acct => (
<RailButton
key={acct.id}
active={acct.id === selectedId}
onClick={() => selectAccount(acct.id)}
onContextMenu={e => openContextMenu(acct.id, e)}
tooltip={acct.label}
analyticsId={`sidebar-app-rail-account-${acct.provider}`}
badge={unreadByAccount[acct.id]}>
<ProviderIcon provider={acct.provider} className="h-5 w-5 rounded" />
</RailButton>
))}
<button
type="button"
onClick={() => {
trackEvent('tauri_browser_click', {
surface: 'sidebar_app_rail',
action: 'open_add_account',
provider: 'none',
});
setAddOpen(true);
}}
data-analytics-id="sidebar-app-rail-add-account"
data-testid="accounts-add-button"
className="group relative flex h-9 w-9 flex-none items-center justify-center rounded-xl border border-dashed border-stone-300 text-stone-400 hover:bg-stone-50 hover:text-stone-600 dark:border-neutral-700 dark:text-neutral-500 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-300"
aria-label={t('accounts.addAccount')}
title={t('accounts.addAccount')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<AddAccountModal
open={addOpen}
onClose={() => setAddOpen(false)}
onPick={handlePickProvider}
connectedProviders={connectedProviders}
/>
{ctxMenu && (
<div
className="fixed z-50 min-w-[140px] rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 py-1 shadow-strong"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onMouseDown={e => e.stopPropagation()}>
<button
type="button"
data-analytics-id="sidebar-app-rail-disconnect-account"
onClick={() => void handleLogout(ctxMenu.accountId)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-coral-600 hover:bg-stone-100 dark:hover:bg-neutral-800/60">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
{t('accounts.disconnect')}
</button>
</div>
)}
</>
);
}
+36 -7
View File
@@ -1175,6 +1175,39 @@ export interface GqlJobListResult {
count: number;
}
/** Reward block on a GraphQL bounty (amount in the asset's smallest base unit). */
export interface GqlBountyReward {
amount: string;
asset: string;
network: string;
}
/** A bounty as returned by the tiny.place GraphQL gateway. */
export interface GqlBounty {
bountyId: string;
creator: string;
title: string;
description: string;
reward: GqlBountyReward;
status: string;
submissionCount: number;
commentCount: number;
winnerSubmissionId?: string;
winnerAgent?: string;
startAt: string;
deadline: string;
createdAt: string;
updatedAt: string;
}
/** Filters for the GraphQL bounties query (all optional). */
export interface GqlBountyQueryParams {
status?: string;
creator?: string;
limit?: number;
offset?: number;
}
/**
* Query params for the GraphQL jobs endpoint. Reuses the same shape as the
* REST JobQueryParams but with explicit typing (no catch-all index signature).
@@ -1797,13 +1830,6 @@ export function createInvokeApiClient() {
durationDays: params.durationDays ?? null,
confirmed: opts?.confirmed ?? false,
}),
/** Fund a bounty via x402 confirm-before-spend. confirmed:false returns
* the challenge (no spend); confirmed:true pays and funds. */
fund: (bountyId: string, opts?: { confirmed?: boolean }) =>
call<X402BuyResult>('openhuman.tinyplace_bounties_fund', {
bountyId,
confirmed: opts?.confirmed ?? false,
}),
cancel: (bountyId: string) =>
call<Bounty>('openhuman.tinyplace_bounties_cancel', { bountyId }),
submit: (bountyId: string, url: string, title?: string, note?: string) =>
@@ -1992,6 +2018,9 @@ export function createInvokeApiClient() {
call<GqlJobListResult>('openhuman.tinyplace_graphql_jobs', { params: params ?? null }),
/** Fetch a single job posting by ID (public, no auth). */
job: (id: string) => call<GqlJobPosting | null>('openhuman.tinyplace_graphql_job', { id }),
bounties: (params?: GqlBountyQueryParams) =>
call<GqlBounty[]>('openhuman.tinyplace_graphql_bounties', { params: params ?? null }),
bounty: (id: string) => call<GqlBounty | null>('openhuman.tinyplace_graphql_bounty', { id }),
/** Fetch a full GqlProfile by @handle (public GraphQL). */
profile: (username: string) =>
call<GqlProfile | null>('openhuman.tinyplace_graphql_profile', { username }),
+8 -262
View File
@@ -1,9 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import AddAccountModal from '../components/accounts/AddAccountModal';
import { AgentIcon, ProviderIcon } from '../components/accounts/providerIcons';
import WebviewHost from '../components/accounts/WebviewHost';
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
import {
CustomGifMascot,
getMascotPalette,
@@ -13,102 +10,25 @@ import {
import { useHumanMascot } from '../features/human/useHumanMascot';
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
import { useT } from '../lib/i18n/I18nContext';
import { trackEvent } from '../services/analytics';
import {
hideWebviewAccount,
purgeWebviewAccount,
showWebviewAccount,
startWebviewAccountService,
} from '../services/webviewAccountService';
import {
addAccount,
removeAccount,
setActiveAccount,
setLastActiveAccount,
} from '../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { useAppSelector } from '../store/hooks';
import {
selectCustomMascotGifUrl,
selectCustomPrimaryColor,
selectCustomSecondaryColor,
selectMascotColor,
} from '../store/mascotSlice';
import type { Account, AccountProvider, ProviderDescriptor } from '../types/accounts';
import type { Account } from '../types/accounts';
import { AGENT_ACCOUNT_ID as AGENT_ID } from '../utils/accountsFullscreen';
import Conversations, { AgentChatPanel } from './Conversations';
// Persistence key for face-toggle state across sessions.
const FACE_MODE_KEY = 'chat.faceMode';
function makeAccountId(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
if (c && typeof c.getRandomValues === 'function') {
const bytes = new Uint8Array(4);
c.getRandomValues(bytes);
const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
return `acct-${Date.now().toString(36)}-${suffix}`;
}
return `acct-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
interface RailButtonProps {
active: boolean;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
tooltip: string;
analyticsId: string;
badge?: number;
children: React.ReactNode;
}
const RailButton = ({
active,
onClick,
onContextMenu,
tooltip,
analyticsId,
badge,
children,
}: RailButtonProps) => (
<button
type="button"
onClick={onClick}
onContextMenu={onContextMenu}
title={tooltip}
data-analytics-id={analyticsId}
// Issue #1284 — `hover:z-50` lifts the entire button (and its tooltip
// child) above sibling rail buttons during hover. Without it, the
// `hover:scale-105` transform on a non-active button establishes its
// own stacking context that traps the tooltip's `z-50` inside it,
// and a later sibling button (next in DOM order) paints over the
// tooltip rectangle. Belt-and-suspenders for the active-button case
// too, where ring-2 + bg-primary-50 don't transform but the lifted
// z still helps tooltips render cleanly above neighbours.
className={`group relative flex h-9 w-9 flex-none items-center justify-center rounded-lg transition-all hover:z-50 ${
active
? 'bg-primary-50 ring-2 ring-primary-500'
: 'hover:bg-stone-100 dark:hover:bg-neutral-800/60 hover:scale-105'
}`}
aria-label={tooltip}>
{children}
{badge && badge > 0 ? (
<span className="absolute -right-0.5 -top-0.5 flex min-w-[16px] items-center justify-center rounded-full bg-coral-500 px-1 text-[9px] font-semibold text-white">
{badge > 99 ? '99+' : badge}
</span>
) : null}
{/* Tooltip is the native `title` (set above): a custom absolute tooltip got
clipped by the rail's horizontal-scroll overflow, and the native title
isn't subject to overflow clipping or CEF webview compositing. */}
</button>
);
interface ContextMenuState {
accountId: string;
x: number;
y: number;
}
/**
* Mascot + TTS panel rendered in face mode (right column of the Assistant
* surface). Extracted as a separate component so its hooks only run when
@@ -188,13 +108,12 @@ const FaceModePanel = () => {
const Accounts = () => {
const { t } = useT();
const dispatch = useAppDispatch();
const accountsById = useAppSelector(state => state.accounts.accounts);
const order = useAppSelector(state => state.accounts.order);
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
const unreadByAccount = useAppSelector(state => state.accounts.unread);
const [addOpen, setAddOpen] = useState(false);
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
// Overlay state is owned by the persistent app rail (now in the sidebar); we
// only read it here to hide/restore the active provider webview.
const overlayOpen = useAppSelector(state => state.accounts.overlayOpen);
const [faceMode] = useState<boolean>(() => {
try {
@@ -222,22 +141,16 @@ const Accounts = () => {
);
usePrewarmMostRecentAccount({ accounts, accountsById, activeAccountId });
const connectedProviders = useMemo(
() => new Set<AccountProvider>(accounts.map(a => a.provider)),
[accounts]
);
const selectedId = activeAccountId ?? AGENT_ID;
const active = selectedId === AGENT_ID ? null : (accountsById[selectedId] ?? null);
const isAgentSelected = selectedId === AGENT_ID;
// The child Tauri webview is a native view composited above the HTML
// canvas, so DOM z-index can't put React overlays on top of it. Hide
// the active webview while any overlay (add-account modal or the
// right-click context menu) is open and restore it on close. No-op
// when the agent pane is selected (pure HTML).
// the active webview while a rail overlay (add-account modal or the
// right-click context menu, both owned by the persistent sidebar rail) is
// open and restore it on close. No-op when the agent pane is selected.
const activeId = active?.id ?? null;
const overlayOpen = addOpen || ctxMenu !== null;
useEffect(() => {
if (!activeId) return;
if (overlayOpen) {
@@ -247,149 +160,12 @@ const Accounts = () => {
}
}, [overlayOpen, activeId]);
const handlePickProvider = (p: ProviderDescriptor) => {
setAddOpen(false);
trackEvent('account_connect_start', { provider: p.id });
const id = makeAccountId();
const acct: Account = {
id,
provider: p.id,
label: p.label,
createdAt: new Date().toISOString(),
status: 'pending',
};
dispatch(addAccount(acct));
dispatch(setActiveAccount(id));
// Issue #1233 — record this real-account selection in the persisted
// MRU pointer so the next session can prewarm it. Agent selections
// never reach this code path (separate `selectAgent` callback below).
dispatch(setLastActiveAccount(id));
};
const selectAgent = () => {
trackEvent('tauri_browser_click', {
surface: 'chat_right_sidebar',
action: 'select_agent',
provider: 'agent',
});
dispatch(setActiveAccount(AGENT_ID));
};
const selectAccount = (id: string) => {
const account = accountsById[id];
if (account) {
trackEvent('tauri_browser_click', {
surface: 'chat_right_sidebar',
action: 'select_account',
provider: account.provider,
account_status: account.status ?? 'unknown',
});
}
dispatch(setActiveAccount(id));
dispatch(setLastActiveAccount(id));
};
const openContextMenu = (accountId: string, e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({ accountId, x: e.clientX, y: e.clientY });
};
const handleLogout = async (accountId: string) => {
setCtxMenu(null);
const account = accountsById[accountId];
if (account) {
trackEvent('tauri_browser_click', {
surface: 'chat_right_sidebar',
action: 'disconnect_account',
provider: account.provider,
account_status: account.status ?? 'unknown',
});
}
try {
await purgeWebviewAccount(accountId);
} catch {
// Purge failures are already logged by the service; still drop the
// account from the UI so the user isn't stuck with a zombie icon.
}
dispatch(removeAccount({ accountId }));
};
// Close the context menu on Escape or any outside click.
useEffect(() => {
if (!ctxMenu) return;
const close = () => setCtxMenu(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
window.addEventListener('mousedown', close);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', close);
window.removeEventListener('keydown', onKey);
};
}, [ctxMenu]);
return (
<div
// `h-full` makes this page fill the shell's content box edge-to-edge.
className="relative flex h-full overflow-hidden"
data-testid="accounts-page"
data-analytics-id="chat-right-sidebar">
{/* App rail — projected into the root sidebar's dynamic region as a compact
horizontal row above the thread search (order-0 sits above the thread
list, which Conversations projects as order-1). */}
<SidebarContent>
<div
data-testid="accounts-app-rail"
data-analytics-id="chat-app-rail"
className="scrollbar-hide order-0 flex flex-none items-center gap-1.5 overflow-x-auto overflow-y-hidden border-b border-stone-100 px-2 py-2 dark:border-neutral-800">
<RailButton
active={isAgentSelected}
onClick={selectAgent}
tooltip={t('accounts.agent')}
analyticsId="chat-app-rail-agent">
<AgentIcon className="h-5 w-5 rounded-md bg-white dark:bg-neutral-200" />
</RailButton>
{accounts.map(acct => (
<RailButton
key={acct.id}
active={acct.id === selectedId}
onClick={() => selectAccount(acct.id)}
onContextMenu={e => openContextMenu(acct.id, e)}
tooltip={acct.label}
analyticsId={`chat-app-rail-account-${acct.provider}`}
badge={unreadByAccount[acct.id]}>
<ProviderIcon provider={acct.provider} className="h-5 w-5 rounded" />
</RailButton>
))}
<button
type="button"
onClick={() => {
trackEvent('tauri_browser_click', {
surface: 'chat_app_rail',
action: 'open_add_account',
provider: 'none',
});
setAddOpen(true);
}}
data-analytics-id="chat-app-rail-add-account"
data-testid="accounts-add-button"
className="group relative flex h-9 w-9 flex-none items-center justify-center rounded-xl border border-dashed border-stone-300 text-stone-400 hover:bg-stone-50 hover:text-stone-600 dark:border-neutral-700 dark:text-neutral-500 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-300"
aria-label={t('accounts.addAccount')}
title={t('accounts.addAccount')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
</SidebarContent>
{/* "Talk to Tiny" face-mode toggle — hidden (kept for potential re-enable). */}
{/* Main pane. In face mode (agent selected) it's a horizontal split with
@@ -432,36 +208,6 @@ const Accounts = () => {
)}
</main>
)}
<AddAccountModal
open={addOpen}
onClose={() => setAddOpen(false)}
onPick={handlePickProvider}
connectedProviders={connectedProviders}
/>
{ctxMenu && (
<div
className="fixed z-50 min-w-[140px] rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 py-1 shadow-strong"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onMouseDown={e => e.stopPropagation()}>
<button
type="button"
data-analytics-id="chat-right-sidebar-disconnect-account"
onClick={() => void handleLogout(ctxMenu.accountId)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-coral-600 hover:bg-stone-100 dark:hover:bg-neutral-800/60">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
{t('accounts.disconnect')}
</button>
</div>
)}
</div>
);
};
+90 -124
View File
@@ -13,7 +13,6 @@ import ChatNewWindowHero from '../components/chat/ChatNewWindowHero';
import ComposerTokenStats from '../components/chat/ComposerTokenStats';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
import PillTabBar from '../components/PillTabBar';
import UpsellBanner from '../components/upsell/UpsellBanner';
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
import MicComposer from '../features/human/MicComposer';
@@ -108,12 +107,7 @@ import {
formatResetTime,
getInlineCompletionSuffix,
} from './conversations/utils/format';
import {
GENERAL_TAB_VALUE,
isThreadVisibleInTab,
SUBCONSCIOUS_TAB_VALUE,
TASKS_TAB_VALUE,
} from './conversations/utils/threadFilter';
import { GENERAL_TAB_VALUE, isThreadVisibleInTab } from './conversations/utils/threadFilter';
const CHAT_MODEL_HINT = 'hint:chat';
/** Maximum trailing characters rendered in the live-streaming assistant
@@ -248,7 +242,10 @@ const Conversations = ({
const [isTranscribing, setIsTranscribing] = useState(false);
const [voiceStatus, setVoiceStatus] = useState<string | null>(null);
const [isPlayingReply, setIsPlayingReply] = useState(false);
const [selectedLabel, setSelectedLabel] = useState<string>(GENERAL_TAB_VALUE);
// Thread-list filtering is fixed to the General bucket — the in-sidebar
// General/Subconscious/Tasks chips were removed. Subconscious reflections and
// task/worker threads have dedicated surfaces (Intelligence, Tasks board).
const selectedLabel = GENERAL_TAB_VALUE;
const [threadSearch, setThreadSearch] = useState('');
const [inlineSuggestionValue, setInlineSuggestionValue] = useState('');
const [sendError, setSendError] = useState<ChatSendError | null>(null);
@@ -308,8 +305,9 @@ const Conversations = ({
);
const rustChat = useRustChat();
const [reactionPickerMsgId, setReactionPickerMsgId] = useState<string | null>(null);
// Inline thread-title rename in the conversation header.
const [editingTitle, setEditingTitle] = useState(false);
// Inline thread-title rename in the sidebar thread list — keyed by the
// thread id being edited (null = none) so any row can rename in place.
const [editingThreadId, setEditingThreadId] = useState<string | null>(null);
const [editTitleValue, setEditTitleValue] = useState('');
const editTitleInputRef = useRef<HTMLInputElement>(null);
const ignoreNextTitleBlurRef = useRef(false);
@@ -440,13 +438,12 @@ const Conversations = ({
}
};
const handleStartEditTitle = () => {
if (!selectedThreadId) return;
const thr = threads.find(t => t.id === selectedThreadId);
debug('[chat] thread rename: start thread=%s', selectedThreadId);
const handleStartEditTitle = (threadId: string) => {
const thr = threads.find(t => t.id === threadId);
debug('[chat] thread rename: start thread=%s', threadId);
setEditTitleValue(thr?.title ?? '');
ignoreNextTitleBlurRef.current = true;
setEditingTitle(true);
setEditingThreadId(threadId);
const scheduleSelect = window.requestAnimationFrame ?? window.setTimeout;
scheduleSelect(() => {
editTitleInputRef.current?.select();
@@ -454,27 +451,27 @@ const Conversations = ({
});
};
const handleCommitTitle = () => {
const handleCommitTitle = (threadId: string) => {
const trimmed = editTitleValue.trim();
setEditingTitle(false);
setEditingThreadId(null);
// Title length only — never log the title text itself (may carry PII).
if (!selectedThreadId || !trimmed) {
debug('[chat] thread rename: commit skipped thread=%s empty=%s', selectedThreadId, !trimmed);
if (!threadId || !trimmed) {
debug('[chat] thread rename: commit skipped thread=%s empty=%s', threadId, !trimmed);
return;
}
const currentTitle = threads.find(t => t.id === selectedThreadId)?.title?.trim();
const currentTitle = threads.find(t => t.id === threadId)?.title?.trim();
if (trimmed === currentTitle) {
debug('[chat] thread rename: commit skipped thread=%s (unchanged)', selectedThreadId);
debug('[chat] thread rename: commit skipped thread=%s (unchanged)', threadId);
return;
}
debug('[chat] thread rename: commit thread=%s len=%d', selectedThreadId, trimmed.length);
void dispatch(updateThreadTitle({ threadId: selectedThreadId, title: trimmed }))
debug('[chat] thread rename: commit thread=%s len=%d', threadId, trimmed.length);
void dispatch(updateThreadTitle({ threadId, title: trimmed }))
.unwrap()
.then(() => debug('[chat] thread rename: committed thread=%s', selectedThreadId))
.then(() => debug('[chat] thread rename: committed thread=%s', threadId))
.catch(err =>
debug(
'[chat] thread rename: failed thread=%s err=%s',
selectedThreadId,
threadId,
err instanceof Error ? err.message : String(err)
)
);
@@ -505,16 +502,9 @@ const Conversations = ({
const openThreadId = (location.state as { openThreadId?: string } | null)?.openThreadId;
const openThread = openThreadId ? data.threads.find(t => t.id === openThreadId) : undefined;
if (openThread) {
// Switch the sidebar tab to the bucket that contains the opened
// thread (e.g. Tasks for a task session) so it's visible/selected in
// the list instead of hidden behind the default General tab.
setSelectedLabel(
isThreadVisibleInTab(openThread, TASKS_TAB_VALUE)
? TASKS_TAB_VALUE
: isThreadVisibleInTab(openThread, SUBCONSCIOUS_TAB_VALUE)
? SUBCONSCIOUS_TAB_VALUE
: GENERAL_TAB_VALUE
);
// An explicit open intent (e.g. View work from the Tasks board) opens
// the thread in the main pane directly; the thread list itself stays
// filtered to General.
dispatch(setSelectedThread(openThread.id));
void dispatch(loadThreadMessages(openThread.id));
return;
@@ -1469,16 +1459,6 @@ const Conversations = ({
return sortedThreads.filter(thread => (thread.title ?? '').toLowerCase().includes(q));
}, [sortedThreads, threadSearch]);
// Fixed bucket set so categories don't disappear when empty and the active
// filter state remains unambiguous regardless of what threads exist.
const labelTabs = [
{ label: t('chat.filter.general'), value: GENERAL_TAB_VALUE },
{ label: t('chat.filter.subconscious'), value: SUBCONSCIOUS_TAB_VALUE },
{ label: t('chat.filter.tasks'), value: TASKS_TAB_VALUE },
];
const selectedLabelDisplay =
labelTabs.find(tab => tab.value === selectedLabel)?.label ?? selectedLabel;
const isSidebar = variant === 'sidebar';
// "New window" = the merged Home surface: a page-variant chat whose selected
// thread has no messages yet. We show the greeting + banners hero above a
@@ -1563,17 +1543,8 @@ const Conversations = ({
</button>
)}
</div>
<div className="px-2 py-2 border-b border-stone-50 dark:border-neutral-800">
<PillTabBar
items={labelTabs}
selected={selectedLabel}
onChange={setSelectedLabel}
containerClassName="scrollbar-hide flex flex-nowrap gap-1 overflow-x-auto py-1"
itemClassName="flex-none whitespace-nowrap px-2"
/>
</div>
{/* New conversation — a subtle, centered thread-style row (not a loud
button), below the pills and above the thread list. */}
button), below the search and above the thread list. */}
<button
type="button"
data-testid="new-thread-button"
@@ -1597,7 +1568,7 @@ const Conversations = ({
<div className="flex-1 overflow-y-auto">
{visibleThreads.length === 0 ? (
<p className="px-4 py-6 text-xs text-stone-400 dark:text-neutral-500 text-center">
{t('chat.noLabelThreads').replace('{label}', selectedLabelDisplay)}
{t('chat.noThreads')}
</p>
) : (
visibleThreads.map(thread => (
@@ -1625,14 +1596,68 @@ const Conversations = ({
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60'
}`}>
<div className="flex items-center justify-between">
<p
className={`text-xs truncate flex-1 ${
selectedThreadId === thread.id
? 'font-medium text-primary-700 dark:text-primary-200'
: 'text-stone-700 dark:text-neutral-200'
}`}>
{resolveThreadDisplayTitle(thread.id)}
</p>
{editingThreadId === thread.id ? (
<input
ref={editTitleInputRef}
value={editTitleValue}
onClick={e => e.stopPropagation()}
onChange={e => setEditTitleValue(e.target.value)}
onKeyDown={e => {
e.stopPropagation();
// Ignore the Enter that confirms an IME composition
// candidate (CJK input) so it doesn't prematurely commit.
if (isImeCompositionKeyEvent(e)) return;
if (e.key === 'Enter') {
e.preventDefault();
handleCommitTitle(thread.id);
} else if (e.key === 'Escape') {
// Escape is an explicit cancel — suppress the commit the
// ensuing blur would otherwise fire.
ignoreNextTitleBlurRef.current = true;
setEditingThreadId(null);
}
}}
onBlur={() => {
if (ignoreNextTitleBlurRef.current) {
ignoreNextTitleBlurRef.current = false;
return;
}
handleCommitTitle(thread.id);
}}
aria-label={t('chat.editThreadTitle')}
data-testid={`thread-title-input-${thread.id}`}
className="h-5 min-w-0 flex-1 border-b border-primary-400 bg-transparent py-0 text-xs font-medium leading-none text-stone-700 outline-none dark:text-neutral-200"
autoFocus
/>
) : (
<p
className={`text-xs truncate flex-1 ${
selectedThreadId === thread.id
? 'font-medium text-primary-700 dark:text-primary-200'
: 'text-stone-700 dark:text-neutral-200'
}`}>
{resolveThreadDisplayTitle(thread.id)}
</p>
)}
<button
type="button"
data-analytics-id="chat-sidebar-edit-thread-title"
onClick={e => {
e.stopPropagation();
handleStartEditTitle(thread.id);
}}
aria-label={t('chat.editThreadTitle')}
title={t('chat.editThreadTitle')}
className="ml-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-stone-200 dark:bg-neutral-800 dark:hover:bg-neutral-800 text-stone-400 dark:text-neutral-500 hover:text-primary-500 transition-all flex-shrink-0">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
type="button"
data-analytics-id="chat-sidebar-delete-thread"
@@ -2514,66 +2539,7 @@ const Conversations = ({
</button>
)}
{/* Thread title + inline rename (page variant). Hover the title to
reveal the edit affordance; Enter commits, Escape cancels. */}
{!isSidebar && selectedThreadId && (
<div className="mt-2 min-w-0">
{editingTitle ? (
<input
ref={editTitleInputRef}
value={editTitleValue}
onChange={e => setEditTitleValue(e.target.value)}
onKeyDown={e => {
// Ignore the Enter that confirms an IME composition candidate
// (CJK input) so it doesn't prematurely commit the rename.
if (isImeCompositionKeyEvent(e)) return;
if (e.key === 'Enter') {
e.preventDefault();
handleCommitTitle();
} else if (e.key === 'Escape') {
setEditingTitle(false);
}
}}
onBlur={() => {
if (ignoreNextTitleBlurRef.current) {
ignoreNextTitleBlurRef.current = false;
return;
}
handleCommitTitle();
}}
aria-label={t('chat.editThreadTitle')}
className="h-6 w-full min-w-0 border-b border-primary-400 bg-transparent py-0 text-sm font-medium leading-none text-stone-700 outline-none dark:text-neutral-200"
autoFocus
/>
) : (
<div className="group/title flex min-w-0 items-center gap-1">
<h3 className="truncate text-sm font-medium text-stone-700 dark:text-neutral-200">
{resolveThreadDisplayTitle(selectedThreadId)}
</h3>
<button
type="button"
data-analytics-id="chat-header-edit-thread-title"
onMouseDown={e => {
e.preventDefault();
handleStartEditTitle();
}}
onClick={handleStartEditTitle}
aria-label={t('chat.editThreadTitle')}
title={t('chat.editThreadTitle')}
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-stone-400 opacity-0 transition-all hover:bg-stone-100 hover:text-stone-600 group-hover/title:opacity-100 group-focus-within/title:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-300 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
</div>
)}
</div>
)}
{/* Thread title + inline rename moved to the sidebar thread list rows. */}
{/* Model + token stats (left) and the quick/reasoning toggle + files
chip (right) share one line. */}
@@ -646,11 +646,11 @@ describe('Conversations — thread rename', () => {
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
});
it('commits an inline thread-title rename from the conversation header', async () => {
it('commits an inline thread-title rename from the sidebar thread row', async () => {
const { thread } = await renderWithSelectedThread();
const { threadApi } = await import('../../services/api/threadApi');
// Enter edit mode via the header pencil affordance.
// Enter edit mode via the thread row pencil affordance.
fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
fireEvent.change(input, { target: { value: 'Renamed in header' } });
@@ -724,13 +724,13 @@ describe('Conversations — thread rename', () => {
expect(threadApi.updateTitle).not.toHaveBeenCalled();
});
it('opens the editor via mousedown and ignores the immediate focus-blur', async () => {
it('opens the editor and ignores the immediate focus-blur', async () => {
await renderWithSelectedThread();
const { threadApi } = await import('../../services/api/threadApi');
// onMouseDown opens edit mode; the blur fired while the input is grabbing
// focus is ignored (no spurious commit).
fireEvent.mouseDown(screen.getByRole('button', { name: 'Edit thread title' }));
// Clicking the row pencil opens edit mode; the blur fired while the input
// is grabbing focus is ignored (no spurious commit).
fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
fireEvent.blur(input);
@@ -324,28 +324,30 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
});
// Covers the page-mode sidebar (TwoPanelLayout, id `chat`) once opened.
// Covers line 941: <div className="flex-1 overflow-y-auto"> (always rendered in page mode)
it('renders the sidebar pill tabs in page mode', async () => {
// Covers the page-mode sidebar (TwoPanelLayout, id `chat`) once opened. The
// General/Subconscious/Tasks filter chips were removed; the thread search is
// the stable top-of-sidebar control.
it('renders the sidebar thread search in page mode', async () => {
await act(async () => {
await renderConversations({ thread: emptyThreadState });
});
await openSidebar();
expect(screen.getByText('General')).toBeInTheDocument();
expect(screen.getByTestId('chat-thread-search-input')).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'General' })).not.toBeInTheDocument();
});
// Covers line 941 empty branch
it('shows the General empty message when the default bucket has no threads', async () => {
// Covers the empty branch — with the filter chips gone the list always shows
// the generic empty message when no (General-bucket) threads exist.
it('shows the empty message when there are no threads', async () => {
await act(async () => {
await renderConversations({ thread: emptyThreadState });
});
// Sidebar is hidden by default — open it first.
await openSidebar();
expect(screen.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('No "General" threads')).toBeInTheDocument();
expect(screen.getByText('No threads yet')).toBeInTheDocument();
});
// Covers lines 1002-1004, 1007, 1011-1012, 1014: thread list items rendered unconditionally
@@ -1337,11 +1339,10 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
});
// Batch-5: Conversation category tabs keep stable labels and mapping (pr#1646).
//
// The tab set is fixed so categories do not disappear when the thread list
// is empty, and the active-filter state remains unambiguous.
it('renders the fixed chat bucket tabs with stable labels', async () => {
// The General/Subconscious/Tasks filter chips were removed — the thread list
// is now fixed to the General bucket with no in-sidebar bucket switcher.
// Subconscious reflections and task/worker threads have dedicated surfaces.
it('does not render the removed bucket filter tabs', async () => {
await act(async () => {
await renderConversations({ thread: emptyThreadState });
});
@@ -1349,60 +1350,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
// Sidebar is hidden by default — open it first.
await openSidebar();
// Bucket tabs must be present regardless of thread count.
expect(screen.getByRole('tab', { name: 'General' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Subconscious' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tasks' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'All' })).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Briefing' })).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Notification' })).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Workers' })).not.toBeInTheDocument();
expect(screen.getByRole('tablist')).toHaveClass('flex-nowrap');
});
it('starts with the "General" tab selected', async () => {
await act(async () => {
await renderConversations({ thread: emptyThreadState });
});
// Sidebar is hidden by default — open it first.
await openSidebar();
expect(screen.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Subconscious' })).toHaveAttribute(
'aria-selected',
'false'
);
});
it('shows category-specific empty message when a label tab is selected and no threads match', async () => {
await act(async () => {
await renderConversations({ thread: emptyThreadState });
});
// Sidebar is hidden by default — open it first.
await openSidebar();
fireEvent.click(screen.getByRole('tab', { name: 'General' }));
await waitFor(() => {
expect(screen.getByText(/"General" threads/i)).toBeInTheDocument();
});
});
it('shows a category-specific empty message when the Tasks tab is selected', async () => {
await act(async () => {
await renderConversations({ thread: emptyThreadState });
});
// Sidebar is hidden by default — open it first.
await openSidebar();
fireEvent.click(screen.getByRole('tab', { name: 'Tasks' }));
await waitFor(() => {
expect(screen.getByText(/"Tasks" threads/i)).toBeInTheDocument();
});
expect(screen.queryByRole('tab', { name: 'General' })).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Subconscious' })).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Tasks' })).not.toBeInTheDocument();
});
});
@@ -33,6 +33,7 @@ function seedState(accounts: Account[]): AccountsState {
messages: {},
unread: {},
logs: {},
overlayOpen: false,
};
for (const acct of accounts) {
state.accounts[acct.id] = acct;
+11
View File
@@ -37,6 +37,7 @@ const initialState: AccountsState = {
messages: {},
unread: {},
logs: {},
overlayOpen: false,
};
const accountsSlice = createSlice({
@@ -141,6 +142,15 @@ const accountsSlice = createSlice({
state.unread[accountId] = 0;
},
/**
* Signals that a rail overlay (add-account modal / context menu) opened or
* closed. Read by the chat page to hide/restore the active provider webview,
* which composites above HTML and would otherwise paint over the overlay.
*/
setAccountsOverlayOpen(state, action: PayloadAction<boolean>) {
state.overlayOpen = action.payload;
},
resetAccountsState() {
return initialState;
},
@@ -195,6 +205,7 @@ export const {
appendLog,
noteWebviewNotificationFired,
focusAccountFromNotification,
setAccountsOverlayOpen,
resetAccountsState,
} = accountsSlice.actions;
+9
View File
@@ -57,6 +57,15 @@ export interface AccountsState {
messages: Record<string, IngestedMessage[]>;
unread: Record<string, number>;
logs: Record<string, AccountLogEntry[]>;
/**
* True while a rail overlay (add-account modal or the right-click context
* menu) is open. The app rail now lives in the persistent sidebar, while the
* active provider webview is composited by the chat page — so the rail signals
* overlay state here and the chat page hides/restores the native webview
* accordingly (DOM z-index can't paint React overlays above a CEF webview).
* Transient: not in the persist whitelist.
*/
overlayOpen: boolean;
}
export interface AccountLogEntry {
@@ -170,13 +170,13 @@ test.describe('Chat management functional coverage', () => {
const threadId = await newThread(page);
const title = `Playwright thread ${Date.now()}`;
// Inline rename from the conversation header (restored after #3751). The
// chat-as-home surface may auto-select a different empty thread, so assert
// on the unique renamed title appearing in the thread list rather than
// pinning to a specific row — that deterministically proves the header
// rename committed end-to-end. (Rename targeting is covered deterministically
// by the Conversations rename unit test.)
await page.getByRole('button', { name: 'Edit thread title' }).click({ force: true });
// Inline rename now lives on each sidebar thread row (moved off the
// conversation header). Every row has its own "Edit thread title" button, so
// scope to this thread's row to avoid a strict-mode multi-match.
await page
.getByTestId(`thread-row-${threadId}`)
.getByRole('button', { name: 'Edit thread title' })
.click({ force: true });
await page.getByRole('textbox', { name: 'Edit thread title' }).fill(title);
await page.keyboard.press('Enter');
await expect(page.getByText(title).first()).toBeVisible({ timeout: 10_000 });
+115
View File
@@ -0,0 +1,115 @@
# Directory listing: batch follow data via GraphQL
**Status:** proposed (backend work) · **Owner:** Agent World / tiny.place
**Related:** `app/src/agentworld/pages/DirectorySection.tsx`, `src/openhuman/tinyplace/manifest.rs`
## Problem
The Directory page (`DirectorySection.tsx`) renders one card per registered agent.
Historically each card issued **two** REST/JSON-RPC calls on mount:
- `follows.stats(agentId)` — to show the follower count.
- `follows.followers(agentId, { limit: 100 })` — only to decide whether *the
current user* follows that agent.
For an `N`-agent directory that is `1 + 2N` requests on a single page load
(e.g. 50 agents → ~101 requests), which trips tiny.place rate limits.
## What already shipped (frontend-only mitigation)
`DirectorySection` now fetches the viewer's **following set once** via
`follows.following(myAgentId, { limit: 500 })` and derives each card's
follow-state locally (`useMyFollowing`). That removes the per-card
`followers` lookup → roughly halves the requests (`1 + N + 1`).
The per-card **follower count** (`follows.stats`) is still `N` requests because
`directory.listAgents()` does not return counts. Eliminating those needs the
backend change below.
## Proposed backend change
Add a single GraphQL query on the tiny.place backend that returns the directory
already joined with follow data, so the whole page is **one** request.
### GraphQL schema (tiny.place backend repo: `tinyhumansai/backend`)
```graphql
type DirectoryAgent {
agentId: String!
name: String
description: String
username: String
skills: [String!]
tags: [String!]
followerCount: Int! # aggregate from the follows table
isFollowedByViewer: Boolean! # viewer follows this agent (join on viewerAgentId)
}
type DirectoryAgentsResult {
agents: [DirectoryAgent!]!
count: Int!
}
extend type Query {
directoryAgents(
viewerAgentId: String # optional; null → isFollowedByViewer = false
limit: Int = 100
offset: Int = 0
): DirectoryAgentsResult!
}
```
`followerCount` should be a grouped aggregate (`COUNT(*) … GROUP BY followee`)
and `isFollowedByViewer` a left-join on `(follower = viewerAgentId, followee =
agentId)` — both computed in one query, no per-agent fan-out.
### Rust SDK passthrough (`tinyplace` crate)
Mirror the existing GraphQL methods (e.g. `ledger_transactions`):
```rust
// tinyplace SDK
pub async fn directory_agents(
&self,
params: Option<&DirectoryAgentsParams>,
) -> Result<DirectoryAgentsResult, Error> { /* GraphQL POST */ }
```
### OpenHuman core controller (`src/openhuman/tinyplace/manifest.rs`)
Add a handler alongside `handle_tinyplace_graphql_ledger_transactions`:
```rust
// method: "openhuman.tinyplace_graphql_directory_agents"
pub(crate) fn handle_tinyplace_graphql_directory_agents(
params: Map<String, Value>,
) -> ControllerFuture { /* deserialize params → client.directory_agents(...) → JSON */ }
```
Register it in the same controller table as the other `tinyplace_graphql_*`
methods, and add a `*_degrade` fallback (empty list) consistent with the
existing passthroughs.
### Frontend client (`app/src/lib/agentworld/invokeApiClient.ts`)
Add under the `graphql` namespace:
```ts
directoryAgents: (params?: { viewerAgentId?: string; limit?: number; offset?: number }) =>
call<DirectoryAgentsResult>('openhuman.tinyplace_graphql_directory_agents', { ...params }),
```
### Frontend page (`DirectorySection.tsx`)
Replace `directory.listAgents()` + `useMyFollowing` + per-card `follows.stats`
with a single `graphql.directoryAgents({ viewerAgentId: myAgentId })` call.
Each card then reads `followerCount` and `isFollowedByViewer` straight off the
row — **one** request for the whole page. Keep `follows.follow/unfollow` for
the optimistic toggle.
## Acceptance
- Loading an `N`-agent directory issues exactly **1** data request (plus wallet
identity), down from `1 + 2N`.
- Follower counts and follow-state match the current per-card behavior.
- Pagination supported via `limit`/`offset`.
+81 -104
View File
@@ -3820,6 +3820,87 @@ pub(crate) fn handle_tinyplace_graphql_job(params: Map<String, Value>) -> Contro
})
}
// ── GraphQL: Bounties ─────────────────────────────────────────────────────────
pub(crate) fn handle_tinyplace_graphql_bounties(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!(
"{LOG_PREFIX} graphql_bounties params_keys={:?}",
params.keys().collect::<Vec<_>>()
);
// BountyGraphQLParams isn't Deserialize, so build it from the optional
// filter object by hand (all fields optional). Reject malformed input
// rather than silently dropping it — a mistyped `limit`/`creator` must
// not degrade into an unfiltered public list.
let query_params: Option<tinyplace::api::graphql::BountyGraphQLParams> = match params
.get("params")
{
None | Some(Value::Null) => None,
Some(Value::Object(obj)) => {
let str_field = |key: &str| -> Result<Option<String>, String> {
match obj.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(format!("graphql_bounties param '{key}' must be a string")),
}
};
let int_field = |key: &str| -> Result<Option<i64>, String> {
match obj.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_i64().map(Some).ok_or_else(|| {
format!("graphql_bounties param '{key}' must be an integer")
}),
}
};
Some(tinyplace::api::graphql::BountyGraphQLParams {
status: str_field("status")?,
creator: str_field("creator")?,
limit: int_field("limit")?,
offset: int_field("offset")?,
})
}
Some(_) => return Err("graphql_bounties 'params' must be an object".to_string()),
};
let client = global_state().client().await?;
// GraphQLAuth::None — bounties are public.
match client.graphql.bounties(query_params.as_ref()).await {
Ok(result) => to_value(result),
Err(e) => match graphql_bounties_degrade(&e) {
Some(empty) => {
log::debug!(
"{LOG_PREFIX} graphql_bounties deserialization failed -> empty: {e}"
);
to_value(empty)
}
None => Err(map_err(e)),
},
}
})
}
/// The backend may return `{"bounties": null}` for an empty board. Degrade
/// Serialization errors to an empty list; propagate everything else.
pub(crate) fn graphql_bounties_degrade(e: &tinyplace::Error) -> Option<Value> {
if matches!(e, tinyplace::Error::Serialization(_)) {
Some(serde_json::json!([]))
} else {
None
}
}
pub(crate) fn handle_tinyplace_graphql_bounty(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let id = req_str(&params, "id")?.to_string();
log::debug!("{LOG_PREFIX} graphql_bounty id={id}");
let client = global_state().client().await?;
// GraphQLAuth::None — no signer required.
let result = client.graphql.bounty(&id).await.map_err(map_err)?;
// Returns Option<GqlBounty> — null means bounty not found.
to_value(result)
})
}
// ── GraphQL: Profile + Identity ───────────────────────────────────────────────
pub(crate) fn handle_tinyplace_graphql_profile(params: Map<String, Value>) -> ControllerFuture {
@@ -4338,97 +4419,6 @@ pub(crate) fn handle_tinyplace_bounties_create(params: Map<String, Value>) -> Co
})
}
pub(crate) fn handle_tinyplace_bounties_fund(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let bounty_id = req_str(&params, "bountyId")?.trim().to_string();
if bounty_id.is_empty() {
return Err("missing required param 'bountyId'".to_string());
}
let confirmed = params
.get("confirmed")
.and_then(Value::as_bool)
.unwrap_or(false);
log::debug!("{LOG_PREFIX} bounties_fund bounty_id={bounty_id} confirmed={confirmed}");
let client = global_state().client().await?;
// ANTI-SPOOF: creator resolved from signer, never from params.
let signer = client
.http()
.signer()
.ok_or("tiny.place signer unavailable; unlock your wallet")?;
let creator = signer.agent_id();
// Phase A: probe for the 402 challenge (or a free/already-funded bounty).
let challenge = match client.bounties.fund(&bounty_id, &creator, None).await {
Ok(bounty) => {
log::debug!("{LOG_PREFIX} bounties_fund no payment needed bounty_id={bounty_id}");
return to_value(serde_json::json!({ "bounty": bounty }));
}
Err(e) => match e.payment_required() {
Some(pr) => pr.payment.clone(),
None => return Err(map_err(e)),
},
};
log::debug!(
"{LOG_PREFIX} bounties_fund 402 challenge network={:?} asset={:?} amount={:?}",
challenge.network,
challenge.asset,
challenge.amount,
);
// Unconfirmed: surface the challenge + balance, spend nothing.
if !confirmed {
let (wallet_balance, wallet_address) = wallet_usdc_balance(&signer.agent_id()).await;
return to_value(serde_json::json!({
"challenge": challenge,
"walletBalance": wallet_balance,
"walletAddress": wallet_address,
}));
}
// Confirmed: cluster guards, pay on-chain, re-submit with the map.
if let Some(network) = challenge.network.as_deref() {
ensure_cluster_matches(network)?;
}
ensure_backend_mint_matches(&client).await?;
let mut extra_metadata = HashMap::new();
extra_metadata.insert("bountyId".to_string(), bounty_id.clone());
let fulfilled = fulfill_payment(
&challenge,
signer.as_ref(),
PaymentContext {
purpose: "bounties.fund".to_string(),
nonce_prefix: "fund".to_string(),
extra_metadata,
},
)
.await?;
let on_chain_tx = fulfilled.on_chain_tx.clone();
// Re-submit with the payment map, retrying while settlement confirms.
match settle_retry("bounties_fund", || {
client
.bounties
.fund(&bounty_id, &creator, Some(&fulfilled.payment_map))
})
.await
{
Ok(bounty) => to_value(serde_json::json!({
"bounty": bounty,
"payment": { "onChainTx": on_chain_tx },
})),
Err(SettleFailure::Hard(m)) => Err(format!(
"bounty funding failed after payment (onChainTx={on_chain_tx}): {m}"
)),
Err(SettleFailure::Exhausted(last)) => Err(format!(
"bounty funded but not confirmed in time (onChainTx={on_chain_tx}); \
last error: {last}"
)),
}
})
}
pub(crate) fn handle_tinyplace_bounties_cancel(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let bounty_id = req_str(&params, "bountyId")?.to_string();
@@ -5609,19 +5599,6 @@ mod tests {
);
}
/// bounties_fund rejects blank bountyId before any client work.
#[test]
fn bounties_fund_rejects_blank_bounty_id() {
let mut params = Map::new();
params.insert("bountyId".to_string(), Value::String(" ".to_string()));
let result = block_on(handle_tinyplace_bounties_fund(params));
assert!(result.is_err());
assert!(
result.unwrap_err().contains("bountyId"),
"expected 'bountyId' in error"
);
}
/// bounties_submit rejects blank url before any client work.
#[test]
fn bounties_submit_rejects_blank_url() {
+24 -11
View File
@@ -34,9 +34,10 @@ use tinyplace::x402::{
};
use tinyplace::PaymentChallenge;
use crate::openhuman::wallet::rpc::with_tinyplace_solana_endpoints;
use crate::openhuman::wallet::{
execute_prepared, prepare_transfer, solana_cluster, ExecutePreparedParams,
PrepareTransferParams, SolanaCluster, WalletChain,
execute_prepared, prepare_transfer, solana_cluster, tinyplace_solana_rpc_endpoints,
ExecutePreparedParams, PrepareTransferParams, SolanaCluster, WalletChain,
};
const LOG_PREFIX: &str = "[tinyplace-pay]";
@@ -366,17 +367,29 @@ pub(crate) async fn fulfill_payment(
truncate(&v.to),
);
let prepared = prepare_transfer(to_transfer_params(&v)).await?.value;
let quote_id = prepared.quote_id;
log::debug!("{LOG_PREFIX} prepared transfer quote_id={quote_id}");
// Run the prepare + broadcast inside the tiny.place Solana endpoint scope so
// both hit the tiny.place settlement RPC (where agent accounts are funded),
// falling back to the public cluster only if that endpoint is unreachable.
// Scoped to this task — general wallet ops are unaffected.
let endpoints = tinyplace_solana_rpc_endpoints();
log::debug!(
"{LOG_PREFIX} settlement solana endpoints={} (primary + fallbacks)",
endpoints.len()
);
let (quote_id, on_chain_tx) = with_tinyplace_solana_endpoints(endpoints, async {
let prepared = prepare_transfer(to_transfer_params(&v)).await?.value;
let quote_id = prepared.quote_id;
log::debug!("{LOG_PREFIX} prepared transfer quote_id={quote_id}");
let exec = execute_prepared(ExecutePreparedParams {
quote_id: quote_id.clone(),
confirmed: true,
let exec = execute_prepared(ExecutePreparedParams {
quote_id: quote_id.clone(),
confirmed: true,
})
.await?
.value;
Ok::<(String, String), String>((quote_id, exec.transaction_hash))
})
.await?
.value;
let on_chain_tx = exec.transaction_hash;
.await?;
log::debug!(
"{LOG_PREFIX} transfer broadcast tx={} quote_id={quote_id}",
truncate(&on_chain_tx),
+45 -25
View File
@@ -19,7 +19,6 @@ use crate::openhuman::tinyplace::manifest::{
handle_tinyplace_bounties_cancel,
handle_tinyplace_bounties_comment,
handle_tinyplace_bounties_create,
handle_tinyplace_bounties_fund,
handle_tinyplace_bounties_get,
handle_tinyplace_bounties_list,
handle_tinyplace_bounties_list_comments,
@@ -61,6 +60,8 @@ use crate::openhuman::tinyplace::manifest::{
handle_tinyplace_follows_unfollow,
// GraphQL Profile + Identity handlers
handle_tinyplace_graphql_agent_card,
handle_tinyplace_graphql_bounties,
handle_tinyplace_graphql_bounty,
// GraphQL Social Feed handlers
handle_tinyplace_graphql_home_feed,
handle_tinyplace_graphql_identities,
@@ -2041,24 +2042,6 @@ fn schema_bounties_create() -> ControllerSchema {
}
}
fn schema_bounties_fund() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "bounties_fund",
description: "Fund a bounty via x402 confirm-before-spend. confirmed=false returns the \
402 challenge + wallet balance (no spend); confirmed=true pays and funds.",
inputs: vec![
required_string("bountyId", "The bounty ID to fund."),
buy_confirmed_input(),
],
outputs: vec![json_output(
"result",
"Either { bounty } (already funded), { challenge, walletBalance, walletAddress } \
(unconfirmed), or { bounty, payment: { onChainTx } } (paid).",
)],
}
}
fn schema_bounties_cancel() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
@@ -2340,6 +2323,37 @@ fn schema_graphql_job() -> ControllerSchema {
}
}
fn schema_graphql_bounties() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "graphql_bounties",
description: "List bounties via GraphQL (public, no auth). Supports \
status/creator filters and limit/offset pagination. Returns GqlBounty[].",
inputs: vec![FieldSchema {
name: "params",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "BountyGraphQLParams filter object (all fields optional). \
Fields: status, creator, limit, offset.",
required: false,
}],
outputs: vec![json_output(
"result",
"GqlBounty[] (empty array when none).",
)],
}
}
fn schema_graphql_bounty() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
function: "graphql_bounty",
description: "Fetch a single bounty by ID via GraphQL (public, no auth). \
Returns full GqlBounty with reward, council, submissions metadata.",
inputs: vec![required_string("id", "The bounty ID to fetch.")],
outputs: vec![json_output("result", "GqlBounty or null if not found.")],
}
}
fn schema_graphql_profile() -> ControllerSchema {
ControllerSchema {
namespace: "tinyplace",
@@ -2536,7 +2550,6 @@ pub fn all_tinyplace_controller_schemas() -> Vec<ControllerSchema> {
schema_bounties_list(),
schema_bounties_get(),
schema_bounties_create(),
schema_bounties_fund(),
schema_bounties_cancel(),
schema_bounties_submit(),
schema_bounties_list_submissions(),
@@ -2556,6 +2569,9 @@ pub fn all_tinyplace_controller_schemas() -> Vec<ControllerSchema> {
// GraphQL Jobs
schema_graphql_jobs(),
schema_graphql_job(),
// GraphQL Bounties
schema_graphql_bounties(),
schema_graphql_bounty(),
// GraphQL Profile + Identity
schema_graphql_profile(),
schema_graphql_user(),
@@ -3023,10 +3039,6 @@ pub fn all_tinyplace_registered_controllers() -> Vec<RegisteredController> {
schema: schema_bounties_create(),
handler: handle_tinyplace_bounties_create,
},
RegisteredController {
schema: schema_bounties_fund(),
handler: handle_tinyplace_bounties_fund,
},
RegisteredController {
schema: schema_bounties_cancel(),
handler: handle_tinyplace_bounties_cancel,
@@ -3094,6 +3106,15 @@ pub fn all_tinyplace_registered_controllers() -> Vec<RegisteredController> {
schema: schema_graphql_job(),
handler: handle_tinyplace_graphql_job,
},
// GraphQL Bounties
RegisteredController {
schema: schema_graphql_bounties(),
handler: handle_tinyplace_graphql_bounties,
},
RegisteredController {
schema: schema_graphql_bounty(),
handler: handle_tinyplace_graphql_bounty,
},
// GraphQL Profile + Identity
RegisteredController {
schema: schema_graphql_profile(),
@@ -3291,7 +3312,6 @@ mod tests {
"openhuman.tinyplace_bounties_list",
"openhuman.tinyplace_bounties_get",
"openhuman.tinyplace_bounties_create",
"openhuman.tinyplace_bounties_fund",
"openhuman.tinyplace_bounties_cancel",
"openhuman.tinyplace_bounties_submit",
"openhuman.tinyplace_bounties_list_submissions",
+7 -2
View File
@@ -27,10 +27,15 @@ pub(crate) struct TinyPlaceState {
impl TinyPlaceState {
/// Build from the environment. `TINYPLACE_API_BASE_URL` overrides the
/// default staging endpoint.
/// default production endpoint (set it to the staging host for testing).
pub(crate) fn from_env() -> Self {
// Treat a blank/whitespace override as unset so it can't produce an
// invalid base_url (mirrors wallet::defaults env handling).
let base_url = std::env::var("TINYPLACE_API_BASE_URL")
.unwrap_or_else(|_| "https://staging-api.tiny.place".to_string());
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "https://api.tiny.place".to_string());
log::debug!("{LOG_PREFIX} state created base_url={base_url}");
Self {
client: OnceCell::new(),
+133
View File
@@ -19,6 +19,9 @@ const TRONSCAN_TX_BASE: &str = "https://tronscan.org/#/transaction/";
const DEFAULT_BTC_REST_URL: &str = "https://blockstream.info/api";
const DEFAULT_SOLANA_RPC_URL: &str = "https://api.mainnet-beta.solana.com";
/// Default tiny.place API base (mirrors `tinyplace::state`) — used to derive the
/// tiny.place Solana settlement RPC when no explicit override is set.
const TINYPLACE_API_BASE_DEFAULT: &str = "https://api.tiny.place";
const DEVNET_SOLANA_RPC_URL: &str = "https://api.devnet.solana.com";
const DEFAULT_TRON_REST_URL: &str = "https://api.trongrid.io";
@@ -240,6 +243,57 @@ pub fn rpc_url_for_chain(chain: WalletChain) -> String {
}
}
/// Ordered Solana JSON-RPC endpoints used **only for tiny.place settlement
/// broadcasts** — primary first, fallback after.
///
/// tiny.place settles on its own Solana RPC (the chain where agent accounts are
/// actually funded); broadcasting to the generic public cluster RPC is what
/// produces "AccountNotFound / debit an account but found no record of a prior
/// credit". So the tiny.place payment path tries the tiny.place RPC first and
/// only falls back to the public cluster on a *transport-level* failure (the
/// endpoint is unreachable), never on a well-formed JSON-RPC error (an
/// authoritative answer such as a real insufficient-funds result).
///
/// Priority:
/// 1. tiny.place's own Solana RPC — `TINYPLACE_SOLANA_RPC_URL` if set, else
/// derived from the tiny.place API base (`<TINYPLACE_API_BASE_URL>/solana/rpc`,
/// default `https://api.tiny.place/solana/rpc`) so it tracks prod/staging.
/// 2. `solana_cluster().rpc_url()` — public mainnet (or devnet) as fallback.
///
/// General wallet operations are unaffected — they keep using the single
/// [`rpc_url_for_chain`] endpoint.
pub fn tinyplace_solana_rpc_endpoints() -> Vec<String> {
let mut endpoints: Vec<String> = vec![
tinyplace_solana_rpc_url(),
solana_cluster().rpc_url().to_string(),
];
// De-dup while preserving order (the override may equal the public URL).
let mut seen = std::collections::HashSet::new();
endpoints.retain(|url| seen.insert(url.clone()));
endpoints
}
/// tiny.place's Solana settlement RPC — explicit `TINYPLACE_SOLANA_RPC_URL`
/// override, else `<TINYPLACE_API_BASE_URL>/solana/rpc` (default base
/// `https://api.tiny.place`).
fn tinyplace_solana_rpc_url() -> String {
if let Some(url) = env_nonempty("TINYPLACE_SOLANA_RPC_URL") {
return url;
}
let base = env_nonempty("TINYPLACE_API_BASE_URL")
.unwrap_or_else(|| TINYPLACE_API_BASE_DEFAULT.to_string());
format!("{}/solana/rpc", base.trim_end_matches('/'))
}
/// Trimmed value of `env_var`, or `None` when unset/blank.
fn env_nonempty(env_var: &str) -> Option<String> {
std::env::var(env_var)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn rpc_url_for_evm_network(network: EvmNetwork) -> String {
network.rpc_url()
}
@@ -617,4 +671,83 @@ mod tests {
assert_eq!(solana_cluster(), SolanaCluster::Mainnet);
});
}
// ── tiny.place Solana settlement endpoints ────────────────────────────────
//
// These read TINYPLACE_* env in addition to the cluster, so serialise them
// under the same lock as the cluster tests and always restore prior values.
fn with_tinyplace_env<T>(
rpc_url: Option<&str>,
api_base: Option<&str>,
cluster: Option<&str>,
f: impl FnOnce() -> T,
) -> T {
let _guard = CLUSTER_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let prev_rpc = std::env::var("TINYPLACE_SOLANA_RPC_URL").ok();
let prev_base = std::env::var("TINYPLACE_API_BASE_URL").ok();
let prev_cluster = std::env::var("OPENHUMAN_SOLANA_CLUSTER").ok();
let set = |key: &str, val: Option<&str>| match val {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
};
set("TINYPLACE_SOLANA_RPC_URL", rpc_url);
set("TINYPLACE_API_BASE_URL", api_base);
set("OPENHUMAN_SOLANA_CLUSTER", cluster);
// Catch panics (e.g. assertion failures) so env is always restored —
// otherwise a failing test leaks process-global env into later tests.
let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
set("TINYPLACE_SOLANA_RPC_URL", prev_rpc.as_deref());
set("TINYPLACE_API_BASE_URL", prev_base.as_deref());
set("OPENHUMAN_SOLANA_CLUSTER", prev_cluster.as_deref());
match out {
Ok(v) => v,
Err(panic) => std::panic::resume_unwind(panic),
}
}
#[test]
fn tinyplace_endpoints_default_to_api_tiny_place_then_public_mainnet() {
with_tinyplace_env(None, None, None, || {
assert_eq!(
tinyplace_solana_rpc_endpoints(),
vec![
"https://api.tiny.place/solana/rpc".to_string(),
DEFAULT_SOLANA_RPC_URL.to_string(),
]
);
});
}
#[test]
fn tinyplace_endpoints_derive_primary_from_api_base() {
with_tinyplace_env(None, Some("https://staging-api.tiny.place/"), None, || {
// Trailing slash trimmed; primary tracks the configured API base.
assert_eq!(
tinyplace_solana_rpc_endpoints()[0],
"https://staging-api.tiny.place/solana/rpc"
);
});
}
#[test]
fn tinyplace_endpoints_explicit_override_wins_as_primary() {
with_tinyplace_env(Some("https://my-rpc.example/rpc"), None, None, || {
let endpoints = tinyplace_solana_rpc_endpoints();
assert_eq!(endpoints[0], "https://my-rpc.example/rpc");
assert_eq!(endpoints[1], DEFAULT_SOLANA_RPC_URL);
});
}
#[test]
fn tinyplace_endpoints_dedup_when_primary_equals_public() {
with_tinyplace_env(Some(DEFAULT_SOLANA_RPC_URL), None, None, || {
// Primary == public fallback collapses to a single endpoint.
assert_eq!(
tinyplace_solana_rpc_endpoints(),
vec![DEFAULT_SOLANA_RPC_URL.to_string()]
);
});
}
}
+2 -2
View File
@@ -22,8 +22,8 @@ pub(crate) use chains::solana::tinyplace_signer_seed;
pub use defaults::{
asset_catalog, default_rpc_url, env_var_for_chain, evm_asset_catalog, explorer_tx_url,
find_asset, find_asset_for_network, network_defaults, rpc_source_for_chain, rpc_url_for_chain,
rpc_url_for_evm_network, solana_cluster, EvmNetwork, RpcSource, SolanaCluster,
WalletAssetDefinition, WalletNetworkDefaults,
rpc_url_for_evm_network, solana_cluster, tinyplace_solana_rpc_endpoints, EvmNetwork, RpcSource,
SolanaCluster, WalletAssetDefinition, WalletNetworkDefaults,
};
pub use execution::{
balances, chain_status, execute_prepared, lookup_tx,
+110 -17
View File
@@ -40,15 +40,93 @@ fn client() -> reqwest::Client {
SHARED_CLIENT.clone()
}
tokio::task_local! {
/// Ordered Solana RPC endpoints active for the current async task. Set only
/// by the tiny.place settlement path (see [`with_tinyplace_solana_endpoints`])
/// so Solana `rpc_call`s made while broadcasting a tiny.place payment try the
/// tiny.place RPC first and fall back to the public cluster. Unset for all
/// general wallet operations.
static TINYPLACE_SOLANA_ENDPOINTS: Vec<String>;
}
/// Run `fut` with the tiny.place Solana failover endpoint list active for any
/// Solana [`rpc_call`] made within it. Scoped to the current async task, so
/// concurrent general wallet operations are unaffected.
pub async fn with_tinyplace_solana_endpoints<F, T>(endpoints: Vec<String>, fut: F) -> T
where
F: std::future::Future<Output = T>,
{
TINYPLACE_SOLANA_ENDPOINTS.scope(endpoints, fut).await
}
fn active_tinyplace_endpoints() -> Option<Vec<String>> {
TINYPLACE_SOLANA_ENDPOINTS.try_with(|e| e.clone()).ok()
}
/// Distinguishes an endpoint being unreachable/broken (retry the next fallback)
/// from a healthy endpoint returning an authoritative JSON-RPC error (do NOT
/// fall back — e.g. a real insufficient-funds result must surface as-is).
enum RpcCallError {
Transport(String),
Rpc(String),
}
/// JSON-RPC POST against a chain's default/override endpoint.
///
/// For Solana, when a tiny.place settlement scope is active
/// ([`with_tinyplace_solana_endpoints`]) the call fails over across the
/// tiny.place → public endpoint list on transport errors. All other chains and
/// non-tiny.place Solana calls use the single configured endpoint.
pub async fn rpc_call<T: DeserializeOwned>(
chain: WalletChain,
method: &str,
params: Value,
) -> Result<T, String> {
if chain == WalletChain::Solana {
if let Some(endpoints) = active_tinyplace_endpoints() {
return rpc_call_failover(&endpoints, method, params).await;
}
}
rpc_call_to(&rpc_url_for_chain(chain), method, params).await
}
/// Try each endpoint in order, advancing only on transport-level failures.
async fn rpc_call_failover<T: DeserializeOwned>(
endpoints: &[String],
method: &str,
params: Value,
) -> Result<T, String> {
let total = endpoints.len();
let mut last_transport: Option<String> = None;
for (idx, url) in endpoints.iter().enumerate() {
match rpc_call_to_inner::<T>(url, method, params.clone()).await {
Ok(value) => {
if idx > 0 {
log::warn!(
"{LOG_PREFIX} tinyplace solana rpc: {method} succeeded on fallback \
endpoint #{idx} ({})",
redact_rpc_url(url)
);
}
return Ok(value);
}
// Authoritative answer from a healthy endpoint — surface it, never
// fall back (e.g. a genuine simulation/insufficient-funds error).
Err(RpcCallError::Rpc(message)) => return Err(message),
Err(RpcCallError::Transport(message)) => {
log::warn!(
"{LOG_PREFIX} tinyplace solana rpc: endpoint #{idx}/{total} ({}) unreachable \
for {method}, trying fallback: {message}",
redact_rpc_url(url)
);
last_transport = Some(message);
}
}
}
Err(last_transport
.unwrap_or_else(|| format!("wallet RPC: no Solana endpoints configured for {method}")))
}
/// JSON-RPC POST against a specific EVM network's RPC URL.
pub async fn evm_rpc_call<T: DeserializeOwned>(
network: EvmNetwork,
@@ -63,6 +141,20 @@ pub async fn rpc_call_to<T: DeserializeOwned>(
method: &str,
params: Value,
) -> Result<T, String> {
rpc_call_to_inner(url, method, params)
.await
.map_err(|e| match e {
RpcCallError::Transport(message) | RpcCallError::Rpc(message) => message,
})
}
/// JSON-RPC POST returning a typed error so callers can distinguish endpoint
/// unreachability (retryable on a fallback) from an authoritative JSON-RPC error.
async fn rpc_call_to_inner<T: DeserializeOwned>(
url: &str,
method: &str,
params: Value,
) -> Result<T, RpcCallError> {
let payload = json!({
"jsonrpc": "2.0",
"id": 1,
@@ -75,37 +167,38 @@ pub async fn rpc_call_to<T: DeserializeOwned>(
redact_rpc_url(url)
);
let client = client();
let response = client
.post(url)
.json(&payload)
.send()
.await
.map_err(|e| format!("wallet RPC transport failed for {method}: {e}"))?;
let response = client.post(url).json(&payload).send().await.map_err(|e| {
RpcCallError::Transport(format!("wallet RPC transport failed for {method}: {e}"))
})?;
let status = response.status();
let raw_body = response
.text()
.await
.map_err(|e| format!("wallet RPC read body failed for {method}: {e}"))?;
let raw_body = response.text().await.map_err(|e| {
RpcCallError::Transport(format!("wallet RPC read body failed for {method}: {e}"))
})?;
log::debug!(
"{LOG_PREFIX} jsonrpc method={method} status={status} body_len={}",
raw_body.len()
);
if !status.is_success() {
return Err(format!(
return Err(RpcCallError::Transport(format!(
"wallet RPC HTTP failure for {method}: status={status} body={raw_body}"
));
)));
}
let body: Value = serde_json::from_str(&raw_body)
.map_err(|e| format!("wallet RPC decode failed for {method}: {e}; body={raw_body}"))?;
let body: Value = serde_json::from_str(&raw_body).map_err(|e| {
RpcCallError::Transport(format!(
"wallet RPC decode failed for {method}: {e}; body={raw_body}"
))
})?;
if let Some(error) = body.get("error") {
return Err(format!("wallet RPC error for {method}: {error}"));
return Err(RpcCallError::Rpc(format!(
"wallet RPC error for {method}: {error}"
)));
}
let result = body
.get("result")
.cloned()
.ok_or_else(|| format!("wallet RPC missing result for {method}"))?;
.ok_or_else(|| RpcCallError::Rpc(format!("wallet RPC missing result for {method}")))?;
serde_json::from_value(result)
.map_err(|e| format!("wallet RPC invalid result for {method}: {e}"))
.map_err(|e| RpcCallError::Rpc(format!("wallet RPC invalid result for {method}: {e}")))
}
/// Plain REST GET returning JSON or text.