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.
|
# TINYPLACE_API_BASE_URL when testing tiny.place on devnet.
|
||||||
# OPENHUMAN_SOLANA_CLUSTER=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
|
# x402 Machine Payments
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -365,7 +373,8 @@ RUST_BACKTRACE=1
|
|||||||
# tiny.place Agent World integration
|
# tiny.place Agent World integration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# [optional] Base URL for the tiny.place backend API.
|
# [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
|
# TINYPLACE_API_BASE_URL=https://staging-api.tiny.place
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Generated
+2
-2
@@ -7913,9 +7913,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyplace"
|
name = "tinyplace"
|
||||||
version = "0.6.0"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46921f859da27a14a5526b289b32399c5c328c26de1a1dcb3f68b0f9d19310ea"
|
checksum = "abf658d89a0e986a4dc5cabfbd005ba63455c70348aca60d836e333010acc9e2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ crate-type = ["rlib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# tiny.place A2A social network SDK — published on crates.io (tinyhumansai/tiny.place)
|
# 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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_repr = "0.1"
|
serde_repr = "0.1"
|
||||||
|
|||||||
Generated
+2
-2
@@ -8803,9 +8803,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyplace"
|
name = "tinyplace"
|
||||||
version = "0.6.0"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46921f859da27a14a5526b289b32399c5c328c26de1a1dcb3f68b0f9d19310ea"
|
checksum = "abf658d89a0e986a4dc5cabfbd005ba63455c70348aca60d836e333010acc9e2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"async-trait",
|
"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 userEvent from '@testing-library/user-event';
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { openUrl } from '../../utils/openUrl';
|
||||||
import X402ConfirmDialog, {
|
import X402ConfirmDialog, {
|
||||||
formatUnits,
|
formatUnits,
|
||||||
|
fundingUrl,
|
||||||
isInsufficient,
|
isInsufficient,
|
||||||
type X402WalletBalance,
|
type X402WalletBalance,
|
||||||
} from './X402ConfirmDialog';
|
} from './X402ConfirmDialog';
|
||||||
|
|
||||||
|
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||||
|
|
||||||
const BALANCE: X402WalletBalance = {
|
const BALANCE: X402WalletBalance = {
|
||||||
raw: '50000000',
|
raw: '50000000',
|
||||||
formatted: '50',
|
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', () => {
|
describe('X402ConfirmDialog', () => {
|
||||||
test('renders amount, asset, network, balance and a truncated wallet', () => {
|
test('renders amount, asset, network, balance and a truncated wallet', () => {
|
||||||
render(<X402ConfirmDialog {...baseProps()} />);
|
render(<X402ConfirmDialog {...baseProps()} />);
|
||||||
@@ -88,10 +106,16 @@ describe('X402ConfirmDialog', () => {
|
|||||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
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" />);
|
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();
|
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', () => {
|
test('shows "Unknown" balance and still allows confirm when balance is null', () => {
|
||||||
|
|||||||
@@ -12,6 +12,22 @@
|
|||||||
*/
|
*/
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import { ModalShell } from '../../components/ui/ModalShell';
|
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 {
|
export interface X402WalletBalance {
|
||||||
/** Balance in raw base units (same scale as the challenge amount). */
|
/** Balance in raw base units (same scale as the challenge amount). */
|
||||||
@@ -100,7 +116,10 @@ export default function X402ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: X402ConfirmDialogProps) {
|
}: 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 amountDisplay = formatUnits(amount, decimals);
|
||||||
const insufficient = isInsufficient(balance, amount);
|
const insufficient = isInsufficient(balance, amount);
|
||||||
const confirmDisabled = busy || insufficient;
|
const confirmDisabled = busy || insufficient;
|
||||||
@@ -118,7 +137,7 @@ export default function X402ConfirmDialog({
|
|||||||
<span
|
<span
|
||||||
className="font-semibold text-stone-900 dark:text-neutral-100"
|
className="font-semibold text-stone-900 dark:text-neutral-100"
|
||||||
data-testid="x402-amount">
|
data-testid="x402-amount">
|
||||||
{amountDisplay} {asset}
|
{amountDisplay} {assetSymbol}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Network">
|
<Row label="Network">
|
||||||
@@ -144,7 +163,8 @@ export default function X402ConfirmDialog({
|
|||||||
|
|
||||||
{insufficient ? (
|
{insufficient ? (
|
||||||
<p className="text-xs text-coral-500" data-testid="x402-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>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-stone-400 dark:text-neutral-500">
|
<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}>
|
<Button variant="secondary" size="sm" onClick={onCancel} disabled={busy}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{insufficient ? (
|
||||||
variant="primary"
|
// Not enough balance — send the user to the tiny.place fund page for
|
||||||
size="sm"
|
// the exact wallet + asset instead of a dead, disabled Pay button.
|
||||||
onClick={onConfirm}
|
<Button
|
||||||
disabled={confirmDisabled}
|
variant="primary"
|
||||||
data-testid="x402-confirm">
|
size="sm"
|
||||||
{busy ? busyLabel : 'Confirm & Pay'}
|
onClick={() => {
|
||||||
</Button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</ModalShell>
|
</ModalShell>
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ const navIcon = (d: string) => (
|
|||||||
// Format: { slug: '<path-segment>', labelKey: 'agentWorld.<name>', iconPath: '<svg d>' }
|
// 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.
|
// 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
|
// Sidebar order: Feed first, then Messages, then the rest; Profiles sits at the
|
||||||
// end. Marketplace and Jobs are intentionally OMITTED from the sidebar (their
|
// end. Marketplace, Jobs and Explore are intentionally OMITTED from the sidebar
|
||||||
// routes still exist below so buy/bid/offer and job-apply flows remain
|
// (their routes still exist below so existing flows / deep links remain
|
||||||
// reachable) — hidden, not removed.
|
// reachable) — hidden, not removed. Jobs is superseded by Bounties.
|
||||||
const SECTIONS: AgentWorldSection[] = [
|
const SECTIONS: AgentWorldSection[] = [
|
||||||
{
|
{
|
||||||
slug: 'feed',
|
slug: 'feed',
|
||||||
@@ -72,11 +72,6 @@ const SECTIONS: AgentWorldSection[] = [
|
|||||||
iconPath:
|
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',
|
'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',
|
slug: 'directory',
|
||||||
labelKey: 'agentWorld.directory',
|
labelKey: 'agentWorld.directory',
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ vi.mock('../AgentWorldShell', () => ({
|
|||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
fund: vi.fn(),
|
|
||||||
cancel: vi.fn(),
|
cancel: vi.fn(),
|
||||||
submit: vi.fn(),
|
submit: vi.fn(),
|
||||||
listSubmissions: 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 ───────────────────────────────────────────────────────────────
|
// ── Cancel flow ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Cancel flow', () => {
|
describe('Cancel flow', () => {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ import {
|
|||||||
import { fetchWalletStatus } from '../../services/walletApi';
|
import { fetchWalletStatus } from '../../services/walletApi';
|
||||||
import type { ToastNotification } from '../../types/intelligence';
|
import type { ToastNotification } from '../../types/intelligence';
|
||||||
import { apiClient } from '../AgentWorldShell';
|
import { apiClient } from '../AgentWorldShell';
|
||||||
|
import { decimalsForAsset, resolveAssetSymbol } from '../assets';
|
||||||
import X402ConfirmDialog, { formatUnits } from '../components/X402ConfirmDialog';
|
import X402ConfirmDialog, { formatUnits } from '../components/X402ConfirmDialog';
|
||||||
import { useX402Buy } from '../hooks/useX402Buy';
|
|
||||||
|
|
||||||
// ── State types ───────────────────────────────────────────────────────────────
|
// ── State types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -72,19 +72,12 @@ function abbrev(addr: string): string {
|
|||||||
return addr;
|
return addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decimals for a given asset symbol. USDC = 6, SOL = 9, others = 0. */
|
/** Format a base-unit reward amount to a human-readable string. `asset` may be
|
||||||
function decimalsForAsset(asset: string): number {
|
* a symbol or a mint address — {@link resolveAssetSymbol} normalises it. */
|
||||||
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. */
|
|
||||||
function formatReward(amount: string, asset: string): string {
|
function formatReward(amount: string, asset: string): string {
|
||||||
const decimals = decimalsForAsset(asset);
|
const decimals = decimalsForAsset(asset);
|
||||||
const display = decimals > 0 ? formatUnits(amount, decimals) : amount;
|
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. */
|
/** Centered status message for loading / error / info states. */
|
||||||
@@ -142,7 +135,6 @@ interface BountyRowProps {
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
myAgentId: string | null;
|
myAgentId: string | null;
|
||||||
onFund: (bountyId: string) => void;
|
|
||||||
onSubmit: (bountyId: string) => void;
|
onSubmit: (bountyId: string) => void;
|
||||||
onComment: (bountyId: string) => void;
|
onComment: (bountyId: string) => void;
|
||||||
onCancel: (bountyId: string) => void;
|
onCancel: (bountyId: string) => void;
|
||||||
@@ -155,7 +147,6 @@ function BountyRow({
|
|||||||
expanded,
|
expanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
myAgentId,
|
myAgentId,
|
||||||
onFund,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onComment,
|
onComment,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -183,8 +174,13 @@ function BountyRow({
|
|||||||
}, [expanded, bounty.bountyId]);
|
}, [expanded, bounty.bountyId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-stone-200 last:border-b-0 dark:border-neutral-800">
|
<div
|
||||||
{/* Summary row */}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@@ -416,12 +412,6 @@ function BountyRow({
|
|||||||
{/* Action buttons (wallet-gated) */}
|
{/* Action buttons (wallet-gated) */}
|
||||||
{myAgentId ? (
|
{myAgentId ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 */}
|
{/* Submit Work: non-creator + open status */}
|
||||||
{!isCreator && bounty.status === 'open' && (
|
{!isCreator && bounty.status === 'open' && (
|
||||||
<Button type="button" onClick={() => onSubmit(bounty.bountyId)} disabled={mutating}>
|
<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 [submitWorkBountyId, setSubmitWorkBountyId] = useState<string | null>(null);
|
||||||
const [commentBountyId, setCommentBountyId] = 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(() => {
|
const fetchBounties = useCallback(() => {
|
||||||
setState({ status: 'loading' });
|
setState({ status: 'loading' });
|
||||||
void apiClient.bounties
|
void apiClient.bounties
|
||||||
@@ -900,13 +886,6 @@ export default function BountiesSection() {
|
|||||||
fetchBounties();
|
fetchBounties();
|
||||||
}, [fetchBounties]);
|
}, [fetchBounties]);
|
||||||
|
|
||||||
// ── Fund flow ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleFund(bountyId: string) {
|
|
||||||
setFundingBountyId(bountyId);
|
|
||||||
fundX402.begin(bountyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cancel ─────────────────────────────────────────────────────────────────
|
// ── Cancel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleCancel(bountyId: string) {
|
async function handleCancel(bountyId: string) {
|
||||||
@@ -962,7 +941,7 @@ export default function BountiesSection() {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
body = (
|
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 => (
|
{state.bounties.map(bounty => (
|
||||||
<BountyRow
|
<BountyRow
|
||||||
key={bounty.bountyId}
|
key={bounty.bountyId}
|
||||||
@@ -972,7 +951,6 @@ export default function BountiesSection() {
|
|||||||
setExpandedBountyId(prev => (prev === bounty.bountyId ? null : bounty.bountyId))
|
setExpandedBountyId(prev => (prev === bounty.bountyId ? null : bounty.bountyId))
|
||||||
}
|
}
|
||||||
myAgentId={myAgentId}
|
myAgentId={myAgentId}
|
||||||
onFund={handleFund}
|
|
||||||
onSubmit={id => setSubmitWorkBountyId(id)}
|
onSubmit={id => setSubmitWorkBountyId(id)}
|
||||||
onComment={id => setCommentBountyId(id)}
|
onComment={id => setCommentBountyId(id)}
|
||||||
onCancel={id => {
|
onCancel={id => {
|
||||||
@@ -1020,10 +998,8 @@ export default function BountiesSection() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
fetchBounties();
|
fetchBounties();
|
||||||
// If the new bounty is a draft, offer to fund it
|
// In SDK 0.10 create-and-fund is atomic, so a new bounty is already
|
||||||
if ((bounty as Bounty).status === 'draft') {
|
// funded (`open`) — no separate fund step.
|
||||||
handleFund((bounty as Bounty).bountyId);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -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} />
|
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
||||||
</PanelScaffold>
|
</PanelScaffold>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ import DirectorySection from './DirectorySection';
|
|||||||
vi.mock('../AgentWorldShell', () => ({
|
vi.mock('../AgentWorldShell', () => ({
|
||||||
apiClient: {
|
apiClient: {
|
||||||
directory: { listAgents: vi.fn() },
|
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 listAgents = vi.mocked(apiClient.directory.listAgents);
|
||||||
const walletStatus = vi.mocked(fetchWalletStatus);
|
const walletStatus = vi.mocked(fetchWalletStatus);
|
||||||
const followStats = vi.mocked(apiClient.follows.stats);
|
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 followFollow = vi.mocked(apiClient.follows.follow);
|
||||||
const followUnfollow = vi.mocked(apiClient.follows.unfollow);
|
const followUnfollow = vi.mocked(apiClient.follows.unfollow);
|
||||||
|
|
||||||
@@ -41,9 +47,9 @@ beforeEach(() => {
|
|||||||
walletStatus.mockResolvedValue({
|
walletStatus.mockResolvedValue({
|
||||||
accounts: [{ chain: 'solana', address: 'MyWaLLetAddr123' }],
|
accounts: [{ chain: 'solana', address: 'MyWaLLetAddr123' }],
|
||||||
} as unknown as Awaited<ReturnType<typeof fetchWalletStatus>>);
|
} 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 });
|
followStats.mockResolvedValue({ agentId: '', followerCount: 0, followingCount: 0 });
|
||||||
followFollowers.mockResolvedValue({ followers: [] });
|
followFollowing.mockResolvedValue({ following: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Loading state ──────────────────────────────────────────────────────────────
|
// ── Loading state ──────────────────────────────────────────────────────────────
|
||||||
@@ -329,7 +335,7 @@ describe('follow button', () => {
|
|||||||
listAgents.mockResolvedValueOnce({
|
listAgents.mockResolvedValueOnce({
|
||||||
agents: [{ agentId: 'other-agent-001', username: 'alice', name: 'Alice' }],
|
agents: [{ agentId: 'other-agent-001', username: 'alice', name: 'Alice' }],
|
||||||
});
|
});
|
||||||
followFollowers.mockResolvedValueOnce({ followers: [] });
|
followFollowing.mockResolvedValueOnce({ following: [] });
|
||||||
followStats.mockResolvedValueOnce({
|
followStats.mockResolvedValueOnce({
|
||||||
agentId: 'other-agent-001',
|
agentId: 'other-agent-001',
|
||||||
followerCount: 5,
|
followerCount: 5,
|
||||||
@@ -347,8 +353,8 @@ describe('follow button', () => {
|
|||||||
listAgents.mockResolvedValueOnce({
|
listAgents.mockResolvedValueOnce({
|
||||||
agents: [{ agentId: 'other-agent-002', username: 'bob', name: 'Bob' }],
|
agents: [{ agentId: 'other-agent-002', username: 'bob', name: 'Bob' }],
|
||||||
});
|
});
|
||||||
followFollowers.mockResolvedValueOnce({
|
followFollowing.mockResolvedValueOnce({
|
||||||
followers: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-002', createdAt: '' }],
|
following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-002', createdAt: '' }],
|
||||||
});
|
});
|
||||||
followStats.mockResolvedValueOnce({
|
followStats.mockResolvedValueOnce({
|
||||||
agentId: 'other-agent-002',
|
agentId: 'other-agent-002',
|
||||||
@@ -375,7 +381,7 @@ describe('follow button', () => {
|
|||||||
listAgents.mockResolvedValueOnce({
|
listAgents.mockResolvedValueOnce({
|
||||||
agents: [{ agentId: 'other-agent-003', username: 'carol', name: 'Carol' }],
|
agents: [{ agentId: 'other-agent-003', username: 'carol', name: 'Carol' }],
|
||||||
});
|
});
|
||||||
followFollowers.mockResolvedValueOnce({ followers: [] });
|
followFollowing.mockResolvedValueOnce({ following: [] });
|
||||||
followStats.mockResolvedValueOnce({
|
followStats.mockResolvedValueOnce({
|
||||||
agentId: 'other-agent-003',
|
agentId: 'other-agent-003',
|
||||||
followerCount: 0,
|
followerCount: 0,
|
||||||
@@ -393,13 +399,36 @@ describe('follow button', () => {
|
|||||||
expect(await screen.findByText('Following')).toBeInTheDocument();
|
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 () => {
|
test('clicking Following calls follows.unfollow and reverts to Follow', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
listAgents.mockResolvedValueOnce({
|
listAgents.mockResolvedValueOnce({
|
||||||
agents: [{ agentId: 'other-agent-004', username: 'dave', name: 'Dave' }],
|
agents: [{ agentId: 'other-agent-004', username: 'dave', name: 'Dave' }],
|
||||||
});
|
});
|
||||||
followFollowers.mockResolvedValueOnce({
|
followFollowing.mockResolvedValueOnce({
|
||||||
followers: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-004', createdAt: '' }],
|
following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-004', createdAt: '' }],
|
||||||
});
|
});
|
||||||
followStats.mockResolvedValueOnce({
|
followStats.mockResolvedValueOnce({
|
||||||
agentId: 'other-agent-004',
|
agentId: 'other-agent-004',
|
||||||
|
|||||||
@@ -124,6 +124,48 @@ function useMyAgentId(): string | null {
|
|||||||
return agentId;
|
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 ────────────────────────────────────────────────────────────
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CARD_CLASS =
|
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 [selected, setSelected] = useState(false);
|
||||||
const [followState, setFollowState] = useState<'unknown' | 'following' | 'not_following'>(
|
// Optimistic override applied after the user (un)follows from this card; null
|
||||||
'unknown'
|
// 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 [followerCount, setFollowerCount] = useState<number | null>(null);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const handle = getHandle(agent);
|
const handle = getHandle(agent);
|
||||||
const skills = getSkills(agent);
|
const skills = getSkills(agent);
|
||||||
const isSelf = myAgentId != null && agent.agentId === myAgentId;
|
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.
|
// 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void apiClient.follows
|
void apiClient.follows
|
||||||
@@ -179,25 +245,6 @@ function AgentCardItem({ agent, myAgentId }: { agent: AgentCard; myAgentId: stri
|
|||||||
};
|
};
|
||||||
}, [agent.agentId]);
|
}, [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(
|
const handleFollow = useCallback(
|
||||||
async (e: React.MouseEvent) => {
|
async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -206,12 +253,12 @@ function AgentCardItem({ agent, myAgentId }: { agent: AgentCard; myAgentId: stri
|
|||||||
try {
|
try {
|
||||||
if (followState === 'following') {
|
if (followState === 'following') {
|
||||||
await apiClient.follows.unfollow(agent.agentId);
|
await apiClient.follows.unfollow(agent.agentId);
|
||||||
setFollowState('not_following');
|
setLocalFollow('not_following');
|
||||||
setFollowerCount(c => (c != null ? c - 1 : c));
|
setFollowerCount(c => (c != null ? Math.max(0, c - 1) : c));
|
||||||
debug('unfollowed %s', agent.agentId);
|
debug('unfollowed %s', agent.agentId);
|
||||||
} else {
|
} else {
|
||||||
await apiClient.follows.follow(agent.agentId);
|
await apiClient.follows.follow(agent.agentId);
|
||||||
setFollowState('following');
|
setLocalFollow('following');
|
||||||
setFollowerCount(c => (c != null ? c + 1 : c));
|
setFollowerCount(c => (c != null ? c + 1 : c));
|
||||||
debug('followed %s', agent.agentId);
|
debug('followed %s', agent.agentId);
|
||||||
}
|
}
|
||||||
@@ -309,6 +356,8 @@ function StatusBlock({ tone, title, body }: { tone: string; title: string; body?
|
|||||||
export default function DirectorySection() {
|
export default function DirectorySection() {
|
||||||
const state = useDirectoryAgents();
|
const state = useDirectoryAgents();
|
||||||
const myAgentId = useMyAgentId();
|
const myAgentId = useMyAgentId();
|
||||||
|
// One batched lookup of who we follow, shared by every card below.
|
||||||
|
const followingSet = useMyFollowing(myAgentId);
|
||||||
|
|
||||||
let body: React.ReactNode;
|
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">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{agents.map(agent => (
|
{agents.map(agent => (
|
||||||
<AgentCardItem key={agent.agentId} agent={agent} myAgentId={myAgentId} />
|
<AgentCardItem
|
||||||
|
key={agent.agentId}
|
||||||
|
agent={agent}
|
||||||
|
myAgentId={myAgentId}
|
||||||
|
followingSet={followingSet}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 ───────────────────────────────────────────────────────────
|
// ── Follow/Unfollow ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Follow/Unfollow', () => {
|
describe('Follow/Unfollow', () => {
|
||||||
@@ -500,14 +414,16 @@ describe('like toggle', () => {
|
|||||||
// ── Comment composer ──────────────────────────────────────────────────────────
|
// ── Comment composer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('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();
|
const user = userEvent.setup();
|
||||||
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
||||||
render(<FeedSection />);
|
render(<FeedSection />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByPlaceholderText(/write a comment/i)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/write a comment/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -520,7 +436,7 @@ describe('comment composer', () => {
|
|||||||
'My test comment'
|
'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);
|
expect(vi.mocked(apiClient.graphql.post)).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -531,11 +447,8 @@ describe('comment composer', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
|
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(() => {
|
const input = await screen.findByPlaceholderText(/write a comment/i);
|
||||||
expect(screen.getByPlaceholderText(/write a comment/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
const input = screen.getByPlaceholderText(/write a comment/i);
|
|
||||||
await user.type(input, 'test');
|
await user.type(input, 'test');
|
||||||
await user.click(screen.getByRole('button', { name: /^comment$/i }));
|
await user.click(screen.getByRole('button', { name: /^comment$/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -544,15 +457,17 @@ describe('comment composer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('comment composer hidden when wallet locked', async () => {
|
test('comment composer hidden when wallet locked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
vi.mocked(fetchWalletStatus).mockRejectedValue(new Error('wallet locked'));
|
vi.mocked(fetchWalletStatus).mockRejectedValue(new Error('wallet locked'));
|
||||||
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
||||||
render(<FeedSection />);
|
render(<FeedSection />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
|
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(() => {
|
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();
|
expect(screen.queryByPlaceholderText(/write a comment/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -561,27 +476,20 @@ describe('comment composer', () => {
|
|||||||
// ── Post composer ─────────────────────────────────────────────────────────────
|
// ── Post composer ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('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 });
|
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
||||||
render(<FeedSection />);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByPlaceholderText(/what's on your mind/i)).toBeInTheDocument();
|
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 user.click(screen.getByRole('button', { name: /^post$/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(vi.mocked(apiClient.feeds.createPost)).toHaveBeenCalledWith('My new post');
|
expect(vi.mocked(apiClient.feeds.createPost)).toHaveBeenCalledWith('My new post');
|
||||||
@@ -592,14 +500,8 @@ describe('post composer', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
||||||
render(<FeedSection />);
|
render(<FeedSection />);
|
||||||
await waitFor(() => {
|
const textarea = await screen.findByPlaceholderText(/what's on your mind/i);
|
||||||
expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument();
|
await user.type(textarea, 'test post');
|
||||||
});
|
|
||||||
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');
|
|
||||||
await user.click(screen.getByRole('button', { name: /^post$/i }));
|
await user.click(screen.getByRole('button', { name: /^post$/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// homeFeed called once on mount + once after create
|
// 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();
|
const user = userEvent.setup();
|
||||||
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
||||||
render(<FeedSection />);
|
render(<FeedSection />);
|
||||||
await waitFor(() => {
|
const textarea = await screen.findByPlaceholderText(/what's on your mind/i);
|
||||||
expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /^post$/i })).toBeDisabled();
|
||||||
});
|
await user.type(textarea, 'hi');
|
||||||
await user.click(screen.getByRole('button', { name: /new post/i }));
|
expect(screen.getByRole('button', { name: /^post$/i })).toBeEnabled();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(fetchWalletStatus).mockRejectedValue(new Error('wallet locked'));
|
||||||
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
|
||||||
render(<FeedSection />);
|
render(<FeedSection />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Hello from the network')).toBeInTheDocument();
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Great post!')).toBeInTheDocument();
|
expect(screen.getByText('Great post!')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
// Delete button for own comment (use getByText to avoid matching parent wrappers)
|
|
||||||
const deleteBtn = screen.getByText('Delete');
|
const deleteBtn = screen.getByText('Delete');
|
||||||
await user.click(deleteBtn);
|
await user.click(deleteBtn);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -9,21 +9,19 @@
|
|||||||
* Phase A interactive features (wallet-gated):
|
* Phase A interactive features (wallet-gated):
|
||||||
* - Like / unlike toggle with optimistic update and server reconcile
|
* - Like / unlike toggle with optimistic update and server reconcile
|
||||||
* - Comment composer (adds comment, refetches detail via GraphQL)
|
* - 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)
|
* - Delete post / delete comment (own content only, with window.confirm)
|
||||||
*
|
*
|
||||||
* Pattern mirrors ExploreSection / MarketplaceSection: useState + useEffect
|
* Pattern mirrors ExploreSection / MarketplaceSection: useState + useEffect
|
||||||
* fetch, PanelScaffold wrapper, StatusBlock for loading/error/empty states.
|
* 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 PanelScaffold from '../../components/layout/PanelScaffold';
|
||||||
import { ModalShell } from '../../components/ui/ModalShell';
|
|
||||||
import {
|
import {
|
||||||
type GqlComment,
|
type GqlComment,
|
||||||
type GqlHomeFeedItem,
|
type GqlHomeFeedItem,
|
||||||
type GqlPost,
|
type GqlPost,
|
||||||
type GqlPostDetail,
|
|
||||||
type LikeResult,
|
type LikeResult,
|
||||||
PaymentRequiredError,
|
PaymentRequiredError,
|
||||||
} from '../../lib/agentworld/invokeApiClient';
|
} from '../../lib/agentworld/invokeApiClient';
|
||||||
@@ -38,11 +36,6 @@ type FeedState =
|
|||||||
| { status: 'error'; message: string }
|
| { status: 'error'; message: string }
|
||||||
| { status: 'ok'; items: GqlHomeFeedItem[] };
|
| { status: 'ok'; items: GqlHomeFeedItem[] };
|
||||||
|
|
||||||
type DetailState =
|
|
||||||
| { status: 'loading' }
|
|
||||||
| { status: 'error'; message: string }
|
|
||||||
| { status: 'ok'; detail: GqlPostDetail };
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function relativeTime(iso: string): string {
|
function relativeTime(iso: string): string {
|
||||||
@@ -155,26 +148,48 @@ function CommentComposer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PostComposerModal ─────────────────────────────────────────────────────────
|
// ── FeedComposer ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PostComposerModal({
|
/** Max post length, mirrors the tiny.place website composer. */
|
||||||
onClose,
|
const MAX_FEED_BODY_LENGTH = 500;
|
||||||
onPostCreated,
|
|
||||||
}: {
|
/**
|
||||||
onClose: () => void;
|
* 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;
|
onPostCreated: () => void;
|
||||||
}) {
|
}
|
||||||
const [body, setBody] = useState('');
|
|
||||||
|
function FeedComposer({ myAgentId, onPostCreated }: FeedComposerProps) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
// Auto-grow the textarea with its content (capped), so the composer expands
|
||||||
if (!body.trim() || submitting) return;
|
// 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);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await apiClient.feeds.createPost(body.trim());
|
await apiClient.feeds.createPost(body);
|
||||||
onClose();
|
setDraft('');
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
}
|
||||||
onPostCreated();
|
onPostCreated();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
setError(String(err));
|
||||||
@@ -184,48 +199,129 @@ function PostComposerModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalShell title="New Post" titleId="new-post-modal-title" onClose={onClose}>
|
<div className="mb-3 rounded-xl border border-stone-200 bg-white p-3 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
<div className="space-y-3">
|
<div className="flex gap-2.5">
|
||||||
|
<InitialAvatar name={myAgentId} />
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
ref={textareaRef}
|
||||||
onChange={e => setBody(e.target.value)}
|
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?"
|
placeholder="What's on your mind?"
|
||||||
rows={4}
|
rows={1}
|
||||||
|
maxLength={MAX_FEED_BODY_LENGTH}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm
|
aria-label="Write a post"
|
||||||
placeholder:text-stone-400 focus:border-primary-400 focus:outline-none
|
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"
|
||||||
dark:border-neutral-700 dark:bg-neutral-800 dark:placeholder:text-neutral-500
|
|
||||||
dark:focus:border-primary-600 disabled:opacity-50"
|
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={() => void submit()}
|
||||||
className="rounded-lg border border-stone-300 px-4 py-2 text-sm font-medium
|
disabled={!canPost}
|
||||||
text-stone-700 hover:bg-stone-50 dark:border-neutral-600
|
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">
|
||||||
dark:text-neutral-300 dark:hover:bg-neutral-800">
|
{submitting ? 'Posting…' : 'Post'}
|
||||||
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'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalShell>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PostCard ──────────────────────────────────────────────────────────────────
|
// ── 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({
|
function PostCard({
|
||||||
item,
|
item,
|
||||||
onClick,
|
|
||||||
myAgentId,
|
myAgentId,
|
||||||
followState,
|
followState,
|
||||||
followLoading,
|
followLoading,
|
||||||
@@ -235,7 +331,6 @@ function PostCard({
|
|||||||
onDeletePost,
|
onDeletePost,
|
||||||
}: {
|
}: {
|
||||||
item: GqlHomeFeedItem;
|
item: GqlHomeFeedItem;
|
||||||
onClick: (post: GqlPost) => void;
|
|
||||||
myAgentId: string | null;
|
myAgentId: string | null;
|
||||||
followState: Record<string, boolean>;
|
followState: Record<string, boolean>;
|
||||||
followLoading: Record<string, boolean>;
|
followLoading: Record<string, boolean>;
|
||||||
@@ -245,13 +340,10 @@ function PostCard({
|
|||||||
onDeletePost: (post: GqlPost) => void;
|
onDeletePost: (post: GqlPost) => void;
|
||||||
}) {
|
}) {
|
||||||
const { post } = item;
|
const { post } = item;
|
||||||
const truncated = post.body.length > 300 ? post.body.slice(0, 300) + '…' : post.body;
|
const [showComments, setShowComments] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<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">
|
||||||
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">
|
|
||||||
{/* Author row */}
|
{/* Author row */}
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
{post.author.avatarUrl ? (
|
{post.author.avatarUrl ? (
|
||||||
@@ -285,29 +377,23 @@ function PostCard({
|
|||||||
@{post.author.handle}
|
@{post.author.handle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{myAgentId && item.post.author.cryptoId !== myAgentId && (
|
{myAgentId && post.author.cryptoId !== myAgentId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={followLoading[item.post.author.cryptoId] ?? false}
|
disabled={followLoading[post.author.cryptoId] ?? false}
|
||||||
onClick={e => {
|
onClick={() => onToggleFollow(post.author.cryptoId)}
|
||||||
e.stopPropagation();
|
|
||||||
onToggleFollow(item.post.author.cryptoId);
|
|
||||||
}}
|
|
||||||
className={`ml-auto shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
|
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-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'
|
: '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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{myAgentId && post.author.cryptoId === myAgentId && (
|
{myAgentId && post.author.cryptoId === myAgentId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={e => {
|
onClick={() => onDeletePost(post)}
|
||||||
e.stopPropagation();
|
|
||||||
onDeletePost(post);
|
|
||||||
}}
|
|
||||||
className="ml-auto text-xs text-stone-400 hover:text-red-500 dark:text-neutral-500
|
className="ml-auto text-xs text-stone-400 hover:text-red-500 dark:text-neutral-500
|
||||||
dark:hover:text-red-400">
|
dark:hover:text-red-400">
|
||||||
Delete
|
Delete
|
||||||
@@ -316,23 +402,28 @@ function PostCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Post body */}
|
{/* Post body */}
|
||||||
<p className="mb-3 text-sm leading-relaxed text-stone-800 dark:text-neutral-200">
|
<p className="mb-3 whitespace-pre-wrap text-sm leading-relaxed text-stone-800 dark:text-neutral-200">
|
||||||
{truncated}
|
{post.body}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Metadata row */}
|
{/* Metadata row */}
|
||||||
<div className="flex items-center gap-4 text-xs text-stone-400 dark:text-neutral-500">
|
<div className="flex items-center gap-4 text-xs text-stone-400 dark:text-neutral-500">
|
||||||
<span>{relativeTime(post.createdAt)}</span>
|
<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'}
|
{post.commentCount} {post.commentCount === 1 ? 'comment' : 'comments'}
|
||||||
</span>
|
</button>
|
||||||
{myAgentId ? (
|
{myAgentId ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={e => {
|
onClick={() => onToggleLike(post)}
|
||||||
e.stopPropagation();
|
|
||||||
onToggleLike(post);
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-1 ${
|
className={`flex items-center gap-1 ${
|
||||||
(likeState[post.postId]?.liked ?? post.viewerHasLiked)
|
(likeState[post.postId]?.liked ?? post.viewerHasLiked)
|
||||||
? 'text-red-500'
|
? 'text-red-500'
|
||||||
@@ -353,11 +444,13 @@ function PostCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
|
{showComments && <InlineComments post={post} myAgentId={myAgentId} />}
|
||||||
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PostDetail ────────────────────────────────────────────────────────────────
|
// ── CommentRow ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CommentRow({
|
function CommentRow({
|
||||||
comment,
|
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) ─────────────────────────────────────────────────
|
// ── FeedSection (main export) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function FeedSection() {
|
export default function FeedSection() {
|
||||||
const [feedState, setFeedState] = useState<FeedState>({ status: 'loading' });
|
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 [followState, setFollowState] = useState<Record<string, boolean>>({});
|
||||||
const [followLoading, setFollowLoading] = useState<Record<string, boolean>>({});
|
const [followLoading, setFollowLoading] = useState<Record<string, boolean>>({});
|
||||||
const [likeState, setLikeState] = useState<Record<string, { liked: boolean; count: number }>>({});
|
const [likeState, setLikeState] = useState<Record<string, { liked: boolean; count: number }>>({});
|
||||||
const [showComposer, setShowComposer] = useState(false);
|
|
||||||
|
|
||||||
const myAgentId = useMyAgentId();
|
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 ──────────────────────────────────────────────────────
|
// ── Follow / Unfollow ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleToggleFollow = async (cryptoId: string) => {
|
const handleToggleFollow = async (cryptoId: string) => {
|
||||||
@@ -769,26 +642,6 @@ export default function FeedSection() {
|
|||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── 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;
|
let body: React.ReactNode;
|
||||||
|
|
||||||
if (feedState.status === 'loading') {
|
if (feedState.status === 'loading') {
|
||||||
@@ -834,7 +687,6 @@ export default function FeedSection() {
|
|||||||
<PostCard
|
<PostCard
|
||||||
key={item.post.postId}
|
key={item.post.postId}
|
||||||
item={item}
|
item={item}
|
||||||
onClick={setSelectedPost}
|
|
||||||
myAgentId={myAgentId}
|
myAgentId={myAgentId}
|
||||||
followState={followState}
|
followState={followState}
|
||||||
followLoading={followLoading}
|
followLoading={followLoading}
|
||||||
@@ -855,26 +707,9 @@ export default function FeedSection() {
|
|||||||
return (
|
return (
|
||||||
<PanelScaffold description="Social feed">
|
<PanelScaffold description="Social feed">
|
||||||
{myAgentId && feedState.status === 'ok' && (
|
{myAgentId && feedState.status === 'ok' && (
|
||||||
<div className="mb-3 flex justify-end">
|
<FeedComposer myAgentId={myAgentId} onPostCreated={refetchFeed} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
{body}
|
{body}
|
||||||
{showComposer && (
|
|
||||||
<PostComposerModal
|
|
||||||
onClose={() => setShowComposer(false)}
|
|
||||||
onPostCreated={() => {
|
|
||||||
setShowComposer(false);
|
|
||||||
refetchFeed();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PanelScaffold>
|
</PanelScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ describe('Register tab — x402 registration', () => {
|
|||||||
expect(screen.getByTestId('x402-confirm')).toBeEnabled();
|
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({
|
vi.mocked(apiClient.registry.register).mockResolvedValueOnce({
|
||||||
challenge: { amount: FREE_AMOUNT, asset: 'USDC', network: 'solana-devnet' },
|
challenge: { amount: FREE_AMOUNT, asset: 'USDC', network: 'solana-devnet' },
|
||||||
walletBalance: { raw: '1000000', formatted: '1', decimals: 6, assetSymbol: 'USDC' },
|
walletBalance: { raw: '1000000', formatted: '1', decimals: 6, assetSymbol: 'USDC' },
|
||||||
@@ -249,7 +249,9 @@ describe('Register tab — x402 registration', () => {
|
|||||||
await checkAndRegister('buyer');
|
await checkAndRegister('buyer');
|
||||||
|
|
||||||
expect(await screen.findByTestId('x402-insufficient')).toBeInTheDocument();
|
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 () => {
|
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 { type GqlLedgerTransaction } from '../../lib/agentworld/invokeApiClient';
|
||||||
import { apiClient } from '../AgentWorldShell';
|
import { apiClient } from '../AgentWorldShell';
|
||||||
import LedgerSection, { abbreviateAddress, formatAmount, StatusBadge } from './LedgerSection';
|
import LedgerSection, {
|
||||||
|
abbreviateAddress,
|
||||||
|
formatAmount,
|
||||||
|
formatLedgerAmount,
|
||||||
|
StatusBadge,
|
||||||
|
} from './LedgerSection';
|
||||||
|
|
||||||
vi.mock('../AgentWorldShell', () => ({
|
vi.mock('../AgentWorldShell', () => ({
|
||||||
apiClient: { graphql: { ledgerTransactions: vi.fn(), ledgerTransaction: vi.fn() } },
|
apiClient: { graphql: { ledgerTransactions: vi.fn(), ledgerTransaction: vi.fn() } },
|
||||||
@@ -27,7 +32,8 @@ const sampleTransaction: GqlLedgerTransaction = {
|
|||||||
type: 'REGISTRATION',
|
type: 'REGISTRATION',
|
||||||
from: 'AAAA1111bbbb2222cccc3333dddd4444eeee5555',
|
from: 'AAAA1111bbbb2222cccc3333dddd4444eeee5555',
|
||||||
to: 'FFFF6666gggg7777hhhh8888iiii9999jjjj0000',
|
to: 'FFFF6666gggg7777hhhh8888iiii9999jjjj0000',
|
||||||
amount: '0.50',
|
// Smallest base unit (USDC has 6 decimals): 500000 micro-USDC = 0.50 USDC.
|
||||||
|
amount: '500000',
|
||||||
asset: 'USDC',
|
asset: 'USDC',
|
||||||
network: 'solana-devnet',
|
network: 'solana-devnet',
|
||||||
timestamp: '2026-06-01T12:00:00Z',
|
timestamp: '2026-06-01T12:00:00Z',
|
||||||
@@ -71,7 +77,8 @@ describe('Ledger list', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('REGISTRATION')).toBeInTheDocument();
|
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('SETTLED')).toBeInTheDocument();
|
||||||
expect(screen.getByText('View on chain')).toBeInTheDocument();
|
expect(screen.getByText('View on chain')).toBeInTheDocument();
|
||||||
// Network shown as a friendly label, not the raw genesis hash.
|
// Network shown as a friendly label, not the raw genesis hash.
|
||||||
@@ -219,3 +226,30 @@ describe('formatAmount', () => {
|
|||||||
expect(formatAmount('n/a')).toBe('n/a');
|
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 PanelScaffold from '../../components/layout/PanelScaffold';
|
||||||
import { type GqlLedgerTransaction } from '../../lib/agentworld/invokeApiClient';
|
import { type GqlLedgerTransaction } from '../../lib/agentworld/invokeApiClient';
|
||||||
import { apiClient } from '../AgentWorldShell';
|
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';
|
import { explorerTxUrl } from '../hooks/useX402Buy';
|
||||||
|
|
||||||
// ── State types ───────────────────────────────────────────────────────────────
|
// ── State types ───────────────────────────────────────────────────────────────
|
||||||
@@ -59,6 +60,22 @@ export function formatAmount(amount: string | undefined): string {
|
|||||||
return negative ? `-${out}` : out;
|
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. */
|
/** Centered status message for loading / error / info states. */
|
||||||
function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) {
|
function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -156,8 +173,8 @@ function TransactionRow({
|
|||||||
{/* Line 1: amount + type + status */}
|
{/* Line 1: amount + type + status */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
<span className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||||
{formatAmount(tx.amount)}
|
{formatLedgerAmount(tx.amount, tx.asset)}
|
||||||
{tx.asset ? ` ${tx.asset}` : ''}
|
{tx.asset ? ` ${resolveAssetSymbol(tx.asset)}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<TypeBadge type={tx.type} />
|
<TypeBadge type={tx.type} />
|
||||||
<StatusBadge status={tx.status} />
|
<StatusBadge status={tx.status} />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|||||||
|
|
||||||
import { fetchWalletStatus } from '../../services/walletApi';
|
import { fetchWalletStatus } from '../../services/walletApi';
|
||||||
import { apiClient } from '../AgentWorldShell';
|
import { apiClient } from '../AgentWorldShell';
|
||||||
import MessagingSection from './MessagingSection';
|
import MessagingSection, { TABS } from './MessagingSection';
|
||||||
|
|
||||||
// Typed helpers so vi.mocked() calls are terse below.
|
// Typed helpers so vi.mocked() calls are terse below.
|
||||||
// These are resolved after the vi.mock factory runs.
|
// These are resolved after the vi.mock factory runs.
|
||||||
@@ -178,12 +178,10 @@ beforeEach(() => {
|
|||||||
// ── DMs panel (E2E enabled) ───────────────────────────────────────────────────
|
// ── DMs panel (E2E enabled) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('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 />);
|
render(<MessagingSection />);
|
||||||
|
|
||||||
const dmsButton = screen.getByRole('button', { name: 'DMs' });
|
// DMs is the default (and only) visible view now — no tab click needed.
|
||||||
await userEvent.click(dmsButton);
|
|
||||||
|
|
||||||
// Should see the peer input, not the "coming soon" placeholder
|
// Should see the peer input, not the "coming soon" placeholder
|
||||||
expect(screen.getByPlaceholderText(/Recipient @handle/)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/Recipient @handle/)).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('dms-coming-soon')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('dms-coming-soon')).not.toBeInTheDocument();
|
||||||
@@ -204,7 +202,6 @@ describe('DMs panel (E2E enabled)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
|
|
||||||
// Enter a handle (non-base58) — will be resolved to resolved-crypto-id
|
// Enter a handle (non-base58) — will be resolved to resolved-crypto-id
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
@@ -237,7 +234,6 @@ describe('DMs panel (E2E enabled)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
await user.type(screen.getByPlaceholderText(/Recipient @handle/), 'peer123');
|
await user.type(screen.getByPlaceholderText(/Recipient @handle/), 'peer123');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
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: [] });
|
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, 'peerEmpty');
|
await user.type(peerInput, 'peerEmpty');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
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'));
|
vi.mocked(apiClient.signal.sendMessage).mockRejectedValueOnce(new Error('encryption failed'));
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, 'peer456');
|
await user.type(peerInput, 'peer456');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
||||||
@@ -310,7 +304,6 @@ describe('DMs panel (E2E enabled)', () => {
|
|||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, 'peer789');
|
await user.type(peerInput, 'peer789');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
||||||
@@ -326,7 +319,6 @@ describe('DMs panel (E2E enabled)', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, 'peer999');
|
await user.type(peerInput, 'peer999');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
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: [] });
|
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, '@alice');
|
await user.type(peerInput, '@alice');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
||||||
@@ -361,7 +352,6 @@ describe('DMs panel (E2E enabled)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, '@unknown-user');
|
await user.type(peerInput, '@unknown-user');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
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: [] });
|
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, 'bob.agent');
|
await user.type(peerInput, 'bob.agent');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
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: [] });
|
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
// A valid 44-char base58 string — should bypass resolution
|
// A valid 44-char base58 string — should bypass resolution
|
||||||
await user.type(peerInput, '61KcG5aGLqpnJz2fXyzABCDEFGHJKLMNPQRSTUVWXY');
|
await user.type(peerInput, '61KcG5aGLqpnJz2fXyzABCDEFGHJKLMNPQRSTUVWXY');
|
||||||
@@ -414,7 +402,6 @@ describe('DMs panel (E2E enabled)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, '@broken-handle');
|
await user.type(peerInput, '@broken-handle');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
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: [] });
|
vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, 'stevejobs');
|
await user.type(peerInput, 'stevejobs');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
||||||
@@ -471,7 +457,6 @@ describe('DMs panel (E2E enabled)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await user.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
const peerInput = screen.getByPlaceholderText(/Recipient @handle/);
|
||||||
await user.type(peerInput, 'ghosthandle');
|
await user.type(peerInput, 'ghosthandle');
|
||||||
await user.click(screen.getByRole('button', { name: 'Open DM' }));
|
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 () => {
|
test('renders DM compose UI with updated placeholder text', async () => {
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'DMs' }));
|
|
||||||
expect(screen.getByPlaceholderText('Recipient @handle or wallet address')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('Recipient @handle or wallet address')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -494,20 +478,20 @@ describe('DMs panel (E2E enabled)', () => {
|
|||||||
|
|
||||||
describe('tab navigation', () => {
|
describe('tab navigation', () => {
|
||||||
test('defaults to Channels tab', () => {
|
test('defaults to Channels tab', () => {
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
const channelsBtn = screen.getByRole('button', { name: 'Channels' });
|
const channelsBtn = screen.getByRole('button', { name: 'Channels' });
|
||||||
expect(channelsBtn).toHaveAttribute('data-active', 'true');
|
expect(channelsBtn).toHaveAttribute('data-active', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can switch to Groups tab', async () => {
|
test('can switch to Groups tab', async () => {
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
const groupsBtn = screen.getByRole('button', { name: 'Groups' });
|
const groupsBtn = screen.getByRole('button', { name: 'Groups' });
|
||||||
await userEvent.click(groupsBtn);
|
await userEvent.click(groupsBtn);
|
||||||
expect(groupsBtn).toHaveAttribute('data-active', 'true');
|
expect(groupsBtn).toHaveAttribute('data-active', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can switch to Inbox tab', async () => {
|
test('can switch to Inbox tab', async () => {
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
const inboxBtn = screen.getByRole('button', { name: 'Inbox' });
|
const inboxBtn = screen.getByRole('button', { name: 'Inbox' });
|
||||||
await userEvent.click(inboxBtn);
|
await userEvent.click(inboxBtn);
|
||||||
expect(inboxBtn).toHaveAttribute('data-active', 'true');
|
expect(inboxBtn).toHaveAttribute('data-active', 'true');
|
||||||
@@ -518,25 +502,25 @@ describe('tab navigation', () => {
|
|||||||
|
|
||||||
describe('empty states', () => {
|
describe('empty states', () => {
|
||||||
test('shows "No channels found" when channels list is empty', async () => {
|
test('shows "No channels found" when channels list is empty', async () => {
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
// Wait for the async fetch to settle
|
// Wait for the async fetch to settle
|
||||||
expect(await screen.findByText(/No channels found/i)).toBeInTheDocument();
|
expect(await screen.findByText(/No channels found/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows "No groups found" when groups list is empty', async () => {
|
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' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
expect(await screen.findByText(/No groups found/i)).toBeInTheDocument();
|
expect(await screen.findByText(/No groups found/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows "No broadcasts found" when broadcasts list is empty', async () => {
|
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' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Broadcasts' }));
|
||||||
expect(await screen.findByText(/No broadcasts found/i)).toBeInTheDocument();
|
expect(await screen.findByText(/No broadcasts found/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows "Your inbox is empty" when inbox is empty', async () => {
|
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' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
|
||||||
expect(await screen.findByText(/Your inbox is empty/i)).toBeInTheDocument();
|
expect(await screen.findByText(/Your inbox is empty/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -570,7 +554,7 @@ describe('inbox actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function openInbox() {
|
async function openInbox() {
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
|
||||||
await screen.findByText('Hello there');
|
await screen.findByText('Hello there');
|
||||||
}
|
}
|
||||||
@@ -624,7 +608,7 @@ describe('membership actions', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await screen.findByText('General');
|
await screen.findByText('General');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Join' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Join' }));
|
||||||
expect(apiClient.channels.join).toHaveBeenCalledWith('ch-1');
|
expect(apiClient.channels.join).toHaveBeenCalledWith('ch-1');
|
||||||
@@ -640,7 +624,7 @@ describe('membership actions', () => {
|
|||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Broadcasts' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Broadcasts' }));
|
||||||
await screen.findByText('Updates');
|
await screen.findByText('Updates');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Subscribe' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Subscribe' }));
|
||||||
@@ -661,7 +645,7 @@ describe('membership actions', () => {
|
|||||||
setWallet('agent-abc');
|
setWallet('agent-abc');
|
||||||
// Both public and membership queries return the same group so button shows "Leave".
|
// Both public and membership queries return the same group so button shows "Leave".
|
||||||
vi.mocked(apiClient.groups.list).mockResolvedValue([group]);
|
vi.mocked(apiClient.groups.list).mockResolvedValue([group]);
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Builders');
|
await screen.findByText('Builders');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Leave' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Leave' }));
|
||||||
@@ -692,7 +676,7 @@ describe('group membership-aware button rendering', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function openGroups() {
|
async function openGroups() {
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Alpha');
|
await screen.findByText('Alpha');
|
||||||
}
|
}
|
||||||
@@ -741,7 +725,7 @@ describe('group membership-aware button rendering', () => {
|
|||||||
|
|
||||||
vi.mocked(apiClient.groups.join).mockResolvedValue(undefined);
|
vi.mocked(apiClient.groups.join).mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Alpha');
|
await screen.findByText('Alpha');
|
||||||
|
|
||||||
@@ -769,7 +753,7 @@ describe('group membership-aware button rendering', () => {
|
|||||||
|
|
||||||
vi.mocked(apiClient.groups.leave).mockResolvedValue(undefined);
|
vi.mocked(apiClient.groups.leave).mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Alpha');
|
await screen.findByText('Alpha');
|
||||||
|
|
||||||
@@ -788,7 +772,7 @@ describe('group membership-aware button rendering', () => {
|
|||||||
setWallet('agent-solana-123');
|
setWallet('agent-solana-123');
|
||||||
vi.mocked(apiClient.groups.list).mockResolvedValue([]);
|
vi.mocked(apiClient.groups.list).mockResolvedValue([]);
|
||||||
|
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText(/No groups found/i);
|
await screen.findByText(/No groups found/i);
|
||||||
|
|
||||||
@@ -820,7 +804,7 @@ describe('group invite management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('renders "Invites" button on group cards', async () => {
|
test('renders "Invites" button on group cards', async () => {
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Invite Test Group');
|
await screen.findByText('Invite Test Group');
|
||||||
expect(screen.getByRole('button', { name: 'Invites' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Invites' })).toBeInTheDocument();
|
||||||
@@ -828,7 +812,7 @@ describe('group invite management', () => {
|
|||||||
|
|
||||||
test('clicking "Invites" opens GroupInvitesPanel and calls listInvites', async () => {
|
test('clicking "Invites" opens GroupInvitesPanel and calls listInvites', async () => {
|
||||||
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
|
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Invite Test Group');
|
await screen.findByText('Invite Test Group');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
|
||||||
@@ -847,7 +831,7 @@ describe('group invite management', () => {
|
|||||||
maxUses: 10,
|
maxUses: 10,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Invite Test Group');
|
await screen.findByText('Invite Test Group');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
|
||||||
@@ -858,7 +842,7 @@ describe('group invite management', () => {
|
|||||||
|
|
||||||
test('Create Invite button calls groups.createInvite', async () => {
|
test('Create Invite button calls groups.createInvite', async () => {
|
||||||
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
|
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Invite Test Group');
|
await screen.findByText('Invite Test Group');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
|
||||||
@@ -877,7 +861,7 @@ describe('group invite management', () => {
|
|||||||
uses: 0,
|
uses: 0,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Invite Test Group');
|
await screen.findByText('Invite Test Group');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
|
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 () => {
|
test('Close button returns to the group list', async () => {
|
||||||
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
|
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByText('Invite Test Group');
|
await screen.findByText('Invite Test Group');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
|
||||||
@@ -901,13 +885,13 @@ describe('group invite management', () => {
|
|||||||
|
|
||||||
describe('redeem invite', () => {
|
describe('redeem invite', () => {
|
||||||
test('renders "Redeem Invite" button in the groups tab', async () => {
|
test('renders "Redeem Invite" button in the groups tab', async () => {
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
expect(await screen.findByRole('button', { name: 'Redeem Invite' })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: 'Redeem Invite' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking "Redeem Invite" opens the redeem panel with inputs', async () => {
|
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 userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await screen.findByRole('button', { name: 'Redeem Invite' });
|
await screen.findByRole('button', { name: 'Redeem Invite' });
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
|
||||||
@@ -924,7 +908,7 @@ describe('redeem invite', () => {
|
|||||||
invitedBy: 'admin-1',
|
invitedBy: 'admin-1',
|
||||||
valid: true,
|
valid: true,
|
||||||
});
|
});
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
|
||||||
await userEvent.type(screen.getByPlaceholderText('Group ID'), 'g-prev');
|
await userEvent.type(screen.getByPlaceholderText('Group ID'), 'g-prev');
|
||||||
@@ -943,7 +927,7 @@ describe('redeem invite', () => {
|
|||||||
joinedAt: '2026-01-01T00:00:00Z',
|
joinedAt: '2026-01-01T00:00:00Z',
|
||||||
updatedAt: '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: 'Groups' }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
|
||||||
await userEvent.type(screen.getByPlaceholderText('Group ID'), 'g-redeem');
|
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 () => {
|
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' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
|
||||||
// Wait for async effects to settle.
|
// Wait for async effects to settle.
|
||||||
await screen.findByText(/Your inbox is empty/i);
|
await screen.findByText(/Your inbox is empty/i);
|
||||||
@@ -991,7 +975,7 @@ describe('inbox stream lifecycle', () => {
|
|||||||
status: 'connected',
|
status: 'connected',
|
||||||
clearMessages: vi.fn(),
|
clearMessages: vi.fn(),
|
||||||
}));
|
}));
|
||||||
render(<MessagingSection />);
|
render(<MessagingSection tabs={TABS} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
|
||||||
// Wait for the async inbox fetch to settle and the live indicator to appear.
|
// Wait for the async inbox fetch to settle and the live indicator to appear.
|
||||||
await screen.findByTestId('inbox-live-indicator');
|
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 () => {
|
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 userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
|
||||||
await screen.findByText(/Your inbox is empty/i);
|
await screen.findByText(/Your inbox is empty/i);
|
||||||
expect(screen.queryByTestId('inbox-live-indicator')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('inbox-live-indicator')).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const E2E_MESSAGING_ENABLED = true;
|
|||||||
|
|
||||||
// ── Tab definition ────────────────────────────────────────────────────────────
|
// ── 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];
|
type Tab = (typeof TABS)[number];
|
||||||
|
|
||||||
const TAB_LABELS: Record<Tab, string> = {
|
const TAB_LABELS: Record<Tab, string> = {
|
||||||
@@ -1361,29 +1361,47 @@ function DmsPanel() {
|
|||||||
|
|
||||||
// ── Messaging section root ────────────────────────────────────────────────────
|
// ── Messaging section root ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function MessagingSection() {
|
// Messaging is currently focused on DMs only. The channels / groups /
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('channels');
|
// 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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Tab chips */}
|
{/* Tab chips — only shown when more than one tab is enabled. */}
|
||||||
<div className="flex gap-1 px-4 py-3 border-b border-stone-200 dark:border-neutral-800 overflow-x-auto shrink-0">
|
{showTabBar && (
|
||||||
{TABS.map(tab => (
|
<div className="flex gap-1 px-4 py-3 border-b border-stone-200 dark:border-neutral-800 overflow-x-auto shrink-0">
|
||||||
<button
|
{tabs.map(tab => (
|
||||||
key={tab}
|
<button
|
||||||
type="button"
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
type="button"
|
||||||
data-active={activeTab === tab}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={[
|
data-active={activeTab === tab}
|
||||||
'whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition-colors',
|
className={[
|
||||||
activeTab === tab
|
'whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition-colors',
|
||||||
? 'bg-stone-800 text-white dark:bg-neutral-100 dark:text-neutral-900'
|
activeTab === tab
|
||||||
: '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',
|
? 'bg-stone-800 text-white dark:bg-neutral-100 dark:text-neutral-900'
|
||||||
].join(' ')}>
|
: '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',
|
||||||
{TAB_LABELS[tab]}
|
].join(' ')}>
|
||||||
</button>
|
{TAB_LABELS[tab]}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Signal key status — always visible when wallet is connected */}
|
{/* Signal key status — always visible when wallet is connected */}
|
||||||
<SignalKeyStatusCard />
|
<SignalKeyStatusCard />
|
||||||
|
|||||||
@@ -363,8 +363,9 @@ describe('graphql-enriched profile card', () => {
|
|||||||
);
|
);
|
||||||
render(<ProfilesSection />);
|
render(<ProfilesSection />);
|
||||||
|
|
||||||
// Rich data rendered
|
// Rich data rendered — the handle appears in the header heading (it also
|
||||||
expect(await screen.findByText('@alice')).toBeInTheDocument();
|
// 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('Building the future')).toBeInTheDocument();
|
||||||
expect(screen.getByText('ai')).toBeInTheDocument();
|
expect(screen.getByText('ai')).toBeInTheDocument();
|
||||||
expect(screen.getByText('automation')).toBeInTheDocument();
|
expect(screen.getByText('automation')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
|
|||||||
const rawSkills = isGraphql ? (profile!.tags ?? []) : [];
|
const rawSkills = isGraphql ? (profile!.tags ?? []) : [];
|
||||||
const skills = rawSkills.map(toLabel);
|
const skills = rawSkills.map(toLabel);
|
||||||
const attestations: GqlAttestation[] = isGraphql ? (profile!.attestations ?? []) : [];
|
const attestations: GqlAttestation[] = isGraphql ? (profile!.attestations ?? []) : [];
|
||||||
|
const actorType = isGraphql ? (profile!.actorType ?? '') : '';
|
||||||
|
const ownedIdentities = isGraphql ? (profile!.identities ?? []) : [];
|
||||||
|
|
||||||
const agentId = cryptoId;
|
const agentId = cryptoId;
|
||||||
const agentName = displayName || usernameClean || '?';
|
const agentName = displayName || usernameClean || '?';
|
||||||
@@ -252,13 +254,23 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<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}
|
{handle}
|
||||||
{verified && (
|
{verified && (
|
||||||
<span className="ml-1 text-xs text-blue-500" title="Verified">
|
<span className="text-xs text-blue-500" title="Verified">
|
||||||
✓
|
✓
|
||||||
</span>
|
</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>
|
</h3>
|
||||||
{cryptoId && (
|
{cryptoId && (
|
||||||
<p
|
<p
|
||||||
@@ -307,6 +319,35 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
|
|||||||
</div>
|
</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 && (
|
{followStats && (
|
||||||
<div className="mt-4 border-t border-stone-200 pt-4 dark:border-neutral-800">
|
<div className="mt-4 border-t border-stone-200 pt-4 dark:border-neutral-800">
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useT } from '../../../lib/i18n/I18nContext';
|
import { useT } from '../../../lib/i18n/I18nContext';
|
||||||
import { APP_VERSION } from '../../../utils/config';
|
import { APP_VERSION } from '../../../utils/config';
|
||||||
import ConnectionIndicator from '../../ConnectionIndicator';
|
import ConnectionIndicator from '../../ConnectionIndicator';
|
||||||
|
import SidebarAppRail from './SidebarAppRail';
|
||||||
import SidebarHeader from './SidebarHeader';
|
import SidebarHeader from './SidebarHeader';
|
||||||
import SidebarNav from './SidebarNav';
|
import SidebarNav from './SidebarNav';
|
||||||
import { SidebarSlotOutlet } from './SidebarSlot';
|
import { SidebarSlotOutlet } from './SidebarSlot';
|
||||||
@@ -13,6 +14,8 @@ import { SidebarSlotOutlet } from './SidebarSlot';
|
|||||||
* ├──────────────┤
|
* ├──────────────┤
|
||||||
* │ SidebarNav │ static primary navigation
|
* │ SidebarNav │ static primary navigation
|
||||||
* ├──────────────┤
|
* ├──────────────┤
|
||||||
|
* │ SidebarAppRail│ persistent app switcher (agent + connected apps)
|
||||||
|
* ├──────────────┤
|
||||||
* │ SidebarSlot │ dynamic, per-route content (scrolls)
|
* │ SidebarSlot │ dynamic, per-route content (scrolls)
|
||||||
* │ (Outlet) │
|
* │ (Outlet) │
|
||||||
* ├──────────────┤
|
* ├──────────────┤
|
||||||
@@ -32,6 +35,12 @@ export default function AppSidebar() {
|
|||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<SidebarNav />
|
<SidebarNav />
|
||||||
</div>
|
</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">
|
<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
|
{/* 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-*`. */}
|
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;
|
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
|
* Query params for the GraphQL jobs endpoint. Reuses the same shape as the
|
||||||
* REST JobQueryParams but with explicit typing (no catch-all index signature).
|
* REST JobQueryParams but with explicit typing (no catch-all index signature).
|
||||||
@@ -1797,13 +1830,6 @@ export function createInvokeApiClient() {
|
|||||||
durationDays: params.durationDays ?? null,
|
durationDays: params.durationDays ?? null,
|
||||||
confirmed: opts?.confirmed ?? false,
|
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) =>
|
cancel: (bountyId: string) =>
|
||||||
call<Bounty>('openhuman.tinyplace_bounties_cancel', { bountyId }),
|
call<Bounty>('openhuman.tinyplace_bounties_cancel', { bountyId }),
|
||||||
submit: (bountyId: string, url: string, title?: string, note?: string) =>
|
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 }),
|
call<GqlJobListResult>('openhuman.tinyplace_graphql_jobs', { params: params ?? null }),
|
||||||
/** Fetch a single job posting by ID (public, no auth). */
|
/** Fetch a single job posting by ID (public, no auth). */
|
||||||
job: (id: string) => call<GqlJobPosting | null>('openhuman.tinyplace_graphql_job', { id }),
|
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). */
|
/** Fetch a full GqlProfile by @handle (public GraphQL). */
|
||||||
profile: (username: string) =>
|
profile: (username: string) =>
|
||||||
call<GqlProfile | null>('openhuman.tinyplace_graphql_profile', { username }),
|
call<GqlProfile | null>('openhuman.tinyplace_graphql_profile', { username }),
|
||||||
|
|||||||
+8
-262
@@ -1,9 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 WebviewHost from '../components/accounts/WebviewHost';
|
||||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
|
||||||
import {
|
import {
|
||||||
CustomGifMascot,
|
CustomGifMascot,
|
||||||
getMascotPalette,
|
getMascotPalette,
|
||||||
@@ -13,102 +10,25 @@ import {
|
|||||||
import { useHumanMascot } from '../features/human/useHumanMascot';
|
import { useHumanMascot } from '../features/human/useHumanMascot';
|
||||||
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
|
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
|
||||||
import { useT } from '../lib/i18n/I18nContext';
|
import { useT } from '../lib/i18n/I18nContext';
|
||||||
import { trackEvent } from '../services/analytics';
|
|
||||||
import {
|
import {
|
||||||
hideWebviewAccount,
|
hideWebviewAccount,
|
||||||
purgeWebviewAccount,
|
|
||||||
showWebviewAccount,
|
showWebviewAccount,
|
||||||
startWebviewAccountService,
|
startWebviewAccountService,
|
||||||
} from '../services/webviewAccountService';
|
} from '../services/webviewAccountService';
|
||||||
import {
|
import { useAppSelector } from '../store/hooks';
|
||||||
addAccount,
|
|
||||||
removeAccount,
|
|
||||||
setActiveAccount,
|
|
||||||
setLastActiveAccount,
|
|
||||||
} from '../store/accountsSlice';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
|
||||||
import {
|
import {
|
||||||
selectCustomMascotGifUrl,
|
selectCustomMascotGifUrl,
|
||||||
selectCustomPrimaryColor,
|
selectCustomPrimaryColor,
|
||||||
selectCustomSecondaryColor,
|
selectCustomSecondaryColor,
|
||||||
selectMascotColor,
|
selectMascotColor,
|
||||||
} from '../store/mascotSlice';
|
} 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 { AGENT_ACCOUNT_ID as AGENT_ID } from '../utils/accountsFullscreen';
|
||||||
import Conversations, { AgentChatPanel } from './Conversations';
|
import Conversations, { AgentChatPanel } from './Conversations';
|
||||||
|
|
||||||
// Persistence key for face-toggle state across sessions.
|
// Persistence key for face-toggle state across sessions.
|
||||||
const FACE_MODE_KEY = 'chat.faceMode';
|
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
|
* Mascot + TTS panel rendered in face mode (right column of the Assistant
|
||||||
* surface). Extracted as a separate component so its hooks only run when
|
* surface). Extracted as a separate component so its hooks only run when
|
||||||
@@ -188,13 +108,12 @@ const FaceModePanel = () => {
|
|||||||
|
|
||||||
const Accounts = () => {
|
const Accounts = () => {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const accountsById = useAppSelector(state => state.accounts.accounts);
|
const accountsById = useAppSelector(state => state.accounts.accounts);
|
||||||
const order = useAppSelector(state => state.accounts.order);
|
const order = useAppSelector(state => state.accounts.order);
|
||||||
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
|
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
|
||||||
const unreadByAccount = useAppSelector(state => state.accounts.unread);
|
// Overlay state is owned by the persistent app rail (now in the sidebar); we
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
// only read it here to hide/restore the active provider webview.
|
||||||
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
|
const overlayOpen = useAppSelector(state => state.accounts.overlayOpen);
|
||||||
|
|
||||||
const [faceMode] = useState<boolean>(() => {
|
const [faceMode] = useState<boolean>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -222,22 +141,16 @@ const Accounts = () => {
|
|||||||
);
|
);
|
||||||
usePrewarmMostRecentAccount({ accounts, accountsById, activeAccountId });
|
usePrewarmMostRecentAccount({ accounts, accountsById, activeAccountId });
|
||||||
|
|
||||||
const connectedProviders = useMemo(
|
|
||||||
() => new Set<AccountProvider>(accounts.map(a => a.provider)),
|
|
||||||
[accounts]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedId = activeAccountId ?? AGENT_ID;
|
const selectedId = activeAccountId ?? AGENT_ID;
|
||||||
const active = selectedId === AGENT_ID ? null : (accountsById[selectedId] ?? null);
|
const active = selectedId === AGENT_ID ? null : (accountsById[selectedId] ?? null);
|
||||||
const isAgentSelected = selectedId === AGENT_ID;
|
const isAgentSelected = selectedId === AGENT_ID;
|
||||||
|
|
||||||
// The child Tauri webview is a native view composited above the HTML
|
// 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
|
// 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
|
// the active webview while a rail overlay (add-account modal or the
|
||||||
// right-click context menu) is open and restore it on close. No-op
|
// right-click context menu, both owned by the persistent sidebar rail) is
|
||||||
// when the agent pane is selected (pure HTML).
|
// open and restore it on close. No-op when the agent pane is selected.
|
||||||
const activeId = active?.id ?? null;
|
const activeId = active?.id ?? null;
|
||||||
const overlayOpen = addOpen || ctxMenu !== null;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
if (overlayOpen) {
|
if (overlayOpen) {
|
||||||
@@ -247,149 +160,12 @@ const Accounts = () => {
|
|||||||
}
|
}
|
||||||
}, [overlayOpen, activeId]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
// `h-full` makes this page fill the shell's content box edge-to-edge.
|
// `h-full` makes this page fill the shell's content box edge-to-edge.
|
||||||
className="relative flex h-full overflow-hidden"
|
className="relative flex h-full overflow-hidden"
|
||||||
data-testid="accounts-page"
|
data-testid="accounts-page"
|
||||||
data-analytics-id="chat-right-sidebar">
|
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). */}
|
{/* "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
|
{/* Main pane. In face mode (agent selected) it's a horizontal split with
|
||||||
@@ -432,36 +208,6 @@ const Accounts = () => {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+90
-124
@@ -13,7 +13,6 @@ import ChatNewWindowHero from '../components/chat/ChatNewWindowHero';
|
|||||||
import ComposerTokenStats from '../components/chat/ComposerTokenStats';
|
import ComposerTokenStats from '../components/chat/ComposerTokenStats';
|
||||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||||
import PillTabBar from '../components/PillTabBar';
|
|
||||||
import UpsellBanner from '../components/upsell/UpsellBanner';
|
import UpsellBanner from '../components/upsell/UpsellBanner';
|
||||||
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
|
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
|
||||||
import MicComposer from '../features/human/MicComposer';
|
import MicComposer from '../features/human/MicComposer';
|
||||||
@@ -108,12 +107,7 @@ import {
|
|||||||
formatResetTime,
|
formatResetTime,
|
||||||
getInlineCompletionSuffix,
|
getInlineCompletionSuffix,
|
||||||
} from './conversations/utils/format';
|
} from './conversations/utils/format';
|
||||||
import {
|
import { GENERAL_TAB_VALUE, isThreadVisibleInTab } from './conversations/utils/threadFilter';
|
||||||
GENERAL_TAB_VALUE,
|
|
||||||
isThreadVisibleInTab,
|
|
||||||
SUBCONSCIOUS_TAB_VALUE,
|
|
||||||
TASKS_TAB_VALUE,
|
|
||||||
} from './conversations/utils/threadFilter';
|
|
||||||
|
|
||||||
const CHAT_MODEL_HINT = 'hint:chat';
|
const CHAT_MODEL_HINT = 'hint:chat';
|
||||||
/** Maximum trailing characters rendered in the live-streaming assistant
|
/** Maximum trailing characters rendered in the live-streaming assistant
|
||||||
@@ -248,7 +242,10 @@ const Conversations = ({
|
|||||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||||
const [voiceStatus, setVoiceStatus] = useState<string | null>(null);
|
const [voiceStatus, setVoiceStatus] = useState<string | null>(null);
|
||||||
const [isPlayingReply, setIsPlayingReply] = useState(false);
|
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 [threadSearch, setThreadSearch] = useState('');
|
||||||
const [inlineSuggestionValue, setInlineSuggestionValue] = useState('');
|
const [inlineSuggestionValue, setInlineSuggestionValue] = useState('');
|
||||||
const [sendError, setSendError] = useState<ChatSendError | null>(null);
|
const [sendError, setSendError] = useState<ChatSendError | null>(null);
|
||||||
@@ -308,8 +305,9 @@ const Conversations = ({
|
|||||||
);
|
);
|
||||||
const rustChat = useRustChat();
|
const rustChat = useRustChat();
|
||||||
const [reactionPickerMsgId, setReactionPickerMsgId] = useState<string | null>(null);
|
const [reactionPickerMsgId, setReactionPickerMsgId] = useState<string | null>(null);
|
||||||
// Inline thread-title rename in the conversation header.
|
// Inline thread-title rename in the sidebar thread list — keyed by the
|
||||||
const [editingTitle, setEditingTitle] = useState(false);
|
// 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 [editTitleValue, setEditTitleValue] = useState('');
|
||||||
const editTitleInputRef = useRef<HTMLInputElement>(null);
|
const editTitleInputRef = useRef<HTMLInputElement>(null);
|
||||||
const ignoreNextTitleBlurRef = useRef(false);
|
const ignoreNextTitleBlurRef = useRef(false);
|
||||||
@@ -440,13 +438,12 @@ const Conversations = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartEditTitle = () => {
|
const handleStartEditTitle = (threadId: string) => {
|
||||||
if (!selectedThreadId) return;
|
const thr = threads.find(t => t.id === threadId);
|
||||||
const thr = threads.find(t => t.id === selectedThreadId);
|
debug('[chat] thread rename: start thread=%s', threadId);
|
||||||
debug('[chat] thread rename: start thread=%s', selectedThreadId);
|
|
||||||
setEditTitleValue(thr?.title ?? '');
|
setEditTitleValue(thr?.title ?? '');
|
||||||
ignoreNextTitleBlurRef.current = true;
|
ignoreNextTitleBlurRef.current = true;
|
||||||
setEditingTitle(true);
|
setEditingThreadId(threadId);
|
||||||
const scheduleSelect = window.requestAnimationFrame ?? window.setTimeout;
|
const scheduleSelect = window.requestAnimationFrame ?? window.setTimeout;
|
||||||
scheduleSelect(() => {
|
scheduleSelect(() => {
|
||||||
editTitleInputRef.current?.select();
|
editTitleInputRef.current?.select();
|
||||||
@@ -454,27 +451,27 @@ const Conversations = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommitTitle = () => {
|
const handleCommitTitle = (threadId: string) => {
|
||||||
const trimmed = editTitleValue.trim();
|
const trimmed = editTitleValue.trim();
|
||||||
setEditingTitle(false);
|
setEditingThreadId(null);
|
||||||
// Title length only — never log the title text itself (may carry PII).
|
// Title length only — never log the title text itself (may carry PII).
|
||||||
if (!selectedThreadId || !trimmed) {
|
if (!threadId || !trimmed) {
|
||||||
debug('[chat] thread rename: commit skipped thread=%s empty=%s', selectedThreadId, !trimmed);
|
debug('[chat] thread rename: commit skipped thread=%s empty=%s', threadId, !trimmed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentTitle = threads.find(t => t.id === selectedThreadId)?.title?.trim();
|
const currentTitle = threads.find(t => t.id === threadId)?.title?.trim();
|
||||||
if (trimmed === currentTitle) {
|
if (trimmed === currentTitle) {
|
||||||
debug('[chat] thread rename: commit skipped thread=%s (unchanged)', selectedThreadId);
|
debug('[chat] thread rename: commit skipped thread=%s (unchanged)', threadId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debug('[chat] thread rename: commit thread=%s len=%d', selectedThreadId, trimmed.length);
|
debug('[chat] thread rename: commit thread=%s len=%d', threadId, trimmed.length);
|
||||||
void dispatch(updateThreadTitle({ threadId: selectedThreadId, title: trimmed }))
|
void dispatch(updateThreadTitle({ threadId, title: trimmed }))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.then(() => debug('[chat] thread rename: committed thread=%s', selectedThreadId))
|
.then(() => debug('[chat] thread rename: committed thread=%s', threadId))
|
||||||
.catch(err =>
|
.catch(err =>
|
||||||
debug(
|
debug(
|
||||||
'[chat] thread rename: failed thread=%s err=%s',
|
'[chat] thread rename: failed thread=%s err=%s',
|
||||||
selectedThreadId,
|
threadId,
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -505,16 +502,9 @@ const Conversations = ({
|
|||||||
const openThreadId = (location.state as { openThreadId?: string } | null)?.openThreadId;
|
const openThreadId = (location.state as { openThreadId?: string } | null)?.openThreadId;
|
||||||
const openThread = openThreadId ? data.threads.find(t => t.id === openThreadId) : undefined;
|
const openThread = openThreadId ? data.threads.find(t => t.id === openThreadId) : undefined;
|
||||||
if (openThread) {
|
if (openThread) {
|
||||||
// Switch the sidebar tab to the bucket that contains the opened
|
// An explicit open intent (e.g. View work from the Tasks board) opens
|
||||||
// thread (e.g. Tasks for a task session) so it's visible/selected in
|
// the thread in the main pane directly; the thread list itself stays
|
||||||
// the list instead of hidden behind the default General tab.
|
// filtered to General.
|
||||||
setSelectedLabel(
|
|
||||||
isThreadVisibleInTab(openThread, TASKS_TAB_VALUE)
|
|
||||||
? TASKS_TAB_VALUE
|
|
||||||
: isThreadVisibleInTab(openThread, SUBCONSCIOUS_TAB_VALUE)
|
|
||||||
? SUBCONSCIOUS_TAB_VALUE
|
|
||||||
: GENERAL_TAB_VALUE
|
|
||||||
);
|
|
||||||
dispatch(setSelectedThread(openThread.id));
|
dispatch(setSelectedThread(openThread.id));
|
||||||
void dispatch(loadThreadMessages(openThread.id));
|
void dispatch(loadThreadMessages(openThread.id));
|
||||||
return;
|
return;
|
||||||
@@ -1469,16 +1459,6 @@ const Conversations = ({
|
|||||||
return sortedThreads.filter(thread => (thread.title ?? '').toLowerCase().includes(q));
|
return sortedThreads.filter(thread => (thread.title ?? '').toLowerCase().includes(q));
|
||||||
}, [sortedThreads, threadSearch]);
|
}, [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';
|
const isSidebar = variant === 'sidebar';
|
||||||
// "New window" = the merged Home surface: a page-variant chat whose selected
|
// "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
|
// thread has no messages yet. We show the greeting + banners hero above a
|
||||||
@@ -1563,17 +1543,8 @@ const Conversations = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="new-thread-button"
|
data-testid="new-thread-button"
|
||||||
@@ -1597,7 +1568,7 @@ const Conversations = ({
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{visibleThreads.length === 0 ? (
|
{visibleThreads.length === 0 ? (
|
||||||
<p className="px-4 py-6 text-xs text-stone-400 dark:text-neutral-500 text-center">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
visibleThreads.map(thread => (
|
visibleThreads.map(thread => (
|
||||||
@@ -1625,14 +1596,68 @@ const Conversations = ({
|
|||||||
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60'
|
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p
|
{editingThreadId === thread.id ? (
|
||||||
className={`text-xs truncate flex-1 ${
|
<input
|
||||||
selectedThreadId === thread.id
|
ref={editTitleInputRef}
|
||||||
? 'font-medium text-primary-700 dark:text-primary-200'
|
value={editTitleValue}
|
||||||
: 'text-stone-700 dark:text-neutral-200'
|
onClick={e => e.stopPropagation()}
|
||||||
}`}>
|
onChange={e => setEditTitleValue(e.target.value)}
|
||||||
{resolveThreadDisplayTitle(thread.id)}
|
onKeyDown={e => {
|
||||||
</p>
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-analytics-id="chat-sidebar-delete-thread"
|
data-analytics-id="chat-sidebar-delete-thread"
|
||||||
@@ -2514,66 +2539,7 @@ const Conversations = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thread title + inline rename (page variant). Hover the title to
|
{/* Thread title + inline rename moved to the sidebar thread list rows. */}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Model + token stats (left) and the quick/reasoning toggle + files
|
{/* Model + token stats (left) and the quick/reasoning toggle + files
|
||||||
chip (right) share one line. */}
|
chip (right) share one line. */}
|
||||||
|
|||||||
@@ -646,11 +646,11 @@ describe('Conversations — thread rename', () => {
|
|||||||
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
|
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 { thread } = await renderWithSelectedThread();
|
||||||
const { threadApi } = await import('../../services/api/threadApi');
|
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' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
|
||||||
const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
|
const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
|
||||||
fireEvent.change(input, { target: { value: 'Renamed in header' } });
|
fireEvent.change(input, { target: { value: 'Renamed in header' } });
|
||||||
@@ -724,13 +724,13 @@ describe('Conversations — thread rename', () => {
|
|||||||
expect(threadApi.updateTitle).not.toHaveBeenCalled();
|
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();
|
await renderWithSelectedThread();
|
||||||
const { threadApi } = await import('../../services/api/threadApi');
|
const { threadApi } = await import('../../services/api/threadApi');
|
||||||
|
|
||||||
// onMouseDown opens edit mode; the blur fired while the input is grabbing
|
// Clicking the row pencil opens edit mode; the blur fired while the input
|
||||||
// focus is ignored (no spurious commit).
|
// is grabbing focus is ignored (no spurious commit).
|
||||||
fireEvent.mouseDown(screen.getByRole('button', { name: 'Edit thread title' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
|
||||||
const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
|
const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
|
||||||
fireEvent.blur(input);
|
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 the page-mode sidebar (TwoPanelLayout, id `chat`) once opened. The
|
||||||
// Covers line 941: <div className="flex-1 overflow-y-auto"> (always rendered in page mode)
|
// General/Subconscious/Tasks filter chips were removed; the thread search is
|
||||||
it('renders the sidebar pill tabs in page mode', async () => {
|
// the stable top-of-sidebar control.
|
||||||
|
it('renders the sidebar thread search in page mode', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await renderConversations({ thread: emptyThreadState });
|
await renderConversations({ thread: emptyThreadState });
|
||||||
});
|
});
|
||||||
|
|
||||||
await openSidebar();
|
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
|
// Covers the empty branch — with the filter chips gone the list always shows
|
||||||
it('shows the General empty message when the default bucket has no threads', async () => {
|
// 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 act(async () => {
|
||||||
await renderConversations({ thread: emptyThreadState });
|
await renderConversations({ thread: emptyThreadState });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sidebar is hidden by default — open it first.
|
// Sidebar is hidden by default — open it first.
|
||||||
await openSidebar();
|
await openSidebar();
|
||||||
expect(screen.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
|
expect(screen.getByText('No threads yet')).toBeInTheDocument();
|
||||||
expect(screen.getByText('No "General" threads')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Covers lines 1002-1004, 1007, 1011-1012, 1014: thread list items rendered unconditionally
|
// 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 General/Subconscious/Tasks filter chips were removed — the thread list
|
||||||
//
|
// is now fixed to the General bucket with no in-sidebar bucket switcher.
|
||||||
// The tab set is fixed so categories do not disappear when the thread list
|
// Subconscious reflections and task/worker threads have dedicated surfaces.
|
||||||
// is empty, and the active-filter state remains unambiguous.
|
it('does not render the removed bucket filter tabs', async () => {
|
||||||
it('renders the fixed chat bucket tabs with stable labels', async () => {
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await renderConversations({ thread: emptyThreadState });
|
await renderConversations({ thread: emptyThreadState });
|
||||||
});
|
});
|
||||||
@@ -1349,60 +1350,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
|||||||
// Sidebar is hidden by default — open it first.
|
// Sidebar is hidden by default — open it first.
|
||||||
await openSidebar();
|
await openSidebar();
|
||||||
|
|
||||||
// Bucket tabs must be present regardless of thread count.
|
expect(screen.queryByRole('tab', { name: 'General' })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole('tab', { name: 'General' })).toBeInTheDocument();
|
expect(screen.queryByRole('tab', { name: 'Subconscious' })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole('tab', { name: 'Subconscious' })).toBeInTheDocument();
|
expect(screen.queryByRole('tab', { name: 'Tasks' })).not.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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function seedState(accounts: Account[]): AccountsState {
|
|||||||
messages: {},
|
messages: {},
|
||||||
unread: {},
|
unread: {},
|
||||||
logs: {},
|
logs: {},
|
||||||
|
overlayOpen: false,
|
||||||
};
|
};
|
||||||
for (const acct of accounts) {
|
for (const acct of accounts) {
|
||||||
state.accounts[acct.id] = acct;
|
state.accounts[acct.id] = acct;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const initialState: AccountsState = {
|
|||||||
messages: {},
|
messages: {},
|
||||||
unread: {},
|
unread: {},
|
||||||
logs: {},
|
logs: {},
|
||||||
|
overlayOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const accountsSlice = createSlice({
|
const accountsSlice = createSlice({
|
||||||
@@ -141,6 +142,15 @@ const accountsSlice = createSlice({
|
|||||||
state.unread[accountId] = 0;
|
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() {
|
resetAccountsState() {
|
||||||
return initialState;
|
return initialState;
|
||||||
},
|
},
|
||||||
@@ -195,6 +205,7 @@ export const {
|
|||||||
appendLog,
|
appendLog,
|
||||||
noteWebviewNotificationFired,
|
noteWebviewNotificationFired,
|
||||||
focusAccountFromNotification,
|
focusAccountFromNotification,
|
||||||
|
setAccountsOverlayOpen,
|
||||||
resetAccountsState,
|
resetAccountsState,
|
||||||
} = accountsSlice.actions;
|
} = accountsSlice.actions;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ export interface AccountsState {
|
|||||||
messages: Record<string, IngestedMessage[]>;
|
messages: Record<string, IngestedMessage[]>;
|
||||||
unread: Record<string, number>;
|
unread: Record<string, number>;
|
||||||
logs: Record<string, AccountLogEntry[]>;
|
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 {
|
export interface AccountLogEntry {
|
||||||
|
|||||||
@@ -170,13 +170,13 @@ test.describe('Chat management functional coverage', () => {
|
|||||||
const threadId = await newThread(page);
|
const threadId = await newThread(page);
|
||||||
const title = `Playwright thread ${Date.now()}`;
|
const title = `Playwright thread ${Date.now()}`;
|
||||||
|
|
||||||
// Inline rename from the conversation header (restored after #3751). The
|
// Inline rename now lives on each sidebar thread row (moved off the
|
||||||
// chat-as-home surface may auto-select a different empty thread, so assert
|
// conversation header). Every row has its own "Edit thread title" button, so
|
||||||
// on the unique renamed title appearing in the thread list rather than
|
// scope to this thread's row to avoid a strict-mode multi-match.
|
||||||
// pinning to a specific row — that deterministically proves the header
|
await page
|
||||||
// rename committed end-to-end. (Rename targeting is covered deterministically
|
.getByTestId(`thread-row-${threadId}`)
|
||||||
// by the Conversations rename unit test.)
|
.getByRole('button', { name: 'Edit thread title' })
|
||||||
await page.getByRole('button', { name: 'Edit thread title' }).click({ force: true });
|
.click({ force: true });
|
||||||
await page.getByRole('textbox', { name: 'Edit thread title' }).fill(title);
|
await page.getByRole('textbox', { name: 'Edit thread title' }).fill(title);
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await expect(page.getByText(title).first()).toBeVisible({ timeout: 10_000 });
|
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 ───────────────────────────────────────────────
|
// ── GraphQL: Profile + Identity ───────────────────────────────────────────────
|
||||||
|
|
||||||
pub(crate) fn handle_tinyplace_graphql_profile(params: Map<String, Value>) -> ControllerFuture {
|
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 {
|
pub(crate) fn handle_tinyplace_bounties_cancel(params: Map<String, Value>) -> ControllerFuture {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let bounty_id = req_str(¶ms, "bountyId")?.to_string();
|
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.
|
/// bounties_submit rejects blank url before any client work.
|
||||||
#[test]
|
#[test]
|
||||||
fn bounties_submit_rejects_blank_url() {
|
fn bounties_submit_rejects_blank_url() {
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ use tinyplace::x402::{
|
|||||||
};
|
};
|
||||||
use tinyplace::PaymentChallenge;
|
use tinyplace::PaymentChallenge;
|
||||||
|
|
||||||
|
use crate::openhuman::wallet::rpc::with_tinyplace_solana_endpoints;
|
||||||
use crate::openhuman::wallet::{
|
use crate::openhuman::wallet::{
|
||||||
execute_prepared, prepare_transfer, solana_cluster, ExecutePreparedParams,
|
execute_prepared, prepare_transfer, solana_cluster, tinyplace_solana_rpc_endpoints,
|
||||||
PrepareTransferParams, SolanaCluster, WalletChain,
|
ExecutePreparedParams, PrepareTransferParams, SolanaCluster, WalletChain,
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOG_PREFIX: &str = "[tinyplace-pay]";
|
const LOG_PREFIX: &str = "[tinyplace-pay]";
|
||||||
@@ -366,17 +367,29 @@ pub(crate) async fn fulfill_payment(
|
|||||||
truncate(&v.to),
|
truncate(&v.to),
|
||||||
);
|
);
|
||||||
|
|
||||||
let prepared = prepare_transfer(to_transfer_params(&v)).await?.value;
|
// Run the prepare + broadcast inside the tiny.place Solana endpoint scope so
|
||||||
let quote_id = prepared.quote_id;
|
// both hit the tiny.place settlement RPC (where agent accounts are funded),
|
||||||
log::debug!("{LOG_PREFIX} prepared transfer quote_id={quote_id}");
|
// 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 {
|
let exec = execute_prepared(ExecutePreparedParams {
|
||||||
quote_id: quote_id.clone(),
|
quote_id: quote_id.clone(),
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
.value;
|
||||||
|
Ok::<(String, String), String>((quote_id, exec.transaction_hash))
|
||||||
})
|
})
|
||||||
.await?
|
.await?;
|
||||||
.value;
|
|
||||||
let on_chain_tx = exec.transaction_hash;
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"{LOG_PREFIX} transfer broadcast tx={} quote_id={quote_id}",
|
"{LOG_PREFIX} transfer broadcast tx={} quote_id={quote_id}",
|
||||||
truncate(&on_chain_tx),
|
truncate(&on_chain_tx),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ use crate::openhuman::tinyplace::manifest::{
|
|||||||
handle_tinyplace_bounties_cancel,
|
handle_tinyplace_bounties_cancel,
|
||||||
handle_tinyplace_bounties_comment,
|
handle_tinyplace_bounties_comment,
|
||||||
handle_tinyplace_bounties_create,
|
handle_tinyplace_bounties_create,
|
||||||
handle_tinyplace_bounties_fund,
|
|
||||||
handle_tinyplace_bounties_get,
|
handle_tinyplace_bounties_get,
|
||||||
handle_tinyplace_bounties_list,
|
handle_tinyplace_bounties_list,
|
||||||
handle_tinyplace_bounties_list_comments,
|
handle_tinyplace_bounties_list_comments,
|
||||||
@@ -61,6 +60,8 @@ use crate::openhuman::tinyplace::manifest::{
|
|||||||
handle_tinyplace_follows_unfollow,
|
handle_tinyplace_follows_unfollow,
|
||||||
// GraphQL Profile + Identity handlers
|
// GraphQL Profile + Identity handlers
|
||||||
handle_tinyplace_graphql_agent_card,
|
handle_tinyplace_graphql_agent_card,
|
||||||
|
handle_tinyplace_graphql_bounties,
|
||||||
|
handle_tinyplace_graphql_bounty,
|
||||||
// GraphQL Social Feed handlers
|
// GraphQL Social Feed handlers
|
||||||
handle_tinyplace_graphql_home_feed,
|
handle_tinyplace_graphql_home_feed,
|
||||||
handle_tinyplace_graphql_identities,
|
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 {
|
fn schema_bounties_cancel() -> ControllerSchema {
|
||||||
ControllerSchema {
|
ControllerSchema {
|
||||||
namespace: "tinyplace",
|
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 {
|
fn schema_graphql_profile() -> ControllerSchema {
|
||||||
ControllerSchema {
|
ControllerSchema {
|
||||||
namespace: "tinyplace",
|
namespace: "tinyplace",
|
||||||
@@ -2536,7 +2550,6 @@ pub fn all_tinyplace_controller_schemas() -> Vec<ControllerSchema> {
|
|||||||
schema_bounties_list(),
|
schema_bounties_list(),
|
||||||
schema_bounties_get(),
|
schema_bounties_get(),
|
||||||
schema_bounties_create(),
|
schema_bounties_create(),
|
||||||
schema_bounties_fund(),
|
|
||||||
schema_bounties_cancel(),
|
schema_bounties_cancel(),
|
||||||
schema_bounties_submit(),
|
schema_bounties_submit(),
|
||||||
schema_bounties_list_submissions(),
|
schema_bounties_list_submissions(),
|
||||||
@@ -2556,6 +2569,9 @@ pub fn all_tinyplace_controller_schemas() -> Vec<ControllerSchema> {
|
|||||||
// GraphQL Jobs
|
// GraphQL Jobs
|
||||||
schema_graphql_jobs(),
|
schema_graphql_jobs(),
|
||||||
schema_graphql_job(),
|
schema_graphql_job(),
|
||||||
|
// GraphQL Bounties
|
||||||
|
schema_graphql_bounties(),
|
||||||
|
schema_graphql_bounty(),
|
||||||
// GraphQL Profile + Identity
|
// GraphQL Profile + Identity
|
||||||
schema_graphql_profile(),
|
schema_graphql_profile(),
|
||||||
schema_graphql_user(),
|
schema_graphql_user(),
|
||||||
@@ -3023,10 +3039,6 @@ pub fn all_tinyplace_registered_controllers() -> Vec<RegisteredController> {
|
|||||||
schema: schema_bounties_create(),
|
schema: schema_bounties_create(),
|
||||||
handler: handle_tinyplace_bounties_create,
|
handler: handle_tinyplace_bounties_create,
|
||||||
},
|
},
|
||||||
RegisteredController {
|
|
||||||
schema: schema_bounties_fund(),
|
|
||||||
handler: handle_tinyplace_bounties_fund,
|
|
||||||
},
|
|
||||||
RegisteredController {
|
RegisteredController {
|
||||||
schema: schema_bounties_cancel(),
|
schema: schema_bounties_cancel(),
|
||||||
handler: handle_tinyplace_bounties_cancel,
|
handler: handle_tinyplace_bounties_cancel,
|
||||||
@@ -3094,6 +3106,15 @@ pub fn all_tinyplace_registered_controllers() -> Vec<RegisteredController> {
|
|||||||
schema: schema_graphql_job(),
|
schema: schema_graphql_job(),
|
||||||
handler: handle_tinyplace_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
|
// GraphQL Profile + Identity
|
||||||
RegisteredController {
|
RegisteredController {
|
||||||
schema: schema_graphql_profile(),
|
schema: schema_graphql_profile(),
|
||||||
@@ -3291,7 +3312,6 @@ mod tests {
|
|||||||
"openhuman.tinyplace_bounties_list",
|
"openhuman.tinyplace_bounties_list",
|
||||||
"openhuman.tinyplace_bounties_get",
|
"openhuman.tinyplace_bounties_get",
|
||||||
"openhuman.tinyplace_bounties_create",
|
"openhuman.tinyplace_bounties_create",
|
||||||
"openhuman.tinyplace_bounties_fund",
|
|
||||||
"openhuman.tinyplace_bounties_cancel",
|
"openhuman.tinyplace_bounties_cancel",
|
||||||
"openhuman.tinyplace_bounties_submit",
|
"openhuman.tinyplace_bounties_submit",
|
||||||
"openhuman.tinyplace_bounties_list_submissions",
|
"openhuman.tinyplace_bounties_list_submissions",
|
||||||
|
|||||||
@@ -27,10 +27,15 @@ pub(crate) struct TinyPlaceState {
|
|||||||
|
|
||||||
impl TinyPlaceState {
|
impl TinyPlaceState {
|
||||||
/// Build from the environment. `TINYPLACE_API_BASE_URL` overrides the
|
/// 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 {
|
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")
|
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}");
|
log::debug!("{LOG_PREFIX} state created base_url={base_url}");
|
||||||
Self {
|
Self {
|
||||||
client: OnceCell::new(),
|
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_BTC_REST_URL: &str = "https://blockstream.info/api";
|
||||||
const DEFAULT_SOLANA_RPC_URL: &str = "https://api.mainnet-beta.solana.com";
|
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 DEVNET_SOLANA_RPC_URL: &str = "https://api.devnet.solana.com";
|
||||||
const DEFAULT_TRON_REST_URL: &str = "https://api.trongrid.io";
|
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 {
|
pub fn rpc_url_for_evm_network(network: EvmNetwork) -> String {
|
||||||
network.rpc_url()
|
network.rpc_url()
|
||||||
}
|
}
|
||||||
@@ -617,4 +671,83 @@ mod tests {
|
|||||||
assert_eq!(solana_cluster(), SolanaCluster::Mainnet);
|
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::{
|
pub use defaults::{
|
||||||
asset_catalog, default_rpc_url, env_var_for_chain, evm_asset_catalog, explorer_tx_url,
|
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,
|
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,
|
rpc_url_for_evm_network, solana_cluster, tinyplace_solana_rpc_endpoints, EvmNetwork, RpcSource,
|
||||||
WalletAssetDefinition, WalletNetworkDefaults,
|
SolanaCluster, WalletAssetDefinition, WalletNetworkDefaults,
|
||||||
};
|
};
|
||||||
pub use execution::{
|
pub use execution::{
|
||||||
balances, chain_status, execute_prepared, lookup_tx,
|
balances, chain_status, execute_prepared, lookup_tx,
|
||||||
|
|||||||
+110
-17
@@ -40,15 +40,93 @@ fn client() -> reqwest::Client {
|
|||||||
SHARED_CLIENT.clone()
|
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.
|
/// 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>(
|
pub async fn rpc_call<T: DeserializeOwned>(
|
||||||
chain: WalletChain,
|
chain: WalletChain,
|
||||||
method: &str,
|
method: &str,
|
||||||
params: Value,
|
params: Value,
|
||||||
) -> Result<T, String> {
|
) -> 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
|
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.
|
/// JSON-RPC POST against a specific EVM network's RPC URL.
|
||||||
pub async fn evm_rpc_call<T: DeserializeOwned>(
|
pub async fn evm_rpc_call<T: DeserializeOwned>(
|
||||||
network: EvmNetwork,
|
network: EvmNetwork,
|
||||||
@@ -63,6 +141,20 @@ pub async fn rpc_call_to<T: DeserializeOwned>(
|
|||||||
method: &str,
|
method: &str,
|
||||||
params: Value,
|
params: Value,
|
||||||
) -> Result<T, String> {
|
) -> 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!({
|
let payload = json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -75,37 +167,38 @@ pub async fn rpc_call_to<T: DeserializeOwned>(
|
|||||||
redact_rpc_url(url)
|
redact_rpc_url(url)
|
||||||
);
|
);
|
||||||
let client = client();
|
let client = client();
|
||||||
let response = client
|
let response = client.post(url).json(&payload).send().await.map_err(|e| {
|
||||||
.post(url)
|
RpcCallError::Transport(format!("wallet RPC transport failed for {method}: {e}"))
|
||||||
.json(&payload)
|
})?;
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("wallet RPC transport failed for {method}: {e}"))?;
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let raw_body = response
|
let raw_body = response.text().await.map_err(|e| {
|
||||||
.text()
|
RpcCallError::Transport(format!("wallet RPC read body failed for {method}: {e}"))
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| format!("wallet RPC read body failed for {method}: {e}"))?;
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"{LOG_PREFIX} jsonrpc method={method} status={status} body_len={}",
|
"{LOG_PREFIX} jsonrpc method={method} status={status} body_len={}",
|
||||||
raw_body.len()
|
raw_body.len()
|
||||||
);
|
);
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(format!(
|
return Err(RpcCallError::Transport(format!(
|
||||||
"wallet RPC HTTP failure for {method}: status={status} body={raw_body}"
|
"wallet RPC HTTP failure for {method}: status={status} body={raw_body}"
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
let body: Value = serde_json::from_str(&raw_body)
|
let body: Value = serde_json::from_str(&raw_body).map_err(|e| {
|
||||||
.map_err(|e| format!("wallet RPC decode failed for {method}: {e}; body={raw_body}"))?;
|
RpcCallError::Transport(format!(
|
||||||
|
"wallet RPC decode failed for {method}: {e}; body={raw_body}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
if let Some(error) = body.get("error") {
|
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
|
let result = body
|
||||||
.get("result")
|
.get("result")
|
||||||
.cloned()
|
.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)
|
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.
|
/// Plain REST GET returning JSON or text.
|
||||||
|
|||||||
Reference in New Issue
Block a user