Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
143512c
fix(tinyplace): wire handle transfer end-to-end (registry.transfer)
M3gA-Mind Jul 16, 2026
8febe00
Merge remote-tracking branch 'upstream/main' into fix/GH-4929-tinypla…
M3gA-Mind Jul 17, 2026
2c089f2
address review: fmt, PII-safe log, primary-transfer guard, i18n
M3gA-Mind Jul 17, 2026
96bb836
Merge remote-tracking branch 'upstream/main' into fix/GH-4929-tinypla…
M3gA-Mind Jul 17, 2026
8fc1e71
harden transfer confirm UX + drop redundant log prefix (review)
M3gA-Mind Jul 17, 2026
9289c14
chore(release): v0.61.2 [skip ci]
github-actions[bot] Jul 15, 2026
fafe0bf
feat(flows): workflow-builder agent-friendliness plumbing — schema-aw…
senamakel Jul 15, 2026
1c43761
fix(flows): guarantee a terminal state on every flows_build turn (no …
graycyrus Jul 15, 2026
cc3c402
fix(flows): save-safety — no silent live-arming on update, flag empty…
graycyrus Jul 15, 2026
376a8ca
fix(flows): copilot proposals reach the canvas — recognition, race-gu…
graycyrus Jul 15, 2026
b1fdc40
fix(flows): live-updating runs lists + row-action cleanup (enable tog…
graycyrus Jul 15, 2026
dbfcf5a
fix(flows): exempt workflow proposal tools from tokenjuice compaction…
graycyrus Jul 15, 2026
193a399
fix(flows): close stale-read race in flows_update disarm + actionable…
graycyrus Jul 15, 2026
e339e51
fix(chat): don't wipe fresh streaming/tool-timeline on a completed hy…
graycyrus Jul 15, 2026
1c741f6
fix(flows): correct save_workflow + workflow_builder contract text (d…
graycyrus Jul 15, 2026
9b2afb9
feat(core): compile-time media feature gate (media_generation + image…
oxoxDev Jul 15, 2026
eb490b5
fix(flows): canvas Accept persists the proposal (accept = review + sa…
graycyrus Jul 15, 2026
d6b135c
fix(flows): circuit-breaker counts validate/dry_run body-level ok:fal…
graycyrus Jul 15, 2026
e369d24
chore(staging): v0.61.3 [skip ci]
github-actions[bot] Jul 15, 2026
d976d54
chore(release): v0.61.4 [skip ci]
github-actions[bot] Jul 15, 2026
82abc5c
feat(rewards): surface the Rewards page via a cloud-gated sidebar foo…
YellowSnnowmann Jul 15, 2026
7d4770e
fix(flows): show "awaiting approval" state in runs lists when a run i…
graycyrus Jul 15, 2026
6fa793b
chore(rust): enforce warning-free clippy (#4872)
senamakel Jul 15, 2026
54e012f
fix(theme): polish Matrix + HAL 9000 (and other dark) presets to WCAG…
M3gA-Mind Jul 15, 2026
9445c13
fix(voice): ship the voice domain in the desktop build (#4901) (#4917)
CodeGhost21 Jul 15, 2026
6b6cddf
chore(release): v0.61.5 [skip ci]
github-actions[bot] Jul 15, 2026
40401ad
feat(memory): ingest Codex and Claude coding sessions (#4863)
senamakel Jul 16, 2026
0112572
fix(agent): provider-aware super-context + delegation guardrails for …
sanil-23 Jul 16, 2026
91b9219
feat(chat): stop-response button + ESC to interrupt and re-edit promp…
M3gA-Mind Jul 16, 2026
deddc7c
feat(tools): generate_document tool emitting real .docx (#4847 Proble…
M3gA-Mind Jul 16, 2026
3794b47
fix(agent-harness): enforce required structured-output block every tu…
M3gA-Mind Jul 16, 2026
8e8c5af
perf(ui): pause mesh-gradient background when window is unfocused/hid…
M3gA-Mind Jul 16, 2026
b278f8e
feat(accessibility): ranked UI-element matcher for reliable selection…
M3gA-Mind Jul 16, 2026
01bb34d
perf(services): start login-gated services concurrently to unblock co…
M3gA-Mind Jul 16, 2026
f91b55b
fix(orchestration): surface async sub-agent failures/awaiting-user in…
M3gA-Mind Jul 16, 2026
38161fe
fix(inference): honor Retry-After in the streaming retry loop (#4895)…
M3gA-Mind Jul 16, 2026
1bdf362
fix(channels): correct channel-setup nav paths, add in-app guidance (…
YellowSnnowmann Jul 16, 2026
f9838ab
fix(flows): surface connected platform_user_id so the builder wires s…
graycyrus Jul 16, 2026
27ff4d5
fix(i18n): repair grammar across locale copy (#4939)
senamakel Jul 16, 2026
f7f56da
feat(web3): compile-time web3 feature gate for wallet/web3/x402 (#480…
oxoxDev Jul 16, 2026
86479a3
fix(flows): expose platform_user_id in flows_list_connections schema …
graycyrus Jul 16, 2026
ee5f570
fix(flows): persist agentic task insights expand state across copilot…
graycyrus Jul 16, 2026
ea005c9
fix(flows): copilot chat stays scrollable (auto-scroll only when pinn…
graycyrus Jul 16, 2026
30fcb1f
fix(conversations): key the proactive-thread insights fallback (discl…
graycyrus Jul 16, 2026
ce8b7e9
fix(flows): stable issue-order for tool-call timeline (monotonic seq …
graycyrus Jul 16, 2026
6bd9514
feat(agent): public accessor for last-turn usage totals (#4940)
senamakel Jul 16, 2026
b8aaaa8
fix(conversations): add required seq to ToolTimelineEntry test fixtur…
M3gA-Mind Jul 16, 2026
4b0fd25
fix(app): forward voice + tokenjuice-treesitter to the desktop build …
oxoxDev Jul 16, 2026
4f3a361
feat(core): compile-time meet feature gate (#4800) (#4915)
oxoxDev Jul 16, 2026
c7009d9
feat(safety): Emergency Stop for desktop automation (#4255) (#4600)
CodeGhost21 Jul 16, 2026
5f55f33
feat(core): compile-time skills feature gate (#4798) (#4913)
oxoxDev Jul 16, 2026
78546ff
fix(build): forward tokenjuice-treesitter + gate feature-forwarding d…
CodeGhost21 Jul 16, 2026
31aba3b
fix(flows): trail-off backstop no longer clobbers real questions (#4956)
graycyrus Jul 16, 2026
24c63b1
fix(flows): resolve a non-owner Slack DM recipient via a lookup node …
graycyrus Jul 16, 2026
e7ceea9
feat(core): compile-time flows feature gate (#4797) (#4912)
oxoxDev Jul 16, 2026
2e787e3
fix(memory): exclude same-session docs from recall so agents don't re…
graycyrus Jul 16, 2026
ef93bd9
feat(core): compile-time mcp feature gate (#4799) (#4914)
oxoxDev Jul 16, 2026
c74fbf0
fix(flows): close Composio tool_call tier-gate bypass (C1) (#5000)
graycyrus Jul 16, 2026
a56cd26
fix(flows): preserve canvas viewport and skip redundant remount on sa…
graycyrus Jul 16, 2026
4422254
fix(tinyplace): handle DM to a non-contact gracefully (Closes #4921) …
M3gA-Mind Jul 17, 2026
dc33928
fix(tinyplace): paginate the Tiny Place ledger listing (#4952)
M3gA-Mind Jul 17, 2026
542b7f8
fix(memory): use stable document_id as sync upsert key (fixes #4947 B…
M3gA-Mind Jul 17, 2026
36bf306
fix(settings): scope Clear App Data reset to the signed-in user (#495…
M3gA-Mind Jul 17, 2026
1bf9c25
fix(conversations): reset agentic task insights override on turn sett…
graycyrus Jul 17, 2026
988b117
fix(flows): builder chat hygiene — run-tool honesty (Bld #4) + reply …
graycyrus Jul 17, 2026
5f6f510
fix(tinyplace): paginate the Agent World feed (Closes #4923) (#4989)
M3gA-Mind Jul 17, 2026
8c1b2dd
fix(conversations): drive agentic task insights reset off turn lifecy…
graycyrus Jul 17, 2026
1bb5360
fix(flows): dismissible/auto-clearing run-error banner, no longer ove…
graycyrus Jul 17, 2026
4fa8475
fix(flows): resolve-before-you-ask — wire runtime lookups instead of …
graycyrus Jul 17, 2026
1868c18
fix(flows): make Composio tier gating effect-aware (reads no longer p…
graycyrus Jul 17, 2026
5c2af38
fix(tinyplace): live-update open DM thread via inbox stream (#4988)
M3gA-Mind Jul 18, 2026
6bf499d
fix(tinyplace): open the profile when a Directory entry is clicked (C…
M3gA-Mind Jul 18, 2026
79c669b
fix(i18n): localize the Codex auth button (Closes #4852) (#4992)
M3gA-Mind Jul 18, 2026
7790c5d
fix(composio): stop auto-registering unsyncable toolkits as memory so…
M3gA-Mind Jul 18, 2026
929f56d
fix(tinyplace): real-time feed updates via feeds stream (#4926) (#4994)
M3gA-Mind Jul 18, 2026
24864aa
refactor(core): relocate the web chat conduit out of channels/ (#5002…
oxoxDev Jul 18, 2026
9117b08
fix(tauri): resolve macOS-gated clippy -D warnings blocking pre-push …
oxoxDev Jul 18, 2026
9975450
fix(composio): gate per-action tools on their full contract (#4853) (…
M3gA-Mind Jul 18, 2026
baeda24
feat(tinyplace): add own-profile editing (name/bio/avatar) to Agent W…
M3gA-Mind Jul 18, 2026
1ec3396
feat(tinyplace): public profile viewer for arbitrary handles (#4931) …
M3gA-Mind Jul 18, 2026
d0ba4e9
fix(webview-accounts): keep X/Twitter Google (GSI) sign-in popup in-a…
M3gA-Mind Jul 18, 2026
310bfa1
fix(core): unbreak the gates-off build and make the smoke lane run te…
oxoxDev Jul 18, 2026
6a833dc
fix(learning): decouple always-on learning subscribers from channels …
oxoxDev Jul 18, 2026
4264c97
Merge remote-tracking branch 'upstream/main'
senamakel Jul 18, 2026
d02b5e7
Merge branch 'main' into pr/4998
senamakel Jul 18, 2026
1adfc28
Merge remote-tracking branch 'upstream/main' into pr/4998
senamakel Jul 18, 2026
2bd9f45
fix(tinyplace): fail closed on unknown primary state
senamakel Jul 18, 2026
00071c9
ci: acknowledge composio gated tests
senamakel Jul 18, 2026
f1edcd4
Merge remote-tracking branch 'upstream/main' into fix/GH-4929-tinypla…
M3gA-Mind Jul 20, 2026
6d07c4e
Merge remote-tracking branch 'upstream/main' into fix/GH-4929-tinypla…
M3gA-Mind Jul 21, 2026
e6c5773
fix(tinyplace): real read-back + idempotent/validated handle transfer…
M3gA-Mind Jul 21, 2026
5d0480d
fix(tinyplace): address CodeRabbit — drop redundant read-back GET + i…
M3gA-Mind Jul 21, 2026
177304c
test(agentworld): update ProfilesSection transfer tests for M3 disabl…
M3gA-Mind Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions app/src/agentworld/components/TransferHandleModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Tests for TransferHandleModal (GH-4929) — the confirm + execute dialog for a
* Tiny Place handle transfer. A transfer is destructive/irreversible, so these
* assert the wiring to `apiClient.registry.transfer`, that confirm is gated on a
* recipient, and that the flow fails CLOSED (error keeps the dialog open and
* never reports success).
*
* All handles/recipients are generic placeholders, never real identities.
*/
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, test, vi } from 'vitest';

import { renderWithProviders } from '../../test/test-utils';
import { apiClient } from '../AgentWorldShell';
import TransferHandleModal from './TransferHandleModal';

vi.mock('../AgentWorldShell', () => ({ apiClient: { registry: { transfer: vi.fn() } } }));

const transfer = vi.mocked(apiClient.registry.transfer);

beforeEach(() => {
vi.clearAllMocks();
});

function setup() {
const onClose = vi.fn();
const onTransferred = vi.fn();
renderWithProviders(
<TransferHandleModal handle="alpha" onClose={onClose} onTransferred={onTransferred} />
);
return { onClose, onTransferred };
}

describe('TransferHandleModal', () => {
test('shows the handle + irreversible warning and gates confirm on a recipient', () => {
setup();
expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument();
expect(screen.getByText('@alpha')).toBeInTheDocument();
expect(screen.getByText(/permanent and cannot be undone/i)).toBeInTheDocument();
// Confirm is disabled until a recipient is entered.
expect(screen.getByTestId('transfer-handle-confirm')).toBeDisabled();
});

test('keeps confirm disabled until the exact handle is re-typed', async () => {
const user = userEvent.setup();
setup();
const confirmBtn = screen.getByTestId('transfer-handle-confirm');
// A recipient alone is not enough for a destructive action.
await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo');
expect(confirmBtn).toBeDisabled();
// A wrong handle keeps it disabled.
await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'wrong');
expect(confirmBtn).toBeDisabled();
// The exact handle (case- and @-insensitive) enables it.
await user.clear(screen.getByTestId('transfer-handle-confirm-input'));
await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@ALPHA');
expect(confirmBtn).toBeEnabled();
});

test('confirming transfers to the resolved recipient, then closes on success', async () => {
const user = userEvent.setup();
transfer.mockResolvedValueOnce({ identity: { username: 'alpha' } as never });
const { onClose, onTransferred } = setup();

await user.type(screen.getByPlaceholderText(/Recipient @handle/i), '@bravo');
// The irreversible action is gated behind re-typing the handle.
await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@alpha');
await user.click(screen.getByTestId('transfer-handle-confirm'));

// Leading @ is stripped before the RPC; handle passed through verbatim.
await waitFor(() => expect(transfer).toHaveBeenCalledWith('alpha', 'bravo'));
await waitFor(() => expect(onTransferred).toHaveBeenCalledTimes(1));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('transfer-handle-error')).not.toBeInTheDocument();
});

test('fails closed: on error it shows the message and does not report success', async () => {
const user = userEvent.setup();
transfer.mockRejectedValueOnce(new Error('recipient handle is not registered on tiny.place'));
const { onClose, onTransferred } = setup();

await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo');
await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'alpha');
await user.click(screen.getByTestId('transfer-handle-confirm'));

await waitFor(() =>
expect(screen.getByTestId('transfer-handle-error')).toHaveTextContent(/not registered/i)
);
// Fail closed: no success callbacks, dialog stays open.
expect(onTransferred).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument();
});
});
155 changes: 155 additions & 0 deletions app/src/agentworld/components/TransferHandleModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* TransferHandleModal — confirm + execute a Tiny Place handle transfer (GH-4929).
*
* A handle transfer is DESTRUCTIVE and irreversible for the sender: on success
* the recipient becomes the handle's sole owner. So this modal states that
* plainly, requires an explicit recipient, requires the user to re-type the
* handle to confirm intent, and takes an explicit confirm click — and it fails
* **closed**: on any error it keeps the dialog open with the message and never
* reports success. The core handler resolves the recipient @handle and
* read-back-confirms the new owner before this promise resolves, so a resolved
* transfer means the reassignment actually landed.
*/
import debugFactory from 'debug';
import { useCallback, useState } from 'react';

import Button from '../../components/ui/Button';
import { ModalShell } from '../../components/ui/ModalShell';
import { useT } from '../../lib/i18n/I18nContext';
import { apiClient } from '../AgentWorldShell';

// Namespaced already ('agentworld:identity'), so messages carry no prefix.
const debug = debugFactory('agentworld:identity');

export interface TransferHandleModalProps {
/** The handle being transferred away (without a leading @). */
handle: string;
onClose: () => void;
/** Called after a confirmed, read-back-verified transfer. */
onTransferred: () => void;
}

/** Normalize a handle for comparison: strip leading @, trim, lowercase. */
function normalizeHandle(value: string): string {
return value.trim().replace(/^@+/, '').toLowerCase();
}

export default function TransferHandleModal({
handle,
onClose,
onTransferred,
}: TransferHandleModalProps) {
const { t } = useT();
const [recipient, setRecipient] = useState('');
const [confirmText, setConfirmText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleClean = handle.replace(/^@+/, '');
// Guard the irreversible action: the user must re-type the exact handle.
const confirmMatches = normalizeHandle(confirmText) === normalizeHandle(handleClean);

const submit = useCallback(async () => {
const target = recipient.trim().replace(/^@+/, '');
if (!target) {
setError(t('agentWorld.transferHandle.recipientRequired'));
return;
}
// Belt-and-suspenders: the button is disabled without a match, but never
// execute a destructive transfer unless the typed confirmation matches.
if (!confirmMatches) {
setError(t('agentWorld.transferHandle.confirmMismatch'));
return;
}
setSubmitting(true);
setError(null);
// Never log the handle or recipient — both identify a user.
debug('handle transfer requested');
try {
// Send the normalized handle (not the raw prop) so the invariant is local
// and doesn't rest on every caller pre-cleaning the value (#4998 review).
await apiClient.registry.transfer(handleClean, target);
debug('handle transfer confirmed');
onTransferred();
onClose();
} catch (err) {
// Fail closed: keep the dialog open, show why, report no success.
// Log only the status (no raw error — it can carry backend/SDK detail);
// the raw message still surfaces in the UI via setError.
debug('handle transfer failed');
setError(String(err));
setSubmitting(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}, [recipient, confirmMatches, handleClean, t, onTransferred, onClose]);

return (
<ModalShell
title={t('agentWorld.transferHandle.title')}
titleId="agentworld-transfer-handle-title"
maxWidthClassName="max-w-sm"
onClose={submitting ? () => undefined : onClose}>
<div className="space-y-4" data-testid="transfer-handle-modal">
<p className="text-sm text-content">@{handleClean}</p>
<p className="text-xs text-red-600 dark:text-red-400">
{t('agentWorld.transferHandle.warning')}
</p>

<input
type="text"
value={recipient}
onChange={e => {
setRecipient(e.target.value);
setError(null);
}}
disabled={submitting}
placeholder={t('agentWorld.transferHandle.recipientPlaceholder')}
aria-label={t('agentWorld.transferHandle.recipientPlaceholder')}
className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500"
/>

{/* Type-to-confirm guard for the irreversible action. */}
<div className="space-y-1">
<p className="text-xs text-content-muted">
{t('agentWorld.transferHandle.confirmLabel')}
</p>
<input
type="text"
value={confirmText}
onChange={e => {
setConfirmText(e.target.value);
setError(null);
}}
disabled={submitting}
placeholder={`@${handleClean}`}
aria-label={t('agentWorld.transferHandle.confirmLabel')}
data-testid="transfer-handle-confirm-input"
className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500"
/>
</div>

{error && (
<p className="text-xs text-red-600 dark:text-red-400" data-testid="transfer-handle-error">
{error}
</p>
)}

<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
tone="danger"
onClick={() => void submit()}
disabled={submitting || !recipient.trim() || !confirmMatches}
data-testid="transfer-handle-confirm">
{submitting
? t('agentWorld.transferHandle.submitting')
: t('agentWorld.transferHandle.confirm')}
</Button>
</div>
</div>
</ModalShell>
);
}
70 changes: 69 additions & 1 deletion app/src/agentworld/pages/ProfilesSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock('../AgentWorldShell', () => ({
apiClient: {
directory: { reverse: vi.fn() },
follows: { stats: vi.fn() },
registry: { export: vi.fn(), assignPrimary: vi.fn() },
registry: { export: vi.fn(), assignPrimary: vi.fn(), transfer: vi.fn() },
graphql: { user: vi.fn() },
users: { get: vi.fn(), updateProfile: vi.fn() },
},
Expand Down Expand Up @@ -362,6 +362,74 @@ function makeProfile(overrides: Partial<GqlProfile> = {}): GqlProfile {
};
}

// GH-4929: a non-primary owned handle offers an enabled Transfer action that
// opens the destructive-confirm modal. #4998 M3: a primary (active) handle now
// renders a DISABLED Transfer control with an explanation (make another handle
// active first) instead of silently omitting it. A handle with no explicit
// primary flag (unknown state) shows no Transfer control at all.
describe('handle transfer action', () => {
test('opens the transfer confirm modal for a non-primary owned handle', async () => {
graphqlUser.mockResolvedValueOnce(
makeProfile({
displayName: 'Owner',
identities: [
{ ...minimalIdentity, username: 'primaryhandle', cryptoId: SOLANA_ADDR, primary: true },
{ ...minimalIdentity, username: 'giftme', cryptoId: SOLANA_ADDR, primary: false },
],
})
);
render(<ProfilesSection />);

// Both handles render a Transfer control (#4998 M3): the non-primary one is
// enabled, the primary one disabled. Pick the enabled one — it opens the modal.
const transferButtons = await screen.findAllByRole('button', { name: 'Transfer' });
const enabled = transferButtons.find(b => !(b as HTMLButtonElement).disabled);
expect(enabled, 'the non-primary handle must expose an enabled Transfer control').toBeDefined();
fireEvent.click(enabled!);

// The destructive-confirm modal opens with its explicit confirm control.
expect(await screen.findByTestId('transfer-handle-modal')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Transfer handle' })).toBeInTheDocument();
});

test('shows a disabled Transfer control with an explanation for a primary handle (#4998 M3)', async () => {
graphqlUser.mockResolvedValueOnce(
makeProfile({
displayName: 'Owner',
identities: [
{ ...minimalIdentity, username: 'onlyprimary', cryptoId: SOLANA_ADDR, primary: true },
],
})
);
render(<ProfilesSection />);

// Rendered, but disabled and explained — not an invisible dead end.
const transferButton = await screen.findByRole('button', { name: 'Transfer' });
expect(transferButton).toBeDisabled();
expect(transferButton).toHaveAttribute(
'title',
'A primary handle cannot be transferred. Make another handle active first.'
);

// Clicking the locked control must NOT open the destructive modal.
fireEvent.click(transferButton);
expect(screen.queryByTestId('transfer-handle-modal')).not.toBeInTheDocument();
});

test('shows no Transfer control when the primary flag is absent (unknown state)', async () => {
graphqlUser.mockResolvedValueOnce(
makeProfile({
displayName: 'Owner',
identities: [{ ...minimalIdentity, username: 'unknownstate', cryptoId: SOLANA_ADDR }],
})
);
render(<ProfilesSection />);

expect(await screen.findByRole('button', { name: 'Make active' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Transfer' })).not.toBeInTheDocument();
});
});

describe('graphql-enriched profile card', () => {
test('renders rich profile from graphql.user when available', async () => {
graphqlUser.mockResolvedValueOnce(
Expand Down
34 changes: 34 additions & 0 deletions app/src/agentworld/pages/ProfilesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { useT } from '../../lib/i18n/I18nContext';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
import TransferHandleModal from '../components/TransferHandleModal';

const log = debug('agentworld:profile');

Expand Down Expand Up @@ -348,6 +349,8 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?
// Handle currently being promoted to primary (in-flight), and any error.
const [switchingHandle, setSwitchingHandle] = useState<string | null>(null);
const [switchError, setSwitchError] = useState<string | null>(null);
// The owned handle whose transfer modal is open (without @), or null.
const [transferHandle, setTransferHandle] = useState<string | null>(null);

// ── Extract display fields from either data source ─────────────────────────
const isGraphql = data.source === 'graphql';
Expand Down Expand Up @@ -568,6 +571,29 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?
: 'Make active'}
</Button>
)}
{/* Transfer is destructive/irreversible — the modal confirms
intent and fails closed. A primary (active) handle is
locked from sale/transfer server-side, so it renders
disabled with an explanation of the path out (make another
handle active first) rather than silently vanishing (#4998
M3). Only an explicit non-primary handle is transferable. */}
{id.primary === false && (
<Button
variant="secondary"
size="sm"
onClick={() => setTransferHandle(id.username.replace(/^@+/, ''))}>
{t('agentWorld.transferHandle.action')}
</Button>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
M3gA-Mind marked this conversation as resolved.
{id.primary === true && (
<Button
variant="secondary"
size="sm"
disabled
title={t('agentWorld.transferHandle.primaryLocked')}>
{t('agentWorld.transferHandle.action')}
</Button>
)}
<span className="text-[10px] uppercase tracking-wide text-content-faint">
{id.status}
</span>
Expand All @@ -581,6 +607,14 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?
</div>
)}

{transferHandle && (
<TransferHandleModal
handle={transferHandle}
onClose={() => setTransferHandle(null)}
onTransferred={() => onSwitched?.()}
/>
)}

{followStats && (
<div className="mt-4 border-t border-line pt-4">
<div className="flex gap-6">
Expand Down
Loading
Loading