diff --git a/app/src/agentworld/pages/ProfilesSection.test.tsx b/app/src/agentworld/pages/ProfilesSection.test.tsx index 5f8a449a7f..ddcea8893f 100644 --- a/app/src/agentworld/pages/ProfilesSection.test.tsx +++ b/app/src/agentworld/pages/ProfilesSection.test.tsx @@ -21,6 +21,7 @@ vi.mock('../AgentWorldShell', () => ({ follows: { stats: vi.fn() }, registry: { export: vi.fn(), assignPrimary: vi.fn() }, graphql: { user: vi.fn() }, + users: { get: vi.fn(), updateProfile: vi.fn() }, }, })); vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() })); @@ -31,6 +32,8 @@ const followStats = vi.mocked(apiClient.follows.stats); const registryExport = vi.mocked(apiClient.registry.export); const assignPrimary = vi.mocked(apiClient.registry.assignPrimary); const graphqlUser = vi.mocked(apiClient.graphql.user); +const usersGet = vi.mocked(apiClient.users.get); +const updateProfile = vi.mocked(apiClient.users.updateProfile); const SOLANA_ADDR = 'WaLLetSoLanaAddr0123456789'; @@ -51,6 +54,26 @@ beforeEach(() => { graphqlUser.mockResolvedValue(null); reverse.mockResolvedValue({ cryptoId: SOLANA_ADDR, identities: [] }); followStats.mockResolvedValue({ agentId: '', followerCount: 0, followingCount: 0 }); + // Own-profile edit path (#4930): users.get seeds the writable fields, and + // updateProfile echoes back the saved User. + usersGet.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Test Agent', + bio: '', + emailVerified: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } as unknown as Awaited>); + updateProfile.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Test Agent', + bio: '', + emailVerified: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } as unknown as Awaited>); }); // ── Loading ─────────────────────────────────────────────────────────────────── @@ -483,3 +506,120 @@ describe('graphql-enriched profile card', () => { expect(screen.queryByText('Verified Accounts')).not.toBeInTheDocument(); }); }); + +// ── Own-profile editing (#4930) ────────────────────────────────────────────── + +describe('own-profile editing', () => { + test('offers an Edit profile button on the own graphql profile', async () => { + graphqlUser.mockResolvedValueOnce(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + render(); + expect(await screen.findByText('Agent Alice')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit profile/i })).toBeInTheDocument(); + }); + + test('saves name + bio via users.updateProfile, then refetches so saved values show', async () => { + graphqlUser + .mockResolvedValueOnce(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })) + .mockResolvedValue(makeProfile({ displayName: 'Agent Alice v2', bio: 'New bio' })); + usersGet.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Agent Alice', + bio: 'Old bio', + emailVerified: false, + createdAt: '', + updatedAt: '', + } as unknown as Awaited>); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + + const nameInput = await screen.findByRole('textbox', { name: /display name/i }); + // Prefilled from users.get (the authoritative writable record). + await waitFor(() => expect(nameInput).toHaveValue('Agent Alice')); + const bioInput = screen.getByRole('textbox', { name: /^bio$/i }); + + fireEvent.change(nameInput, { target: { value: 'Agent Alice v2' } }); + fireEvent.change(bioInput, { target: { value: 'New bio' } }); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => + expect(updateProfile).toHaveBeenCalledWith( + SOLANA_ADDR, + expect.objectContaining({ displayName: 'Agent Alice v2', bio: 'New bio' }) + ) + ); + // Refetch renders the saved values. + expect(await screen.findByText('Agent Alice v2')).toBeInTheDocument(); + }); + + test('a late prefill fetch does not clobber what the user already typed (#4930)', async () => { + graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + // Defer users.get so it resolves AFTER the user has started editing. + let resolveGet!: (value: unknown) => void; + const deferred = new Promise(res => { + resolveGet = res; + }); + usersGet.mockReturnValue(deferred as unknown as ReturnType); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + + const nameInput = await screen.findByRole('textbox', { name: /display name/i }); + // User types before the prefill lands. + fireEvent.change(nameInput, { target: { value: 'My New Name' } }); + + // Prefill resolves late with the authoritative (different) record. + resolveGet({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Agent Alice', + bio: 'Old bio', + avatarEmail: 'alice@example.com', + emailVerified: false, + createdAt: '', + updatedAt: '', + }); + + // The user's edit survives; the prefill must not overwrite the touched field. + await waitFor(() => expect(nameInput).toHaveValue('My New Name')); + }); + + test('keeps the form open and surfaces an error when save fails', async () => { + graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + updateProfile.mockRejectedValueOnce(new Error('network down')); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + fireEvent.click(await screen.findByRole('button', { name: /^save$/i })); + + expect(await screen.findByText(/could not save your profile/i)).toBeInTheDocument(); + expect(screen.getByTestId('profile-edit-form')).toBeInTheDocument(); + expect(updateProfile).toHaveBeenCalledTimes(1); + }); + + test('will not save an empty display name (guards against blanking it)', async () => { + graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + usersGet.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Agent Alice', + bio: 'Old bio', + emailVerified: false, + createdAt: '', + updatedAt: '', + } as unknown as Awaited>); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + const nameInput = await screen.findByRole('textbox', { name: /display name/i }); + await waitFor(() => expect(nameInput).toHaveValue('Agent Alice')); + + // Clear the name → Save is disabled and never blanks the profile. + fireEvent.change(nameInput, { target: { value: ' ' } }); + const saveBtn = screen.getByRole('button', { name: /^save$/i }); + expect(saveBtn).toBeDisabled(); + fireEvent.click(saveBtn); + expect(updateProfile).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/agentworld/pages/ProfilesSection.tsx b/app/src/agentworld/pages/ProfilesSection.tsx index e98d315220..29268e8c97 100644 --- a/app/src/agentworld/pages/ProfilesSection.tsx +++ b/app/src/agentworld/pages/ProfilesSection.tsx @@ -7,7 +7,8 @@ * "register a handle" prompt when the wallet owns none, and a wallet-locked * notice when the wallet isn't set up. */ -import { useCallback, useEffect, useState } from 'react'; +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; import PanelScaffold from '../../components/layout/PanelScaffold'; import Button from '../../components/ui/Button'; @@ -18,9 +19,12 @@ import { type IdentityExport, PaymentRequiredError, } from '../../lib/agentworld/invokeApiClient'; +import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; +const log = debug('agentworld:profile'); + /** A handle registered to the wallet (subset of the directory.reverse identity). */ interface OwnedIdentity { username?: string; @@ -168,7 +172,175 @@ function useMyIdentity(reloadKey: number): ProfileState { // ── Sub-components ──────────────────────────────────────────────────────────── +/** + * Own-profile edit form (name / bio / avatar), wired to the already-plumbed + * `users.updateProfile` write path (#4930). Prefills the writable fields from + * the authoritative `users.get` record — including `avatarEmail`, which the + * display-side `GqlProfile` does not carry (the shown avatar URL is derived + * server-side from the Gravatar email). On success the parent refetches so the + * saved values render. + */ +function ProfileEditForm({ + cryptoId, + initialDisplayName, + initialBio, + onCancel, + onSaved, +}: { + cryptoId: string; + initialDisplayName: string; + initialBio: string; + onCancel: () => void; + onSaved: () => void; +}) { + const { t } = useT(); + const [displayName, setDisplayName] = useState(initialDisplayName); + const [bio, setBio] = useState(initialBio); + const [avatarEmail, setAvatarEmail] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + // Which fields the user has edited. The async prefill below must never + // overwrite a field the user already started typing in (its fetch can resolve + // after the user opens the form and begins editing — silently discarding + // their input otherwise, #4930 review). + const touched = useRef({ displayName: false, bio: false, avatarEmail: false }); + + // Seed the writable fields (notably avatarEmail, absent from GqlProfile) from + // the authoritative user record. Non-fatal: on failure we keep the values + // already seeded from the displayed profile. Untouched fields only. + useEffect(() => { + let cancelled = false; + void apiClient.users + .get(cryptoId) + .then(user => { + if (cancelled) return; + if (!touched.current.displayName && typeof user?.displayName === 'string') + setDisplayName(user.displayName); + if (!touched.current.bio && typeof user?.bio === 'string') setBio(user.bio); + if (!touched.current.avatarEmail && typeof user?.avatarEmail === 'string') + setAvatarEmail(user.avatarEmail); + }) + .catch((err: unknown) => { + log('prefill from users.get failed: %s', String(err)); + }); + return () => { + cancelled = true; + }; + }, [cryptoId]); + + const handleSave = useCallback(async () => { + // A display name is required: `displayName`/`bio` are always sent trimmed, + // so an empty name would blank the existing one. Guard here (and disable + // Save below) rather than silently clearing it. `bio` may be empty; only + // `avatarEmail` is omitted-when-empty (so an existing avatar isn't cleared). + if (!displayName.trim()) { + setError(t('agentWorld.profile.nameRequired', 'Display name cannot be empty.')); + return; + } + setSaving(true); + setError(null); + log('saving profile update'); + try { + await apiClient.users.updateProfile(cryptoId, { + displayName: displayName.trim(), + bio: bio.trim(), + // Only send avatarEmail when the user provided one, so an untouched + // (empty) field never blanks an existing avatar. It is a Gravatar + // email, not an image URL. + ...(avatarEmail.trim() ? { avatarEmail: avatarEmail.trim() } : {}), + }); + log('profile update saved'); + onSaved(); + } catch (err) { + log('profile update failed: %s', String(err)); + setError(t('agentWorld.profile.saveError', 'Could not save your profile. Try again.')); + } finally { + setSaving(false); + } + }, [avatarEmail, bio, cryptoId, displayName, onSaved, t]); + + const inputClass = + 'w-full rounded-md border border-line bg-surface px-2.5 py-1.5 text-sm text-content ' + + 'placeholder:text-content-faint focus:border-primary-500 focus:outline-none'; + + return ( +
{ + e.preventDefault(); + void handleSave(); + }}> + + +