mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: chat sidebar rework, Agent World UX polish, tiny.place settlement RPC (#3854)
This commit is contained in:
+10
-1
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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('—');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
✓
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`.
|
||||
@@ -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(¶ms, "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(¶ms, "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(¶ms, "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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()]
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user