From 6501bda75524fc6c9e67df5d69ea3bfc52fa6f92 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 10 Jun 2026 15:11:18 -0700 Subject: [PATCH 1/7] feat(desktop): restore archive identity UI in profile panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Manage section below the quick-actions row with a full-width Archive / Unarchive button, plus the "Archived on this relay" flair under the displayName. Gated by the original three-path composition (self / relay admin or owner / NIP-OA owner of viewee) — the relay re-verifies authority on submit. Button label flips to "Archive agent" on bot profiles. Restores the 5-case e2e gate matrix that #917 dropped alongside the UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../features/profile/ui/UserProfilePanel.tsx | 60 +++++++++ .../profile/ui/UserProfilePanelSections.tsx | 105 ++++++++++++++++ desktop/tests/e2e/identity-archive.spec.ts | 115 ++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 desktop/tests/e2e/identity-archive.spec.ts diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f71a5f575..6b1484e76 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { ArrowLeft, X } from "lucide-react"; +import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { @@ -15,6 +16,12 @@ import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsSto import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { + useArchiveIdentityMutation, + useIsIdentityArchived, + useOaOwnerQuery, + useUnarchiveIdentityMutation, +} from "@/features/identity-archive/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { useContactListQuery, @@ -28,6 +35,7 @@ import { MemoryFocusedView, ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; +import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; @@ -162,6 +170,17 @@ export function UserProfilePanel({ const contactListQuery = useContactListQuery(currentPubkey); const followMutation = useFollowMutation(currentPubkey); const unfollowMutation = useUnfollowMutation(currentPubkey); + const myMembershipQuery = useMyRelayMembershipQuery(); + // Skip the kind:0 lookup when viewing yourself — the OA gate is for + // archiving *other* identities you own. + const oaOwnerQuery = useOaOwnerQuery( + pubkey, + currentPubkey !== undefined && + pubkey.toLowerCase() !== currentPubkey.toLowerCase(), + ); + const isArchived = useIsIdentityArchived(pubkey); + const archiveMutation = useArchiveIdentityMutation(); + const unarchiveMutation = useUnarchiveIdentityMutation(); const { onOpenAgentSession } = useAgentSession(); const { goChannel } = useAppNavigation(); @@ -197,6 +216,15 @@ export function UserProfilePanel({ const isSelf = currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); const canViewActivity = isOwner === true && Boolean(onOpenAgentSession); + // NIP-IA gate composition. The button shows when ANY of: self path (acting + // on own pubkey), admin path (current user is owner/admin in relay_members), + // or OA-owner path (current user is the verified NIP-OA owner of the viewee + // per its live kind:0). The relay re-verifies authority on submit; this gate + // is purely a UX guard. + const myRole = myMembershipQuery.data?.role; + const isRelayAdminOrOwner = myRole === "owner" || myRole === "admin"; + const isOaOwnerOfViewee = oaOwnerQuery.data?.isMe === true; + const canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee; const isFollowing = !isSelf && (contactListQuery.data?.contacts.some( @@ -250,6 +278,32 @@ export function UserProfilePanel({ [goChannel], ); + const handleArchive = React.useCallback(() => { + archiveMutation.mutate( + { targetPubkey: pubkey }, + { + onSuccess: () => toast.success("Archived on this relay"), + onError: (error) => + toast.error( + `Archive failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }, + ); + }, [archiveMutation, pubkey]); + + const handleUnarchive = React.useCallback(() => { + unarchiveMutation.mutate( + { targetPubkey: pubkey }, + { + onSuccess: () => toast.success("Unarchived on this relay"), + onError: (error) => + toast.error( + `Unarchive failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }, + ); + }, [pubkey, unarchiveMutation]); + const displayName = profile?.displayName ?? truncatePubkey(pubkey); const ownerHandle = React.useMemo(() => { if (currentPubkey === undefined) { @@ -316,6 +370,8 @@ export function UserProfilePanel({ > {view === "summary" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 6d30e4f0d..d629c067d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -2,6 +2,8 @@ import * as React from "react"; import type { LucideIcon } from "lucide-react"; import { Activity, + Archive, + ArchiveRestore, ArrowUpRight, Brain, ChevronDown, @@ -26,6 +28,10 @@ import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import type { + useArchiveIdentityMutation, + useUnarchiveIdentityMutation, +} from "@/features/identity-archive/hooks"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { @@ -41,6 +47,7 @@ import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; const RUNTIME_LABELS: Record = { goose: "Goose", @@ -61,6 +68,8 @@ async function copyToClipboard(value: string, label?: string) { // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { + archiveMutation: ReturnType; + canArchive: boolean; canEditAgent: boolean; canViewActivity: boolean; channelCount: number; @@ -68,9 +77,12 @@ export type ProfileSummaryViewProps = { channelsLoading: boolean; displayName: string; followMutation: ReturnType; + handleArchive: () => void; handleEditAgent: () => void; handleMessage: () => void; handleOpenActivity: () => void; + handleUnarchive: () => void; + isArchived: boolean | undefined; isBot: boolean; isFollowing: boolean; isOwner: boolean | undefined; @@ -88,11 +100,14 @@ export type ProfileSummaryViewProps = { profile: ReturnType["data"]; pubkey: string; relayAgent: RelayAgent | undefined; + unarchiveMutation: ReturnType; unfollowMutation: ReturnType; userStatus: { text: string; emoji: string } | null | undefined; }; export function ProfileSummaryView({ + archiveMutation, + canArchive, canEditAgent, canViewActivity, channelCount, @@ -100,9 +115,12 @@ export function ProfileSummaryView({ channelsLoading, displayName, followMutation, + handleArchive, handleEditAgent, handleMessage, handleOpenActivity, + handleUnarchive, + isArchived, isBot, isFollowing, isOwner, @@ -120,6 +138,7 @@ export function ProfileSummaryView({ profile, pubkey, relayAgent, + unarchiveMutation, unfollowMutation, userStatus, }: ProfileSummaryViewProps) { @@ -149,10 +168,13 @@ export function ProfileSummaryView({ const showChannelsIngress = channelsLoading || channelCount > 0 || isBot || relayAgent !== undefined; + const showManageSection = canArchive && isArchived !== undefined; + return (
) : null} + {showManageSection ? ( + + ) : null} + + {showMemoriesIngress || showChannelsIngress || canViewActivity ? (
{showMemoriesIngress ? ( @@ -263,12 +297,14 @@ function ProfileWorkingBadge({ function ProfileHero({ displayName, + isArchived, isBot, presenceStatus, profile, userStatus, }: { displayName: string; + isArchived: boolean | undefined; isBot: boolean; presenceStatus: "online" | "away" | "offline" | undefined; profile: ProfileSummaryViewProps["profile"]; @@ -311,6 +347,17 @@ function ProfileHero({ ) : null}
+ {isArchived === true ? ( + + + Archived on this relay + + ) : null} + {profile?.about?.trim() ? ( ; + isArchived: boolean; + isBot: boolean; + onArchive: () => void; + onUnarchive: () => void; + unarchiveMutation: ReturnType; +}) { + const archiveLabel = isBot ? "Archive agent" : "Archive identity"; + const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity"; + + return ( +
+

+ Manage +

+ {isArchived ? ( + + ) : ( + + )} +
+ ); +} + // ── Field rows ─────────────────────────────────────────────────────────────── type ProfileField = { diff --git a/desktop/tests/e2e/identity-archive.spec.ts b/desktop/tests/e2e/identity-archive.spec.ts new file mode 100644 index 000000000..58498569d --- /dev/null +++ b/desktop/tests/e2e/identity-archive.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// NIP-IA archive button + "Archived" flair gate matrix. +// +// Guards the composition `canArchive = isSelf || isRelayAdminOrOwner || +// isOaOwnerOfViewee` in UserProfilePanel.tsx. Unit tests cover each input in +// isolation; this spec covers the OR composition where silent regressions +// (refactor turns OR into AND, role expansion bypasses a branch, etc.) would +// otherwise slip past code review. + +const ALICE_PUBKEY = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; + +async function openSelfProfile(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + // First seed message in #general is from the active identity. + const firstMessage = page.getByTestId("message-row").first(); + await firstMessage.locator("button", { hasText: "npub1mock..." }).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); +} + +async function openAliceProfile(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + // Second seed message in #general is from Alice. Her display name "alice" + // is registered in mockDisplayNames, so the author button text is "alice". + const aliceMessage = page.getByTestId("message-row").nth(1); + await aliceMessage.locator("button", { hasText: "alice" }).first().click(); + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toContainText(ALICE_PUBKEY.slice(0, 8)); +} + +test.describe("NIP-IA archive button gate", () => { + test("case 1 — self viewer + self target: Archive visible, no flair", async ({ + page, + }) => { + await installMockBridge(page, { relayRole: null, oaOwnerIsMe: false }); + await openSelfProfile(page); + await expect( + page.getByTestId("user-profile-archive-identity"), + ).toBeVisible(); + await expect(page.getByTestId("user-profile-archived-flair")).toHaveCount( + 0, + ); + }); + + test("case 2 — relay admin viewing Alice: Archive visible", async ({ + page, + }) => { + await installMockBridge(page, { + relayRole: "admin", + oaOwnerIsMe: false, + archivedIdentities: [], + }); + await openAliceProfile(page); + await expect( + page.getByTestId("user-profile-archive-identity"), + ).toBeVisible(); + }); + + test("case 3 — verified OA owner viewing Alice: Archive visible", async ({ + page, + }) => { + await installMockBridge(page, { + relayRole: null, + oaOwnerIsMe: true, + archivedIdentities: [], + }); + await openAliceProfile(page); + await expect( + page.getByTestId("user-profile-archive-identity"), + ).toBeVisible(); + }); + + test("case 4 — no authority viewing Alice: Archive hidden", async ({ + page, + }) => { + await installMockBridge(page, { + relayRole: null, + oaOwnerIsMe: false, + archivedIdentities: [], + }); + await openAliceProfile(page); + await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount( + 0, + ); + await expect( + page.getByTestId("user-profile-unarchive-identity"), + ).toHaveCount(0); + }); + + test("case 5 — Alice archived: flair + Unarchive button (under admin gate)", async ({ + page, + }) => { + await installMockBridge(page, { + relayRole: "admin", + oaOwnerIsMe: false, + archivedIdentities: [ALICE_PUBKEY], + }); + await openAliceProfile(page); + await expect(page.getByTestId("user-profile-archived-flair")).toBeVisible(); + await expect( + page.getByTestId("user-profile-unarchive-identity"), + ).toBeVisible(); + await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount( + 0, + ); + }); +}); From de7c05dea3a1cba63b44f81c0dcd0078017c4f43 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 12 Jun 2026 15:05:28 -0700 Subject: [PATCH 2/7] refactor(desktop): collapse archive prop drilling into useIdentityArchive hook Introduces useIdentityArchive(pubkey) in features/identity-archive/hooks.ts, composing the three gate queries (relay membership, OA owner, archived status) plus currentPubkey, owning both mutations and the toasted archive/unarchive callbacks. Returns { canArchive, isArchived, isPending, archive, unarchive }. Both call sites (ProfileSummaryView and ProfileManageSection) consume the hook directly, removing the six drilled props from UserProfilePanel and the now-dead hook calls, handlers, and imports. Gate composition is preserved verbatim: isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/identity-archive/hooks.ts | 100 ++++++++++++++++++ .../features/profile/ui/UserProfilePanel.tsx | 60 ----------- .../profile/ui/UserProfilePanelSections.tsx | 56 +++------- 3 files changed, 116 insertions(+), 100 deletions(-) diff --git a/desktop/src/features/identity-archive/hooks.ts b/desktop/src/features/identity-archive/hooks.ts index 401803d25..14ad81aea 100644 --- a/desktop/src/features/identity-archive/hooks.ts +++ b/desktop/src/features/identity-archive/hooks.ts @@ -1,6 +1,8 @@ import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { archiveIdentity, @@ -105,3 +107,101 @@ export function useUnarchiveIdentityMutation() { }, }); } + +/** Everything the profile panel needs to gate + drive NIP-IA archival. */ +export type IdentityArchiveActions = { + /** + * UX gate. `true` when ANY auth path will be accepted by the relay: self + * (acting on own pubkey), relay admin/owner, or verified NIP-OA owner of the + * viewee. The relay re-verifies on submit — this is purely a render guard. + */ + canArchive: boolean; + /** + * `true` iff the target is in the relay's latest `kind:13535` snapshot. + * `undefined` while the snapshot loads so callers can defer the flair / + * Manage section until authority + state are both known. + */ + isArchived: boolean | undefined; + /** Either mutation in flight — drives the disabled + "Archiving…" states. */ + isPending: boolean; + /** Submit a `kind:9035` archive request for `pubkey` (toasts on result). */ + archive: () => void; + /** Submit a `kind:9036` unarchive request for `pubkey` (toasts on result). */ + unarchive: () => void; +}; + +/** + * Self-contained NIP-IA archive controller for a single `pubkey`. Composes the + * three gate queries, owns both mutations, and exposes the archive/unarchive + * callbacks with toasts — collapsing what used to be six props drilled through + * the profile panel into one hook call. + * + * Safe to call from multiple components on the same `pubkey`: React Query + * dedupes the underlying subscriptions by queryKey, so the only cost is a + * second hook invocation, not a second network round-trip. + * + * Gate composition is verbatim from the old `UserProfilePanel`: + * `canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee`. + */ +export function useIdentityArchive(pubkey: string): IdentityArchiveActions { + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; + + const pubkeyLower = pubkey.toLowerCase(); + const isSelf = + currentPubkey !== undefined && + pubkeyLower === currentPubkey.toLowerCase(); + + const myMembershipQuery = useMyRelayMembershipQuery(); + // Skip the kind:0 lookup when viewing yourself — the OA gate is for + // archiving *other* identities you own. Also defer until our own identity + // resolves so we never fire the lookup against an unknown viewer. + const oaOwnerQuery = useOaOwnerQuery( + pubkey, + currentPubkey !== undefined && !isSelf, + ); + + const isArchived = useIsIdentityArchived(pubkey); + + const archiveMutation = useArchiveIdentityMutation(); + const unarchiveMutation = useUnarchiveIdentityMutation(); + + const myRole = myMembershipQuery.data?.role; + const isRelayAdminOrOwner = myRole === "owner" || myRole === "admin"; + const isOaOwnerOfViewee = oaOwnerQuery.data?.isMe === true; + const canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee; + + const archive = React.useCallback(() => { + archiveMutation.mutate( + { targetPubkey: pubkey }, + { + onSuccess: () => toast.success("Archived on this relay"), + onError: (error) => + toast.error( + `Archive failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }, + ); + }, [archiveMutation, pubkey]); + + const unarchive = React.useCallback(() => { + unarchiveMutation.mutate( + { targetPubkey: pubkey }, + { + onSuccess: () => toast.success("Unarchived on this relay"), + onError: (error) => + toast.error( + `Unarchive failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }, + ); + }, [pubkey, unarchiveMutation]); + + return { + canArchive, + isArchived, + isPending: archiveMutation.isPending || unarchiveMutation.isPending, + archive, + unarchive, + }; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 6b1484e76..f71a5f575 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { ArrowLeft, X } from "lucide-react"; -import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { @@ -16,12 +15,6 @@ import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsSto import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; import { useChannelsQuery } from "@/features/channels/hooks"; -import { - useArchiveIdentityMutation, - useIsIdentityArchived, - useOaOwnerQuery, - useUnarchiveIdentityMutation, -} from "@/features/identity-archive/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { useContactListQuery, @@ -35,7 +28,6 @@ import { MemoryFocusedView, ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; -import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; @@ -170,17 +162,6 @@ export function UserProfilePanel({ const contactListQuery = useContactListQuery(currentPubkey); const followMutation = useFollowMutation(currentPubkey); const unfollowMutation = useUnfollowMutation(currentPubkey); - const myMembershipQuery = useMyRelayMembershipQuery(); - // Skip the kind:0 lookup when viewing yourself — the OA gate is for - // archiving *other* identities you own. - const oaOwnerQuery = useOaOwnerQuery( - pubkey, - currentPubkey !== undefined && - pubkey.toLowerCase() !== currentPubkey.toLowerCase(), - ); - const isArchived = useIsIdentityArchived(pubkey); - const archiveMutation = useArchiveIdentityMutation(); - const unarchiveMutation = useUnarchiveIdentityMutation(); const { onOpenAgentSession } = useAgentSession(); const { goChannel } = useAppNavigation(); @@ -216,15 +197,6 @@ export function UserProfilePanel({ const isSelf = currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); const canViewActivity = isOwner === true && Boolean(onOpenAgentSession); - // NIP-IA gate composition. The button shows when ANY of: self path (acting - // on own pubkey), admin path (current user is owner/admin in relay_members), - // or OA-owner path (current user is the verified NIP-OA owner of the viewee - // per its live kind:0). The relay re-verifies authority on submit; this gate - // is purely a UX guard. - const myRole = myMembershipQuery.data?.role; - const isRelayAdminOrOwner = myRole === "owner" || myRole === "admin"; - const isOaOwnerOfViewee = oaOwnerQuery.data?.isMe === true; - const canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee; const isFollowing = !isSelf && (contactListQuery.data?.contacts.some( @@ -278,32 +250,6 @@ export function UserProfilePanel({ [goChannel], ); - const handleArchive = React.useCallback(() => { - archiveMutation.mutate( - { targetPubkey: pubkey }, - { - onSuccess: () => toast.success("Archived on this relay"), - onError: (error) => - toast.error( - `Archive failed: ${error instanceof Error ? error.message : String(error)}`, - ), - }, - ); - }, [archiveMutation, pubkey]); - - const handleUnarchive = React.useCallback(() => { - unarchiveMutation.mutate( - { targetPubkey: pubkey }, - { - onSuccess: () => toast.success("Unarchived on this relay"), - onError: (error) => - toast.error( - `Unarchive failed: ${error instanceof Error ? error.message : String(error)}`, - ), - }, - ); - }, [pubkey, unarchiveMutation]); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); const ownerHandle = React.useMemo(() => { if (currentPubkey === undefined) { @@ -370,8 +316,6 @@ export function UserProfilePanel({ > {view === "summary" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index d629c067d..d5eea3d8f 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -28,10 +28,7 @@ import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import type { - useArchiveIdentityMutation, - useUnarchiveIdentityMutation, -} from "@/features/identity-archive/hooks"; +import { useIdentityArchive } from "@/features/identity-archive/hooks"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { @@ -68,8 +65,6 @@ async function copyToClipboard(value: string, label?: string) { // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { - archiveMutation: ReturnType; - canArchive: boolean; canEditAgent: boolean; canViewActivity: boolean; channelCount: number; @@ -77,12 +72,9 @@ export type ProfileSummaryViewProps = { channelsLoading: boolean; displayName: string; followMutation: ReturnType; - handleArchive: () => void; handleEditAgent: () => void; handleMessage: () => void; handleOpenActivity: () => void; - handleUnarchive: () => void; - isArchived: boolean | undefined; isBot: boolean; isFollowing: boolean; isOwner: boolean | undefined; @@ -100,14 +92,11 @@ export type ProfileSummaryViewProps = { profile: ReturnType["data"]; pubkey: string; relayAgent: RelayAgent | undefined; - unarchiveMutation: ReturnType; unfollowMutation: ReturnType; userStatus: { text: string; emoji: string } | null | undefined; }; export function ProfileSummaryView({ - archiveMutation, - canArchive, canEditAgent, canViewActivity, channelCount, @@ -115,12 +104,9 @@ export function ProfileSummaryView({ channelsLoading, displayName, followMutation, - handleArchive, handleEditAgent, handleMessage, handleOpenActivity, - handleUnarchive, - isArchived, isBot, isFollowing, isOwner, @@ -138,13 +124,15 @@ export function ProfileSummaryView({ profile, pubkey, relayAgent, - unarchiveMutation, unfollowMutation, userStatus, }: ProfileSummaryViewProps) { const { goChannel } = useAppNavigation(); const activeTurns = useActiveAgentTurns(isBot ? pubkey : null); + const { canArchive, isArchived } = useIdentityArchive(pubkey); + + const metadataFields = [ ...buildPublicFields({ pubkey, @@ -208,14 +196,7 @@ export function ProfileSummaryView({ ) : null} {showManageSection ? ( - + ) : null} @@ -581,20 +562,15 @@ function ProfileQuickAction({ // `canArchive` gate upstream is a UX guard so the button only renders when at // least one path will be accepted. function ProfileManageSection({ - archiveMutation, - isArchived, isBot, - onArchive, - onUnarchive, - unarchiveMutation, + pubkey, }: { - archiveMutation: ReturnType; - isArchived: boolean; isBot: boolean; - onArchive: () => void; - onUnarchive: () => void; - unarchiveMutation: ReturnType; + pubkey: string; }) { + const { isArchived, isPending, archive, unarchive } = + useIdentityArchive(pubkey); + const archiveLabel = isBot ? "Archive agent" : "Archive identity"; const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity"; @@ -607,25 +583,25 @@ function ProfileManageSection({ ) : ( )} From b5406e68457f0599587817fa0cef1983da3d7e11 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 12 Jun 2026 15:06:05 -0700 Subject: [PATCH 3/7] refactor(desktop): move Manage section below profile metadata fields Relocates the Archive/Unarchive Manage section so it renders after the metadata field group instead of above the memories/channels ingress, placing identity management at the bottom of the profile summary. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/profile/ui/UserProfilePanelSections.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index d5eea3d8f..7b0bbe3cd 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -195,11 +195,6 @@ export function ProfileSummaryView({ ) : null} - {showManageSection ? ( - - ) : null} - - {showMemoriesIngress || showChannelsIngress || canViewActivity ? (
{showMemoriesIngress ? ( @@ -246,6 +241,10 @@ export function ProfileSummaryView({ {metadataFields.length > 0 ? ( ) : null} + + {showManageSection ? ( + + ) : null} ); } From 931dbc711a7ecae5e5d2f61c16d19ba60e09c6be Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sat, 13 Jun 2026 20:06:26 -0700 Subject: [PATCH 4/7] chore(desktop): biome format pass on archive UI files Collapses the isSelf check to a single line and removes a stray blank line introduced during the rebase conflict resolution. Whitespace only, no logic touched. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/features/identity-archive/hooks.ts | 3 +-- desktop/src/features/profile/ui/UserProfilePanelSections.tsx | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/desktop/src/features/identity-archive/hooks.ts b/desktop/src/features/identity-archive/hooks.ts index 14ad81aea..cec5880a4 100644 --- a/desktop/src/features/identity-archive/hooks.ts +++ b/desktop/src/features/identity-archive/hooks.ts @@ -149,8 +149,7 @@ export function useIdentityArchive(pubkey: string): IdentityArchiveActions { const pubkeyLower = pubkey.toLowerCase(); const isSelf = - currentPubkey !== undefined && - pubkeyLower === currentPubkey.toLowerCase(); + currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); const myMembershipQuery = useMyRelayMembershipQuery(); // Skip the kind:0 lookup when viewing yourself — the OA gate is for diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 7b0bbe3cd..328decb09 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -132,7 +132,6 @@ export function ProfileSummaryView({ const { canArchive, isArchived } = useIdentityArchive(pubkey); - const metadataFields = [ ...buildPublicFields({ pubkey, From a871c4da18c211fa78a1c6de417a2e2806e57695 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sat, 13 Jun 2026 20:23:20 -0700 Subject: [PATCH 5/7] refactor(desktop): split UserProfilePanelSections into sub-components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The archive UI work tipped UserProfilePanelSections.tsx past the hard 1000-line cap (1016 > 1000). Per the file-size guard's own rule, split the file rather than adding an override. Extracts the natural section seams into self-documenting sibling files: - ProfileFields.tsx — field type, public/owner builders, group + row, copy helper - ProfileHero.tsx — hero header + collapsible description - ProfileActions.tsx — primary actions, quick action, working badge, ingress row - ProfileManageSection.tsx — NIP-IA archive/unarchive section - profileSummaryTypes.ts — shared profile/status types (avoids circular import) UserProfilePanelSections.tsx now holds only ProfileSummaryView composition and the focused views. Pure refactor: same render output, same gate, same section order, testids and the identity-archive 5-case matrix untouched. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/profile/ui/ProfileActions.tsx | 190 +++++ .../src/features/profile/ui/ProfileFields.tsx | 326 ++++++++ .../src/features/profile/ui/ProfileHero.tsx | 176 ++++ .../profile/ui/ProfileManageSection.tsx | 55 ++ .../profile/ui/UserProfilePanelSections.tsx | 762 +----------------- .../profile/ui/profileSummaryTypes.ts | 13 + 6 files changed, 779 insertions(+), 743 deletions(-) create mode 100644 desktop/src/features/profile/ui/ProfileActions.tsx create mode 100644 desktop/src/features/profile/ui/ProfileFields.tsx create mode 100644 desktop/src/features/profile/ui/ProfileHero.tsx create mode 100644 desktop/src/features/profile/ui/ProfileManageSection.tsx create mode 100644 desktop/src/features/profile/ui/profileSummaryTypes.ts diff --git a/desktop/src/features/profile/ui/ProfileActions.tsx b/desktop/src/features/profile/ui/ProfileActions.tsx new file mode 100644 index 000000000..32f610efa --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileActions.tsx @@ -0,0 +1,190 @@ +import type { LucideIcon } from "lucide-react"; +import { + ChevronRight, + MessageSquare, + Pencil, + UserMinus, + UserPlus, +} from "lucide-react"; +import { toast } from "sonner"; + +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import type { + useFollowMutation, + useUnfollowMutation, +} from "@/features/profile/hooks"; +import { cn } from "@/shared/lib/cn"; +import { useNow } from "@/shared/lib/useNow"; +import { Badge } from "@/shared/ui/badge"; + +export function ProfileWorkingBadge({ + channelId, + name, + observedAt, + onNavigate, +}: { + channelId: string; + name: string; + observedAt: number; + onNavigate: (channelId: string) => void; +}) { + const now = useNow(1000); + + return ( + onNavigate(channelId)} + > + Working in #{name} · {formatElapsed(now - observedAt)} + + ); +} + +export function ProfilePrimaryActions({ + canEditAgent, + followMutation, + isFollowing, + onEditAgent, + onMessage, + pubkey, + unfollowMutation, +}: { + canEditAgent: boolean; + followMutation: ReturnType; + isFollowing: boolean; + onEditAgent: () => void; + onMessage?: () => void; + pubkey: string; + unfollowMutation: ReturnType; +}) { + return ( +
+ {isFollowing ? ( + + unfollowMutation.mutate(pubkey, { + onError: (error) => + toast.error( + `Unfollow failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }) + } + /> + ) : ( + + followMutation.mutate(pubkey, { + onError: (error) => + toast.error( + `Follow failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }) + } + /> + )} + {onMessage ? ( + + ) : null} + {canEditAgent ? ( + + ) : null} +
+ ); +} + +function ProfileQuickAction({ + active, + disabled, + icon: Icon, + label, + onClick, + testId, +}: { + active?: boolean; + disabled?: boolean; + icon: LucideIcon; + label: string; + onClick: () => void; + testId?: string; +}) { + return ( + + ); +} + +export function ProfileIngressRow({ + icon: Icon, + label, + onClick, + testId, + trailing, +}: { + icon: LucideIcon; + label: string; + onClick: () => void; + testId: string; + trailing?: string; +}) { + return ( + + ); +} diff --git a/desktop/src/features/profile/ui/ProfileFields.tsx b/desktop/src/features/profile/ui/ProfileFields.tsx new file mode 100644 index 000000000..a11fcf8e6 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileFields.tsx @@ -0,0 +1,326 @@ +import type { LucideIcon } from "lucide-react"; +import { + Activity, + Copy, + Cpu, + Fingerprint, + MessageSquare, + Server, + Terminal, + UserRound, +} from "lucide-react"; +import type * as React from "react"; +import { toast } from "sonner"; + +import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity"; +import type { ProfileSummaryData } from "@/features/profile/ui/profileSummaryTypes"; +import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; + +const RUNTIME_LABELS: Record = { + goose: "Goose", + "claude-code": "Claude Code", + "codex-acp": "Codex", + aider: "Aider", +}; + +export function runtimeLabel(command: string): string { + return RUNTIME_LABELS[command] ?? command; +} + +export async function copyToClipboard(value: string, label?: string) { + await navigator.clipboard.writeText(value); + toast.success(label ? `Copied ${label}` : "Copied to clipboard"); +} + +export type ProfileField = { + copyValue?: string; + /** + * Plain-text representation. Always required so non-visual surfaces (e.g. tooltips, + * copy-to-clipboard) keep working. When `displayNode` is set, the row renders that + * instead of the text — but the text still drives the title/tooltip. + */ + displayValue: string; + /** + * Optional rich rendering for the value cell (e.g. a status badge). When present, + * replaces the plain text node in the row. + */ + displayNode?: React.ReactNode; + icon: LucideIcon; + label: string; + testId?: string; +}; + +export function buildPublicFields({ + isBot, + profile, + pubkey, + relayAgent, +}: { + isBot: boolean; + profile: ProfileSummaryData; + pubkey: string; + relayAgent: RelayAgent | undefined; +}): ProfileField[] { + const fields: ProfileField[] = [ + { + copyValue: pubkey, + displayValue: truncatePubkeyShort(pubkey), + icon: Fingerprint, + label: "Public key", + testId: "user-profile-copy-pubkey", + }, + ]; + + if (profile?.nip05Handle) { + fields.push({ + copyValue: profile.nip05Handle, + displayValue: profile.nip05Handle, + icon: UserRound, + label: "NIP-05", + testId: "user-profile-nip05", + }); + } + + if (isBot && relayAgent?.agentType) { + fields.push({ + copyValue: relayAgent.agentType, + displayValue: runtimeLabel(relayAgent.agentType), + icon: Cpu, + label: "Agent type", + testId: "user-profile-agent-type", + }); + } + + if (relayAgent?.capabilities.length) { + fields.push({ + copyValue: relayAgent.capabilities.join(", "), + displayValue: relayAgent.capabilities.join(", "), + icon: Server, + label: "Capabilities", + testId: "user-profile-capabilities", + }); + } + + return fields; +} + +export function buildOwnerFields({ + managedAgent, + ownerDisplayName, + ownerHandle, + presenceLoaded, + presenceStatus, + relayAgent, +}: { + managedAgent: ManagedAgent | undefined; + ownerDisplayName: string | null; + ownerHandle: string | null; + presenceLoaded: boolean; + presenceStatus: "online" | "away" | "offline" | undefined; + relayAgent: RelayAgent | undefined; +}): ProfileField[] { + const fields: ProfileField[] = []; + + if (ownerDisplayName) { + fields.push({ + copyValue: ownerHandle ?? undefined, + displayValue: ownerDisplayName, + icon: UserRound, + label: "Owned by", + testId: "user-profile-owned-by", + }); + } + + if (managedAgent?.agentCommand) { + fields.push({ + copyValue: managedAgent.agentCommand, + displayValue: runtimeLabel(managedAgent.agentCommand), + icon: Terminal, + label: "Runtime", + testId: "user-profile-runtime", + }); + } else if (relayAgent?.agentType) { + fields.push({ + copyValue: relayAgent.agentType, + displayValue: runtimeLabel(relayAgent.agentType), + icon: Terminal, + label: "Runtime", + testId: "user-profile-runtime", + }); + } + + if (managedAgent) { + fields.push({ + displayValue: managedAgent.status + .replace(/_/g, " ") + .replace(/\b\w/g, (char: string) => char.toUpperCase()), + displayNode: ( + + ), + icon: Activity, + label: "Status", + testId: "user-profile-agent-status", + }); + } + + if (managedAgent?.model) { + fields.push({ + copyValue: managedAgent.model, + displayValue: managedAgent.model, + icon: Cpu, + label: "Model", + testId: "user-profile-model", + }); + } + + if (managedAgent?.acpCommand) { + fields.push({ + copyValue: managedAgent.acpCommand, + displayValue: managedAgent.acpCommand, + icon: Terminal, + label: "ACP command", + testId: "user-profile-acp", + }); + } + + if (managedAgent?.mcpCommand) { + fields.push({ + copyValue: managedAgent.mcpCommand, + displayValue: managedAgent.mcpCommand, + icon: Terminal, + label: "MCP command", + testId: "user-profile-mcp", + }); + } + + if (managedAgent?.backend.type === "provider") { + const backendLabel = managedAgent.backend.id; + fields.push({ + copyValue: backendLabel, + displayValue: backendLabel, + icon: Server, + label: "Backend", + testId: "user-profile-backend", + }); + } + + if (managedAgent) { + fields.push({ + displayValue: managedAgent.startOnAppLaunch ? "Yes" : "No", + icon: Server, + label: "Start on launch", + testId: "user-profile-start-on-launch", + }); + fields.push({ + displayValue: managedAgent.respondTo.replace(/-/g, " "), + icon: MessageSquare, + label: "Respond to", + testId: "user-profile-respond-to", + }); + } + + if (managedAgent?.lastError) { + fields.push({ + copyValue: managedAgent.lastError, + displayValue: managedAgent.lastError, + icon: Activity, + label: "Last error", + testId: "user-profile-last-error", + }); + } + + return fields; +} + +export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { + const publicKeyLabel = "Public key"; + const ownedByLabel = "Owned by"; + const statusLabel = "Status"; + const orderedFields = [ + ...fields.filter((field) => field.label === publicKeyLabel), + ...fields.filter((field) => field.label === ownedByLabel), + ...fields.filter( + (field) => + field.label !== publicKeyLabel && + field.label !== ownedByLabel && + field.copyValue, + ), + ...fields.filter((field) => field.label === statusLabel), + ...fields.filter((field) => { + if ( + field.label === publicKeyLabel || + field.label === ownedByLabel || + field.label === statusLabel + ) { + return false; + } + return !field.copyValue; + }), + ]; + + return ( +
+
+ {orderedFields.map((field) => ( + + ))} +
+
+ ); +} + +function ProfileFieldRow({ field }: { field: ProfileField }) { + const Icon = field.icon; + const isCopyable = Boolean(field.copyValue); + + const content = ( + <> + + + + + + {field.label} + + + {field.displayNode ?? field.displayValue} + + + {isCopyable ? ( + + ) : null} + + ); + + if (isCopyable && field.copyValue) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); +} diff --git a/desktop/src/features/profile/ui/ProfileHero.tsx b/desktop/src/features/profile/ui/ProfileHero.tsx new file mode 100644 index 000000000..4daba8c54 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileHero.tsx @@ -0,0 +1,176 @@ +import { Archive, ChevronDown, ChevronUp } from "lucide-react"; +import * as React from "react"; + +import { getPresenceLabel } from "@/features/presence/lib/presence"; +import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { + ProfileSummaryData, + ProfileSummaryUserStatus, +} from "@/features/profile/ui/profileSummaryTypes"; +import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; +import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; +import { cn } from "@/shared/lib/cn"; + +export function ProfileHero({ + displayName, + isArchived, + isBot, + presenceStatus, + profile, + userStatus, +}: { + displayName: string; + isArchived: boolean | undefined; + isBot: boolean; + presenceStatus: "online" | "away" | "offline" | undefined; + profile: ProfileSummaryData; + userStatus: ProfileSummaryUserStatus; +}) { + return ( +
+
+ + {presenceStatus ? ( + + + + ) : null} +
+ +
+
+

+ {displayName} +

+ {isBot ? ( + + ) : null} +
+ + {isArchived === true ? ( + + + Archived on this relay + + ) : null} + + {profile?.about?.trim() ? ( + + ) : null} + + {profile?.nip05Handle ? ( +

{profile.nip05Handle}

+ ) : null} + + {userStatus ? ( +

+ {userStatus.emoji ? ( + + ) : null} + {userStatus.text} +

+ ) : null} +
+
+ ); +} + +function ProfileHeroDescription({ about }: { about: string }) { + const [expanded, setExpanded] = React.useState(false); + const [isTruncated, setIsTruncated] = React.useState(false); + const textRef = React.useRef(null); + + const measureTruncation = React.useCallback(() => { + const element = textRef.current; + if (!element || expanded) { + return; + } + setIsTruncated(element.scrollHeight > element.clientHeight + 1); + }, [expanded]); + + React.useLayoutEffect(() => { + measureTruncation(); + }, [measureTruncation]); + + React.useEffect(() => { + const element = textRef.current; + if (!element) { + return; + } + + const observer = new ResizeObserver(() => { + measureTruncation(); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [measureTruncation]); + + const toggleClassName = + "inline-flex items-center gap-0.5 text-xs font-medium text-muted-foreground opacity-60 transition-opacity hover:text-foreground hover:opacity-100"; + + return ( +
+
+

+ {about} +

+
+ {!expanded && isTruncated ? ( + + ) : null} + {expanded ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/ui/ProfileManageSection.tsx b/desktop/src/features/profile/ui/ProfileManageSection.tsx new file mode 100644 index 000000000..c4d474c2d --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileManageSection.tsx @@ -0,0 +1,55 @@ +import { Archive, ArchiveRestore } from "lucide-react"; + +import { useIdentityArchive } from "@/features/identity-archive/hooks"; +import { Button } from "@/shared/ui/button"; + +// NIP-IA archive / unarchive lives in its own section under the quick-actions +// row. The relay verifies authority (self / admin / OA-owner) on submit; the +// `canArchive` gate upstream is a UX guard so the button only renders when at +// least one path will be accepted. +export function ProfileManageSection({ + isBot, + pubkey, +}: { + isBot: boolean; + pubkey: string; +}) { + const { isArchived, isPending, archive, unarchive } = + useIdentityArchive(pubkey); + + const archiveLabel = isBot ? "Archive agent" : "Archive identity"; + const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity"; + + return ( +
+

+ Manage +

+ {isArchived ? ( + + ) : ( + + )} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 328decb09..45740fc8c 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -1,66 +1,30 @@ -import * as React from "react"; -import type { LucideIcon } from "lucide-react"; -import { - Activity, - Archive, - ArchiveRestore, - ArrowUpRight, - Brain, - ChevronDown, - ChevronRight, - ChevronUp, - Copy, - Cpu, - Fingerprint, - Hash, - MessageSquare, - Pencil, - Server, - Terminal, - UserMinus, - UserPlus, - UserRound, -} from "lucide-react"; -import { toast } from "sonner"; +import { Activity, ArrowUpRight, Brain, Hash } from "lucide-react"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; -import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; -import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useIdentityArchive } from "@/features/identity-archive/hooks"; -import { getPresenceLabel } from "@/features/presence/lib/presence"; -import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { useFollowMutation, useUnfollowMutation, - useUserProfileQuery, } from "@/features/profile/hooks"; -import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; -import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; +import { + ProfileIngressRow, + ProfilePrimaryActions, + ProfileWorkingBadge, +} from "@/features/profile/ui/ProfileActions"; +import { + buildOwnerFields, + buildPublicFields, + ProfileFieldGroup, +} from "@/features/profile/ui/ProfileFields"; +import { ProfileHero } from "@/features/profile/ui/ProfileHero"; +import { ProfileManageSection } from "@/features/profile/ui/ProfileManageSection"; +import type { + ProfileSummaryData, + ProfileSummaryUserStatus, +} from "@/features/profile/ui/profileSummaryTypes"; import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { useNow } from "@/shared/lib/useNow"; -import { Badge } from "@/shared/ui/badge"; -import { Button } from "@/shared/ui/button"; - -const RUNTIME_LABELS: Record = { - goose: "Goose", - "claude-code": "Claude Code", - "codex-acp": "Codex", - aider: "Aider", -}; - -function runtimeLabel(command: string): string { - return RUNTIME_LABELS[command] ?? command; -} - -async function copyToClipboard(value: string, label?: string) { - await navigator.clipboard.writeText(value); - toast.success(label ? `Copied ${label}` : "Copied to clipboard"); -} // ── Summary view ───────────────────────────────────────────────────────────── @@ -89,11 +53,11 @@ export type ProfileSummaryViewProps = { onOpenDm?: (pubkeys: string[]) => void; presenceLoaded: boolean; presenceStatus: "online" | "away" | "offline" | undefined; - profile: ReturnType["data"]; + profile: ProfileSummaryData; pubkey: string; relayAgent: RelayAgent | undefined; unfollowMutation: ReturnType; - userStatus: { text: string; emoji: string } | null | undefined; + userStatus: ProfileSummaryUserStatus; }; export function ProfileSummaryView({ @@ -248,694 +212,6 @@ export function ProfileSummaryView({ ); } -function ProfileWorkingBadge({ - channelId, - name, - observedAt, - onNavigate, -}: { - channelId: string; - name: string; - observedAt: number; - onNavigate: (channelId: string) => void; -}) { - const now = useNow(1000); - - return ( - onNavigate(channelId)} - > - Working in #{name} · {formatElapsed(now - observedAt)} - - ); -} - -// ── Hero & metadata ────────────────────────────────────────────────────────── - -function ProfileHero({ - displayName, - isArchived, - isBot, - presenceStatus, - profile, - userStatus, -}: { - displayName: string; - isArchived: boolean | undefined; - isBot: boolean; - presenceStatus: "online" | "away" | "offline" | undefined; - profile: ProfileSummaryViewProps["profile"]; - userStatus: ProfileSummaryViewProps["userStatus"]; -}) { - return ( -
-
- - {presenceStatus ? ( - - - - ) : null} -
- -
-
-

- {displayName} -

- {isBot ? ( - - ) : null} -
- - {isArchived === true ? ( - - - Archived on this relay - - ) : null} - - {profile?.about?.trim() ? ( - - ) : null} - - {profile?.nip05Handle ? ( -

{profile.nip05Handle}

- ) : null} - - {userStatus ? ( -

- {userStatus.emoji ? ( - - ) : null} - {userStatus.text} -

- ) : null} -
-
- ); -} - -function ProfileHeroDescription({ about }: { about: string }) { - const [expanded, setExpanded] = React.useState(false); - const [isTruncated, setIsTruncated] = React.useState(false); - const textRef = React.useRef(null); - - const measureTruncation = React.useCallback(() => { - const element = textRef.current; - if (!element || expanded) { - return; - } - setIsTruncated(element.scrollHeight > element.clientHeight + 1); - }, [expanded]); - - React.useLayoutEffect(() => { - measureTruncation(); - }, [measureTruncation]); - - React.useEffect(() => { - const element = textRef.current; - if (!element) { - return; - } - - const observer = new ResizeObserver(() => { - measureTruncation(); - }); - observer.observe(element); - return () => observer.disconnect(); - }, [measureTruncation]); - - const toggleClassName = - "inline-flex items-center gap-0.5 text-xs font-medium text-muted-foreground opacity-60 transition-opacity hover:text-foreground hover:opacity-100"; - - return ( -
-
-

- {about} -

-
- {!expanded && isTruncated ? ( - - ) : null} - {expanded ? ( - - ) : null} -
- ); -} - -// ── Primary actions ────────────────────────────────────────────────────────── - -function ProfilePrimaryActions({ - canEditAgent, - followMutation, - isFollowing, - onEditAgent, - onMessage, - pubkey, - unfollowMutation, -}: { - canEditAgent: boolean; - followMutation: ReturnType; - isFollowing: boolean; - onEditAgent: () => void; - onMessage?: () => void; - pubkey: string; - unfollowMutation: ReturnType; -}) { - return ( -
- {isFollowing ? ( - - unfollowMutation.mutate(pubkey, { - onError: (error) => - toast.error( - `Unfollow failed: ${error instanceof Error ? error.message : String(error)}`, - ), - }) - } - /> - ) : ( - - followMutation.mutate(pubkey, { - onError: (error) => - toast.error( - `Follow failed: ${error instanceof Error ? error.message : String(error)}`, - ), - }) - } - /> - )} - {onMessage ? ( - - ) : null} - {canEditAgent ? ( - - ) : null} -
- ); -} - -function ProfileQuickAction({ - active, - disabled, - icon: Icon, - label, - onClick, - testId, -}: { - active?: boolean; - disabled?: boolean; - icon: LucideIcon; - label: string; - onClick: () => void; - testId?: string; -}) { - return ( - - ); -} - -// ── Manage section (archive / unarchive) ───────────────────────────────────── - -// NIP-IA archive / unarchive lives in its own section under the quick-actions -// row. The relay verifies authority (self / admin / OA-owner) on submit; the -// `canArchive` gate upstream is a UX guard so the button only renders when at -// least one path will be accepted. -function ProfileManageSection({ - isBot, - pubkey, -}: { - isBot: boolean; - pubkey: string; -}) { - const { isArchived, isPending, archive, unarchive } = - useIdentityArchive(pubkey); - - const archiveLabel = isBot ? "Archive agent" : "Archive identity"; - const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity"; - - return ( -
-

- Manage -

- {isArchived ? ( - - ) : ( - - )} -
- ); -} - -// ── Field rows ─────────────────────────────────────────────────────────────── - -type ProfileField = { - copyValue?: string; - /** - * Plain-text representation. Always required so non-visual surfaces (e.g. tooltips, - * copy-to-clipboard) keep working. When `displayNode` is set, the row renders that - * instead of the text — but the text still drives the title/tooltip. - */ - displayValue: string; - /** - * Optional rich rendering for the value cell (e.g. a status badge). When present, - * replaces the plain text node in the row. - */ - displayNode?: React.ReactNode; - icon: LucideIcon; - label: string; - testId?: string; -}; - -function buildPublicFields({ - isBot, - profile, - pubkey, - relayAgent, -}: { - isBot: boolean; - profile: ProfileSummaryViewProps["profile"]; - pubkey: string; - relayAgent: RelayAgent | undefined; -}): ProfileField[] { - const fields: ProfileField[] = [ - { - copyValue: pubkey, - displayValue: truncatePubkeyShort(pubkey), - icon: Fingerprint, - label: "Public key", - testId: "user-profile-copy-pubkey", - }, - ]; - - if (profile?.nip05Handle) { - fields.push({ - copyValue: profile.nip05Handle, - displayValue: profile.nip05Handle, - icon: UserRound, - label: "NIP-05", - testId: "user-profile-nip05", - }); - } - - if (isBot && relayAgent?.agentType) { - fields.push({ - copyValue: relayAgent.agentType, - displayValue: runtimeLabel(relayAgent.agentType), - icon: Cpu, - label: "Agent type", - testId: "user-profile-agent-type", - }); - } - - if (relayAgent?.capabilities.length) { - fields.push({ - copyValue: relayAgent.capabilities.join(", "), - displayValue: relayAgent.capabilities.join(", "), - icon: Server, - label: "Capabilities", - testId: "user-profile-capabilities", - }); - } - - return fields; -} - -function buildOwnerFields({ - managedAgent, - ownerDisplayName, - ownerHandle, - presenceLoaded, - presenceStatus, - relayAgent, -}: { - managedAgent: ManagedAgent | undefined; - ownerDisplayName: string | null; - ownerHandle: string | null; - presenceLoaded: boolean; - presenceStatus: "online" | "away" | "offline" | undefined; - relayAgent: RelayAgent | undefined; -}): ProfileField[] { - const fields: ProfileField[] = []; - - if (ownerDisplayName) { - fields.push({ - copyValue: ownerHandle ?? undefined, - displayValue: ownerDisplayName, - icon: UserRound, - label: "Owned by", - testId: "user-profile-owned-by", - }); - } - - if (managedAgent?.agentCommand) { - fields.push({ - copyValue: managedAgent.agentCommand, - displayValue: runtimeLabel(managedAgent.agentCommand), - icon: Terminal, - label: "Runtime", - testId: "user-profile-runtime", - }); - } else if (relayAgent?.agentType) { - fields.push({ - copyValue: relayAgent.agentType, - displayValue: runtimeLabel(relayAgent.agentType), - icon: Terminal, - label: "Runtime", - testId: "user-profile-runtime", - }); - } - - if (managedAgent) { - fields.push({ - displayValue: managedAgent.status - .replace(/_/g, " ") - .replace(/\b\w/g, (char: string) => char.toUpperCase()), - displayNode: ( - - ), - icon: Activity, - label: "Status", - testId: "user-profile-agent-status", - }); - } - - if (managedAgent?.model) { - fields.push({ - copyValue: managedAgent.model, - displayValue: managedAgent.model, - icon: Cpu, - label: "Model", - testId: "user-profile-model", - }); - } - - if (managedAgent?.acpCommand) { - fields.push({ - copyValue: managedAgent.acpCommand, - displayValue: managedAgent.acpCommand, - icon: Terminal, - label: "ACP command", - testId: "user-profile-acp", - }); - } - - if (managedAgent?.mcpCommand) { - fields.push({ - copyValue: managedAgent.mcpCommand, - displayValue: managedAgent.mcpCommand, - icon: Terminal, - label: "MCP command", - testId: "user-profile-mcp", - }); - } - - if (managedAgent?.backend.type === "provider") { - const backendLabel = managedAgent.backend.id; - fields.push({ - copyValue: backendLabel, - displayValue: backendLabel, - icon: Server, - label: "Backend", - testId: "user-profile-backend", - }); - } - - if (managedAgent) { - fields.push({ - displayValue: managedAgent.startOnAppLaunch ? "Yes" : "No", - icon: Server, - label: "Start on launch", - testId: "user-profile-start-on-launch", - }); - fields.push({ - displayValue: managedAgent.respondTo.replace(/-/g, " "), - icon: MessageSquare, - label: "Respond to", - testId: "user-profile-respond-to", - }); - } - - if (managedAgent?.lastError) { - fields.push({ - copyValue: managedAgent.lastError, - displayValue: managedAgent.lastError, - icon: Activity, - label: "Last error", - testId: "user-profile-last-error", - }); - } - - return fields; -} - -function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { - const publicKeyLabel = "Public key"; - const ownedByLabel = "Owned by"; - const statusLabel = "Status"; - const orderedFields = [ - ...fields.filter((field) => field.label === publicKeyLabel), - ...fields.filter((field) => field.label === ownedByLabel), - ...fields.filter( - (field) => - field.label !== publicKeyLabel && - field.label !== ownedByLabel && - field.copyValue, - ), - ...fields.filter((field) => field.label === statusLabel), - ...fields.filter((field) => { - if ( - field.label === publicKeyLabel || - field.label === ownedByLabel || - field.label === statusLabel - ) { - return false; - } - return !field.copyValue; - }), - ]; - - return ( -
-
- {orderedFields.map((field) => ( - - ))} -
-
- ); -} - -function ProfileFieldRow({ field }: { field: ProfileField }) { - const Icon = field.icon; - const isCopyable = Boolean(field.copyValue); - - const content = ( - <> - - - - - - {field.label} - - - {field.displayNode ?? field.displayValue} - - - {isCopyable ? ( - - ) : null} - - ); - - if (isCopyable && field.copyValue) { - return ( - - ); - } - - return ( -
- {content} -
- ); -} - -// ── Ingress rows ───────────────────────────────────────────────────────────── - -function ProfileIngressRow({ - icon: Icon, - label, - onClick, - testId, - trailing, -}: { - icon: LucideIcon; - label: string; - onClick: () => void; - testId: string; - trailing?: string; -}) { - return ( - - ); -} - // ── Focused views ──────────────────────────────────────────────────────────── export function MemoryFocusedView({ diff --git a/desktop/src/features/profile/ui/profileSummaryTypes.ts b/desktop/src/features/profile/ui/profileSummaryTypes.ts new file mode 100644 index 000000000..21ff15998 --- /dev/null +++ b/desktop/src/features/profile/ui/profileSummaryTypes.ts @@ -0,0 +1,13 @@ +import type { useUserProfileQuery } from "@/features/profile/hooks"; + +/** Resolved profile document for the viewed identity (kind:0 metadata, etc.). */ +export type ProfileSummaryData = ReturnType["data"]; + +/** Free-form user status line (emoji + text), or absent. */ +export type ProfileSummaryUserStatus = + | { + text: string; + emoji: string; + } + | null + | undefined; From 31acc52b9185f93840f39c0554b470b7a3a0a000 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sun, 14 Jun 2026 13:14:34 -0700 Subject: [PATCH 6/7] feat(desktop): archive confirmation dialog with bot-aware guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the archive action behind a calm AlertDialog (ArchiveConfirmDialog) that honestly explains what archiving does before it fires. Archiving is relay-scoped (NIP-IA kind:13535 snapshot, not account deletion, not global) and reversible, so the dialog reassures rather than alarms — the confirm button renders secondary, not destructive. - Bot-aware copy throughout: "Archive this agent?" vs "Archive this identity?", "removes this agent" vs "removes this person". - Three effect bullets (won't appear in search/autocomplete/member-add, affects only this space, reversible via unarchive) rendered as a
    sibling of AlertDialogDescription — that component is a

    and can't legally nest a list. - Bot path adds a trailing paragraph pointing to permanent deletion in the Agents tab via an in-app link (Button variant=link) that closes the dialog then navigates with useAppNavigation().goAgents() — no raw anchor, no routing behind an open modal. Navigation is lifted to ProfileManageSection and passed in via onGoToAgents so the dialog stays presentational. Identity path renders no link (there is no delete target for a human identity). - Confirm button styling fix: AlertDialogAction applies buttonVariants() (primary) to its own element; the secondary classes are passed straight to it so tailwind-merge overrides the primary background rather than asChild + a nested Button (which concatenates variants and leaves primary winning on source order). Unarchive stays bare one-click (recovery action, no friction). All three archive testids plus archive-confirm-dialog/archive-confirm-action are intact; identity-archive.spec.ts is testid-driven, stays 5/5 green, and case 1 clicks through the confirm. Zero validateDOMNesting warnings. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/identity-archive/hooks.ts | 59 ++++-------- .../profile/ui/ArchiveConfirmDialog.tsx | 96 +++++++++++++++++++ .../profile/ui/ProfileManageSection.tsx | 22 ++++- desktop/tests/e2e/identity-archive.spec.ts | 16 +++- 4 files changed, 146 insertions(+), 47 deletions(-) create mode 100644 desktop/src/features/profile/ui/ArchiveConfirmDialog.tsx diff --git a/desktop/src/features/identity-archive/hooks.ts b/desktop/src/features/identity-archive/hooks.ts index cec5880a4..d61df7687 100644 --- a/desktop/src/features/identity-archive/hooks.ts +++ b/desktop/src/features/identity-archive/hooks.ts @@ -26,11 +26,7 @@ export function useArchivedIdentitiesQuery(enabled = true) { }); } -/** - * `true` iff `pubkey` appears in the relay's latest archive snapshot. - * Returns `undefined` while the snapshot is loading so callers can hide the - * flair until we know. - */ +/** `undefined` while the snapshot loads so callers can defer the flair. */ export function useIsIdentityArchived(pubkey: string): boolean | undefined { const query = useArchivedIdentitiesQuery(); if (!query.data) return undefined; @@ -40,20 +36,16 @@ export function useIsIdentityArchived(pubkey: string): boolean | undefined { /** * Predicate for hiding archived identities from forward-looking discovery - * surfaces (mention autocomplete, DM picker, member-adder, search, - * panel-fold). Distinct from `useIsIdentityArchived` because callers here - * need a synchronous boolean: while the `kind:13535` snapshot is loading the - * predicate returns `false` (no-op — show everyone), never `true` — fail-open - * so a cold-start can't briefly hide everyone. + * surfaces (autocomplete, DM picker, member-adder, search, panel-fold). + * Fail-open: returns `false` while the snapshot loads so a cold start can't + * briefly hide everyone. * - * Self-exempt by construction: the current user is **never** filtered or - * folded from their own client, even when archived on the relay. NIP-IA §Self - * Requests makes archival deliberately non-silent — the anti-shadowban - * property requires the archived user to see they're archived and be able to - * self-unarchive. The profile pane's "Archived" flair is the honest - * disclosure; removing self from member lists / autocomplete / search would - * build the exact shadowban the NIP is designed to prevent. Self-exemption - * lives here, in the predicate, so no caller can forget it. + * Self-exempt by construction: the current user is never folded from their own + * client, even when archived on the relay. NIP-IA §Self Requests makes archival + * deliberately non-silent — the anti-shadowban property requires the archived + * user to see they're archived and self-unarchive. Folding self would build the + * exact shadowban the NIP prevents, so the exemption lives here in the + * predicate where no caller can forget it. */ export function useIsArchivedPredicate(): (pubkey: string) => boolean { const query = useArchivedIdentitiesQuery(); @@ -71,10 +63,7 @@ export function useIsArchivedPredicate(): (pubkey: string) => boolean { }, [query.data, selfPubkey]); } -/** - * Resolve the NIP-OA owner of a target via its live `kind:0`. Gates the - * owner-path archive button. - */ +/** Gates the owner-path archive button via the target's live `kind:0`. */ export function useOaOwnerQuery(pubkey: string, enabled = true) { return useQuery({ enabled, @@ -110,38 +99,22 @@ export function useUnarchiveIdentityMutation() { /** Everything the profile panel needs to gate + drive NIP-IA archival. */ export type IdentityArchiveActions = { - /** - * UX gate. `true` when ANY auth path will be accepted by the relay: self - * (acting on own pubkey), relay admin/owner, or verified NIP-OA owner of the - * viewee. The relay re-verifies on submit — this is purely a render guard. - */ + /** Render guard only — the relay re-verifies authority on submit. */ canArchive: boolean; - /** - * `true` iff the target is in the relay's latest `kind:13535` snapshot. - * `undefined` while the snapshot loads so callers can defer the flair / - * Manage section until authority + state are both known. - */ + /** `undefined` while the snapshot loads — defer flair + Manage until known. */ isArchived: boolean | undefined; - /** Either mutation in flight — drives the disabled + "Archiving…" states. */ isPending: boolean; - /** Submit a `kind:9035` archive request for `pubkey` (toasts on result). */ archive: () => void; - /** Submit a `kind:9036` unarchive request for `pubkey` (toasts on result). */ unarchive: () => void; }; /** * Self-contained NIP-IA archive controller for a single `pubkey`. Composes the - * three gate queries, owns both mutations, and exposes the archive/unarchive - * callbacks with toasts — collapsing what used to be six props drilled through - * the profile panel into one hook call. + * gate queries, owns both mutations, and exposes archive/unarchive with toasts. * * Safe to call from multiple components on the same `pubkey`: React Query - * dedupes the underlying subscriptions by queryKey, so the only cost is a - * second hook invocation, not a second network round-trip. - * - * Gate composition is verbatim from the old `UserProfilePanel`: - * `canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee`. + * dedupes the underlying subscriptions by queryKey, so a second hook call costs + * a render, not a second network round-trip. */ export function useIdentityArchive(pubkey: string): IdentityArchiveActions { const identityQuery = useIdentityQuery(); diff --git a/desktop/src/features/profile/ui/ArchiveConfirmDialog.tsx b/desktop/src/features/profile/ui/ArchiveConfirmDialog.tsx new file mode 100644 index 000000000..5de757673 --- /dev/null +++ b/desktop/src/features/profile/ui/ArchiveConfirmDialog.tsx @@ -0,0 +1,96 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button, buttonVariants } from "@/shared/ui/button"; + +// Archive is relay-scoped + reversible (NIP-IA), so this gates with a calm, +// reassuring confirmation rather than a destructive warning. The confirm action +// renders `secondary` to match the trigger and the non-alarming tone — we pass +// the secondary classes straight to `AlertDialogAction` (whose base style is the +// default/primary variant) so tailwind-merge overrides the primary background; +// `asChild` + a nested Button would concatenate both variants and leave the +// primary fill winning on source order. +export function ArchiveConfirmDialog({ + open, + onOpenChange, + onConfirm, + onGoToAgents, + isBot, + isPending, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + onGoToAgents: () => void; + isBot: boolean; + isPending: boolean; +}) { + const title = isBot ? "Archive this agent?" : "Archive this identity?"; + const subject = isBot ? "this agent" : "this person"; + + return ( + + + + {title} + + Archiving removes {subject} from this space. + + + {/* The list + closing paragraph sit outside AlertDialogDescription on + purpose — that component renders a

    , which can't legally contain + a

      or another block

      . */} +

        +
      • + They won't appear in search, autocomplete, or when adding members +
      • +
      • + This only affects{" "} + this space — + not their account anywhere else +
      • +
      • You can unarchive them at any time to restore them
      • +
      + {isBot ? ( +

      + To permanently remove this agent instead, delete it in the{" "} + + . +

      + ) : null} + + + + + + {isPending ? "Archiving…" : "Archive"} + + + + + ); +} diff --git a/desktop/src/features/profile/ui/ProfileManageSection.tsx b/desktop/src/features/profile/ui/ProfileManageSection.tsx index c4d474c2d..428bd891d 100644 --- a/desktop/src/features/profile/ui/ProfileManageSection.tsx +++ b/desktop/src/features/profile/ui/ProfileManageSection.tsx @@ -1,7 +1,10 @@ import { Archive, ArchiveRestore } from "lucide-react"; +import { useState } from "react"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useIdentityArchive } from "@/features/identity-archive/hooks"; import { Button } from "@/shared/ui/button"; +import { ArchiveConfirmDialog } from "./ArchiveConfirmDialog"; // NIP-IA archive / unarchive lives in its own section under the quick-actions // row. The relay verifies authority (self / admin / OA-owner) on submit; the @@ -16,10 +19,17 @@ export function ProfileManageSection({ }) { const { isArchived, isPending, archive, unarchive } = useIdentityArchive(pubkey); + const { goAgents } = useAppNavigation(); + const [confirmOpen, setConfirmOpen] = useState(false); const archiveLabel = isBot ? "Archive agent" : "Archive identity"; const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity"; + const handleConfirm = () => { + archive(); + setConfirmOpen(false); + }; + return (

      @@ -42,7 +52,7 @@ export function ProfileManageSection({ className="w-full" data-testid="user-profile-archive-identity" disabled={isPending} - onClick={archive} + onClick={() => setConfirmOpen(true)} type="button" variant="secondary" > @@ -50,6 +60,16 @@ export function ProfileManageSection({ {isPending ? "Archiving…" : archiveLabel} )} + { + void goAgents(); + }} + onOpenChange={setConfirmOpen} + open={confirmOpen} + />

      ); } diff --git a/desktop/tests/e2e/identity-archive.spec.ts b/desktop/tests/e2e/identity-archive.spec.ts index 58498569d..3518540e6 100644 --- a/desktop/tests/e2e/identity-archive.spec.ts +++ b/desktop/tests/e2e/identity-archive.spec.ts @@ -42,12 +42,22 @@ test.describe("NIP-IA archive button gate", () => { }) => { await installMockBridge(page, { relayRole: null, oaOwnerIsMe: false }); await openSelfProfile(page); - await expect( - page.getByTestId("user-profile-archive-identity"), - ).toBeVisible(); + const archiveButton = page.getByTestId("user-profile-archive-identity"); + await expect(archiveButton).toBeVisible(); await expect(page.getByTestId("user-profile-archived-flair")).toHaveCount( 0, ); + + // Archive is now gated behind a confirmation modal — clicking the button + // opens the dialog rather than firing immediately. Drive the full flow so + // the gate stays meaningful: the modal must surface, then confirm fires. + await expect(page.getByTestId("archive-confirm-dialog")).toHaveCount(0); + await archiveButton.click(); + await expect(page.getByTestId("archive-confirm-dialog")).toBeVisible(); + const confirm = page.getByTestId("archive-confirm-action"); + await expect(confirm).toBeVisible(); + await confirm.click(); + await expect(page.getByTestId("archive-confirm-dialog")).toHaveCount(0); }); test("case 2 — relay admin viewing Alice: Archive visible", async ({ From c5a901a470f348fbfd61f3af3fa9b88c79e571ad Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sun, 14 Jun 2026 13:52:49 -0700 Subject: [PATCH 7/7] fix(desktop): detect owned agents via kind:0 OA-owner signal in profile panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owned agents rendered as humans in the profile panel's archive flow: the Archive button + confirm modal showed the human variant even for an agent the viewer owns (repro: tho's agent Edna). Root cause: two gates disagreed. The archive button's canArchive gate resolves correctly via OA-ownership, but the human-vs-agent framing used a separate signal — isBot = Boolean(relayAgent || managedAgent) — that checks the relay-agents registry + the local managed-agents list. An owned agent deployed elsewhere can miss BOTH lists, so isBot was false and the panel rendered the human framing while the button still showed. Fix: OR in the kind:0-derived agent flag (isAgent on the users-batch summary, which the backend sets from profile_has_valid_oa_owner — a verified NIP-OA auth tag on the target's kind:0). That's the same authoritative signal the archive gate's resolveOaOwner trusts, so isBot can no longer drift from the gate. Client-only change, no relay/registry change. - UserProfilePanel: query useUsersBatchQuery([pubkey]) and OR its isAgent into isBot (keyed by lowercased pubkey, matching the house pattern). - BotIdenticon: forward an optional data-testid to its wrapper. - ProfileHero: tag the profile bot indicator with data-testid=profile-bot-indicator for the regression test. - profile.spec.ts: regression test — an owned agent seeded with the kind:0 agent flag but absent from relay/managed lists now renders agent framing. Applied fresh in-branch on #961 (not cherry-picked) so the bot-detection fix and the archive dialog ship together: Edna now renders the full agent-framed dialog ("Archive this agent?" + Agents-tab link) end-to-end. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/messages/ui/BotIdenticon.tsx | 3 ++ .../src/features/profile/ui/ProfileHero.tsx | 1 + .../features/profile/ui/UserProfilePanel.tsx | 12 ++++- desktop/tests/e2e/profile.spec.ts | 54 +++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/ui/BotIdenticon.tsx b/desktop/src/features/messages/ui/BotIdenticon.tsx index 90309cccb..743110f65 100644 --- a/desktop/src/features/messages/ui/BotIdenticon.tsx +++ b/desktop/src/features/messages/ui/BotIdenticon.tsx @@ -7,6 +7,7 @@ type BotIdenticonProps = { /** Size in pixels (default 20) */ size?: number; className?: string; + "data-testid"?: string; }; /** @@ -17,6 +18,7 @@ export const BotIdenticon = React.memo(function BotIdenticon({ value, size = 20, className, + "data-testid": dataTestid, }: BotIdenticonProps) { const svgHtml = React.useMemo(() => toSvg(value, size), [value, size]); @@ -24,6 +26,7 @@ export const BotIdenticon = React.memo(function BotIdenticon({
      diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f71a5f575..52ae9dde9 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -22,6 +22,7 @@ import { useProfileQuery, useUnfollowMutation, useUserProfileQuery, + useUsersBatchQuery, } from "@/features/profile/hooks"; import { ChannelsFocusedView, @@ -156,6 +157,12 @@ export function UserProfilePanel({ const relayAgentsQuery = useRelayAgentsQuery({ enabled: true }); const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); + // kind:0-derived agent flag (a verified NIP-OA `auth` tag on the target's + // profile) — the same authoritative signal the archive gate's OA-owner + // resolution trusts. The relay-agents registry + local managed-agents list + // below can both miss an owned agent that was deployed elsewhere, so we OR + // this in to keep `isBot` from drifting from the gate. + const usersBatchQuery = useUsersBatchQuery([pubkey]); const channelsQuery = useChannelsQuery(); const presenceQuery = usePresenceQuery([pubkey]); const userStatusQuery = useUserStatusQuery([pubkey]); @@ -176,7 +183,10 @@ export function UserProfilePanel({ const managedAgent = managedAgentsQuery.data?.find( (agent) => agent.pubkey.toLowerCase() === pubkeyLower, ); - const isBot = Boolean(relayAgent || managedAgent); + const isAgentByOaOwner = Boolean( + usersBatchQuery.data?.profiles[pubkeyLower]?.isAgent, + ); + const isBot = Boolean(relayAgent || managedAgent) || isAgentByOaOwner; const isOwner = useIsManagedAgent(isBot ? pubkey : null); // Populate the active-turns store for this agent so useActiveAgentTurns works diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index ae1db3ab2..292cfb2d3 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -615,6 +615,60 @@ test("renders agent memories seeded through the Playwright mock bridge", async ( await expect(page.getByTestId("agent-memory-list")).toContainText("orphan"); }); +test("owned agent absent from relay/managed lists still renders agent framing", async ({ + page, +}) => { + // Regression: bot-detection used to rely solely on the relay-agents registry + // + the local managed-agents list. An owned agent deployed elsewhere can miss + // BOTH lists, so the panel rendered it as a human (wrong archive framing). + // The fix ORs in the kind:0 NIP-OA agent flag (same signal the archive gate + // trusts), surfaced via the users-batch summary's `isAgent`. + const ednaPubkey = + "16aaadcf39011edbd887e4abefe5837170621db277e234f3f6c220d38ba75ecf"; + await installMockBridge(page, { + // Seeded as an agent (kind:0 NIP-OA owner) but NOT as a managed agent and + // NOT in the relay-agents registry — exactly the bug scenario. + searchProfiles: [ + { pubkey: ednaPubkey, displayName: "Edna", isAgent: true }, + ], + }); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + await page.evaluate( + ({ pubkey }) => { + const emit = ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + pubkey: string; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) { + throw new Error("Mock message emitter is unavailable."); + } + emit({ channelName: "general", content: "Edna check-in", pubkey }); + }, + { pubkey: ednaPubkey }, + ); + + const messageRow = page + .getByTestId("message-row") + .filter({ hasText: "Edna check-in" }); + await expect(messageRow).toBeVisible(); + await messageRow.locator("button").first().click(); + + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + // The bot indicator only renders when isBot resolves true — the assertion + // that the OA-owner signal now drives agent framing. + await expect(page.getByTestId("profile-bot-indicator")).toBeVisible(); +}); + test("renders settings in the app shell with a back button", async ({ page, }) => {