Files
buzz/desktop/src/features/agents/ui/RespondToField.tsx
Logan Johnson 2226b6f95b fix(desktop): npub identity controls across profile, agents, and workflows (#7489)
🤖
## Summary

Building on #7488's npub foundation, this PR finishes the identity
display change for the controls where you actually manage people and
keys: profile, settings, agent access, and workflows. Everywhere in
these surfaces, an identity key shows — and copies — as its canonical
npub (npub is the human-readable encoding of a Nostr public key: the
compact `npub1j57...fjmv` form where space is tight, the full npub where
the whole key matters), and accepts npub as input.

After this change:

- Profile panel: the public-key row and the managed-by / declared-owner
copies show the full npub. If a key can't be encoded, you see
"Unavailable" with no copy button — never a raw or partial key.
- Settings: the identity card shows and copies the npub. The
hosted-communities account identity derives from the bound key
(`pubkey_hex`) — the same authority as the mismatch gate and hosted
operations — so the display can never disagree with what the app acts
on; an unusable hex falls back to a neutral label instead of rendering
the unverified server npub. The connected claim and a community's
Connect action require that same usable bound key to match the local one
— with no usable binding the card cannot claim connected or start
Connect, while the community list, linking, and delete/rebind recovery
stay available.
- Hosted create/onboarding: the account and device identity rows in the
create flow and owner onboarding derive from the same authoritative
fields (bound key / local key), with the same neutral fallback;
readiness requires a usable bound key that matches the local one.
- Respond-to allowlist (controls who may respond to an agent): entries
can be typed or pasted as hex or npub; both spellings of the same key
are recognized as one entry and dedupe. Search results, chips, and
remove buttons use the compact npub.
- Workflow key fields: to/from keys display as npubs in the form and
save back as canonical hex. Templates like `{{trigger.author}}`, roles,
and free text pass through untouched; placeholders accept both
spellings.
- Recipient and agent dialogs: the verify popover is npub-only (the
raw-hex line is gone); denied-membership screens never show a raw key.
- The Rust-side truncated display name (used for native surfaces) shows
the same compact npub, so those surfaces match the web UI.

Internal representation is unchanged: keys are still stored, sent, and
matched as canonical 64-character hex — npub is a display and input
spelling, normalized to hex at the boundary, so existing data and
integrations keep working. Bound-key usability and comparison use one
normalized form (trimmed, lowercased, 64 hex characters; npub rejected),
so padded or mixed-case spellings of the same key match. Display names,
private keys, and event IDs are untouched.

## Details

- `respondToAllowlist` / `RespondToField`: npub entries normalize to
canonical hex; cross-form dedupe; compact npub in rows and chips;
direct-add accepts npub and stores canonical hex.
- `workflowFormTypes` / `WorkflowStepCard`: hex → npub for display, npub
→ canonical hex on save; templates, roles, and free text pass through in
both directions (roundtrip-tested).
- `UserProfilePanelFields`, `ProfileSettingsCard`,
`HostedCommunitiesSettingsCard`, `MembershipDenied`,
`SelectedRecipientChip`, `AddAgentToChannelDialog`: npub display and
copy; invalid keys → "Unavailable" with no copy; hosted identity rows
derive from the bound `pubkey_hex` (create/onboarding rows from the
bound and local keys), never the unverified server npub;
connected/readiness/Connect gates use the same usable-bound-key
predicate, and the settings Connect invocation callback re-checks it
before starting.
- `src-tauri/src/commands/identity.rs`: `truncated_display_name`
compacts to the first 8 + last 4 characters of the npub (above a 12-char
threshold), mirroring `truncateNpub`.
- e2e: profile key rows and clipboard polls assert npub forms and
raw-hex suppression; the display-screenshots spec pins the npub-only
popover; hosted specs drive the real settings card, create flow, and
onboarding rows through their real providers, and the unlinked/npub-only
identity cases assert no connected claim and no Connect action.

### Related issue

- Fixes: N/A. No separate issue; the related work is the stack below.
- Stack: builds on #7488 (shared npub foundation), now merged; this PR
is rebased onto main and stands on its own.

### Testing

At head `303c90ffa` (base: main `bfc38485`; 24 files, +1125/−146):

- Focused unit suites (respondToAllowlist, workflowFormTypes,
hostedCommunityApi bound-key helpers) green; mutation-checked — dropping
allowlist canonicalization fails the dedupe case, and dropping bound-key
normalization fails the npub-in-hex and padded same-key cases.
- Full desktop unit suite 6,477/6,477, `desktop-typecheck`,
`desktop-check` (formatting fixed narrowly with `biome check --write` on
the touched files only), and a fresh E2E build at the current head; the
add-community + hosted-communities-settings specs 18/18 and onboarding
integration 69/69 on a fresh dedicated port, with focused new-case runs
4+4 covering padded same-key (ready, Connect kept — no false rebind) and
npub-in-hex (neutral label, recovery, no Connect) across the settings
card, create flow, and first-community onboarding, plus the
unlinked-account settings regression asserting Connect cannot occur.
- `cargo fmt`/clippy (both feature sets) and `cargo test identity` (71
pass) passed at the earlier full-change head; since then, the only
production changes in this PR's delta are the hosted identity display
authority and its fail-closed bound-key gating/normalization above
(base-side fixes carry #7488's receipts) — every other change is
test-only.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 14:08:01 -04:00

539 lines
20 KiB
TypeScript

import * as React from "react";
import { AlertTriangle, ChevronDown, Search, X } from "lucide-react";
import {
mergeAllowlist,
parsePubkeyInput,
} from "@/features/agents/lib/respondToAllowlist";
import { parsePubkeyInput as parseCanonicalPubkey } from "@/shared/lib/nostrUtils";
import { truncateNpub } from "@/shared/lib/pubkey";
import { PubKey } from "@/shared/ui/PubKey";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useUserSearchQuery } from "@/features/profile/hooks";
import type { RespondToMode, UserSearchResult } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
type AgentRunLocation,
agentAccessWarningText,
} from "@/features/agents/lib/agentAccessWarning";
import { useAgentRunLocation } from "./AgentRunLocationContext";
import { PersonaDropdownField } from "./PersonaDropdownField";
import type { PersonaDropdownOption } from "./agentConfigOptions";
/**
* Inbound author gate UI for create/edit agent dialogs.
*
* Dropdown:
* - Only me (default; maps to `buzz-acp --respond-to=owner-only`)
* - Anyone (`--respond-to=anyone` — fully open agent)
* - Selected people (`--respond-to=allowlist`, plus the selected pubkeys as
* `--respond-to-allowlist`)
*
* `nobody` is intentionally not surfaced — it pairs with a heartbeat-only
* setup that has no meaningful GUI use case.
*
* Anyone and Selected people both share the host's access with someone other
* than the owner, so both render the persistent warning; only the audience
* phrase differs. It leads with the audience so it reads as a warning rather
* than an explanation, and stays one sentence — Only me already owns the line
* below the control.
*
* The line below Only me says "Only you and your agents", because the harness
* gate admits the owner and every verified same-owner agent, not the owner
* alone (see `managed_agents/access_policy.rs`). The dropdown label stays
* "Only me": it is the audience the user picks, and it has meant this since
* before agents could instruct each other.
*
* Which machine and stakes it names follow the optional `runLocation` prop, and
* an unknown location falls back to the local wording rather than hedging with
* "computer or server" — see `lib/agentAccessWarning.ts` for the copy and the
* reasoning.
*
* Validation is duplicated lightly here for inline UX feedback only; the
* authoritative validator is `validate_respond_to_allowlist` in
* `desktop/src-tauri/src/managed_agents/types.rs`.
*/
function formatSearchUserName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
truncateNpub(user.pubkey)
);
}
function formatSearchUserSecondary(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return truncateNpub(user.pubkey);
}
const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [
{ label: "Only me (default)", value: "owner-only" },
{ label: "Anyone", value: "anyone" },
{ label: "Selected people", value: "allowlist" },
];
export const OWNER_ONLY_ACCESS_DISABLED_REASON =
"This build disallows changing this setting.";
export function CreateAgentRespondToField({
mode,
allowlist,
onModeChange,
onAllowlistChange,
ownerPubkey,
disabled,
disabledReason,
variant,
runLocation,
}: {
mode: RespondToMode;
allowlist: string[];
onModeChange: (mode: RespondToMode) => void;
onAllowlistChange: (allowlist: string[]) => void;
/**
* The agent's own owner pubkey, when known (Edit dialog). The owner is
* always implicitly included by the harness — surfaced here as a small
* informational note so it doesn't look like a missing entry.
*/
ownerPubkey?: string | null;
disabled?: boolean;
/** Explanation shown when this access control is unavailable. */
disabledReason?: string;
/** When "persona", uses PersonaDropdownField styling to match the persona dialog. */
variant?: "default" | "persona";
/**
* Where the agent's process runs, when the surface can tell. Omit or pass
* `null` when it can't — the warning then uses the same "your computer"
* wording as a local agent rather than hedging. Never synthesize a value.
*/
runLocation?: AgentRunLocation | null;
}) {
const [query, setQuery] = React.useState("");
const [isDirectEntryOpen, setIsDirectEntryOpen] = React.useState(false);
const [pasteText, setPasteText] = React.useState("");
const deferredQuery = React.useDeferredValue(query.trim());
const allowlistSet = React.useMemo(
() => new Set(allowlist.map((p) => p.toLowerCase())),
[allowlist],
);
const userSearchQuery = useUserSearchQuery(deferredQuery, {
enabled: mode === "allowlist" && deferredQuery.length > 0,
limit: 8,
});
const isArchivedDiscovery = useIsArchivedPredicate();
const searchResults = React.useMemo(
() =>
(userSearchQuery.data ?? []).filter(
(user) =>
!allowlistSet.has(user.pubkey.toLowerCase()) &&
!isArchivedDiscovery(user.pubkey),
),
[allowlistSet, isArchivedDiscovery, userSearchQuery.data],
);
const pasteParsed = React.useMemo(
() => parsePubkeyInput(pasteText),
[pasteText],
);
function handleAddSearchResult(user: UserSearchResult) {
onAllowlistChange(mergeAllowlist(allowlist, [user.pubkey]));
setQuery("");
}
function handleAddRawPubkey(pubkey: string) {
onAllowlistChange(mergeAllowlist(allowlist, [pubkey]));
setQuery("");
}
function handleRemove(pubkey: string) {
onAllowlistChange(
allowlist.filter((p) => p.toLowerCase() !== pubkey.toLowerCase()),
);
}
function handleAddFromPaste() {
if (pasteParsed.valid.length === 0) return;
onAllowlistChange(mergeAllowlist(allowlist, pasteParsed.valid));
setPasteText("");
}
const isPersonaVariant = variant === "persona";
// An explicit prop wins; otherwise inherit from the dialog subtree. Surfaces
// inside AgentDialog get it from context (see AgentRunLocationContext for
// why), standalone ones like EditRespondToDialog pass the prop.
const inheritedRunLocation = useAgentRunLocation();
const warningText = agentAccessWarningText(
mode,
runLocation ?? inheritedRunLocation,
);
// Rendered in two positions: directly below the selector for Anyone, but
// after the people picker for Selected people, so it never sits between the
// user and the selection they came here to make.
const accessWarning = warningText ? (
<div
className="flex items-start gap-2 rounded-xl border border-warning/30 bg-warning-bg px-3 py-2.5"
data-testid="agent-access-warning"
>
<AlertTriangle
aria-hidden="true"
className="mt-0.5 h-4 w-4 shrink-0 text-warning"
/>
<p aria-live="polite" className="text-xs leading-5 text-warning">
{warningText}
</p>
</div>
) : null;
return (
<div className="space-y-2" data-testid="agent-respond-to">
<label
className={
isPersonaVariant
? "text-sm font-medium text-foreground"
: "text-sm font-medium"
}
htmlFor="agent-respond-to"
>
Who can send instructions
</label>
{isPersonaVariant ? (
<PersonaDropdownField
disabled={disabled}
id="agent-respond-to"
onValueChange={(value) => onModeChange(value as RespondToMode)}
options={RESPOND_TO_OPTIONS}
placeholder="Only me (default)"
value={mode}
/>
) : (
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
data-testid="agent-respond-to-select"
disabled={disabled}
id="agent-respond-to"
onChange={(e) => onModeChange(e.target.value as RespondToMode)}
value={mode}
>
{RESPOND_TO_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{disabledReason ? (
<p
className="text-xs text-muted-foreground"
data-testid="agent-respond-to-disabled-reason"
>
{disabledReason}
</p>
) : null}
{mode === "anyone" ? accessWarning : null}
{mode === "owner-only" ? (
<p className="text-xs text-muted-foreground">
Only you and your agents can send instructions.
</p>
) : null}
{mode === "allowlist" ? (
<AllowlistPicker
allowlist={allowlist}
deferredQuery={deferredQuery}
disabled={disabled}
isDirectEntryOpen={isDirectEntryOpen}
onAddFromPaste={handleAddFromPaste}
onAddRawPubkey={handleAddRawPubkey}
onAddSearchResult={handleAddSearchResult}
onPasteTextChange={setPasteText}
onQueryChange={setQuery}
onRemove={handleRemove}
onToggleDirectEntry={() => setIsDirectEntryOpen((v) => !v)}
ownerPubkey={ownerPubkey ?? null}
pasteInvalid={pasteParsed.invalid}
pasteText={pasteText}
pasteValidCount={pasteParsed.valid.length}
query={query}
searchError={
userSearchQuery.error instanceof Error
? userSearchQuery.error.message
: null
}
searchIsLoading={userSearchQuery.isLoading}
searchResults={searchResults}
variant={isPersonaVariant ? "persona" : "default"}
/>
) : null}
{mode === "allowlist" ? accessWarning : null}
</div>
);
}
function AllowlistPicker({
allowlist,
deferredQuery,
disabled,
isDirectEntryOpen,
onAddFromPaste,
onAddRawPubkey,
onAddSearchResult,
onPasteTextChange,
onQueryChange,
onRemove,
onToggleDirectEntry,
ownerPubkey,
pasteInvalid,
pasteText,
pasteValidCount,
query,
searchError,
searchIsLoading,
searchResults,
variant = "default",
}: {
allowlist: string[];
deferredQuery: string;
disabled?: boolean;
isDirectEntryOpen: boolean;
onAddFromPaste: () => void;
onAddRawPubkey: (pubkey: string) => void;
onAddSearchResult: (user: UserSearchResult) => void;
onPasteTextChange: (value: string) => void;
onQueryChange: (value: string) => void;
onRemove: (pubkey: string) => void;
onToggleDirectEntry: () => void;
ownerPubkey: string | null;
pasteInvalid: string[];
pasteText: string;
pasteValidCount: number;
query: string;
searchError: string | null;
searchIsLoading: boolean;
searchResults: UserSearchResult[];
variant?: "default" | "persona";
}) {
const isPersona = variant === "persona";
// Detect if the query is a pubkey (npub or hex) not already in the list;
// direct entry offers the canonical hex for storage.
const queryPubkey = parseCanonicalPubkey(deferredQuery);
const queryIsDirectPubkey =
queryPubkey !== null &&
!allowlist.some((p) => p.toLowerCase() === queryPubkey);
return (
<div
className={
isPersona
? "space-y-2.5"
: "space-y-2.5 rounded-xl border border-border/80 bg-muted/15 p-3"
}
data-testid="agent-respond-to-allowlist"
>
{!isPersona ? (
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Selected people</span>
<span className="rounded-full bg-background px-2 py-1 text-2xs font-medium leading-none text-muted-foreground">
{allowlist.length} selected
</span>
</div>
) : null}
{!isPersona && ownerPubkey ? (
<p className="text-xs text-muted-foreground">
You (
<PubKey pubkey={ownerPubkey} />) can always use this agent. You
don&apos;t need to add yourself.
</p>
) : !isPersona ? (
<p className="text-xs text-muted-foreground">
You can always use this agent.
</p>
) : null}
<div className="rounded-lg border border-border/80 bg-background">
<div className="flex items-center gap-2 px-2.5 py-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
className="h-auto border-0 px-0 py-0 shadow-none focus-visible:ring-0"
data-testid="agent-respond-to-search"
disabled={disabled}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={
isPersona ? "Search people" : "Search by name or NIP-05."
}
value={query}
/>
</div>
{allowlist.length > 0 ? (
<div className="flex flex-wrap gap-1.5 border-t border-border/70 px-2.5 py-2">
{allowlist.map((pubkey) => (
<div
className="inline-flex items-center gap-1.5 rounded-full border border-border/80 bg-muted/60 px-2.5 py-1 text-2xs leading-none"
data-testid={`agent-respond-to-chip-${pubkey}`}
key={pubkey}
>
<UserAvatar
avatarUrl={null}
displayName={truncateNpub(pubkey)}
size="xs"
/>
<PubKey pubkey={pubkey} />
<button
aria-label={`Remove ${truncateNpub(pubkey)}`}
className="text-muted-foreground transition-colors hover:text-foreground"
disabled={disabled}
onClick={() => onRemove(pubkey)}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
) : null}
{deferredQuery.length > 0 ? (
<div className="border-t border-border/70 px-2 py-2">
{searchIsLoading ? (
<p className="px-2 py-1 text-sm text-muted-foreground">
Searching…
</p>
) : searchResults.length > 0 ? (
<div className="max-h-44 space-y-1 overflow-y-auto">
{searchResults.map((result) => (
<button
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
data-testid={`agent-respond-to-result-${result.pubkey}`}
key={result.pubkey}
onClick={() => onAddSearchResult(result)}
type="button"
>
<div className="flex items-center gap-2 min-w-0">
<UserAvatar
avatarUrl={result.avatarUrl}
displayName={formatSearchUserName(result)}
shape={result.isAgent ? "squircle" : "circle"}
size="xs"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium leading-5">
{formatSearchUserName(result)}
</p>
<p className="truncate text-xs text-muted-foreground">
{formatSearchUserSecondary(result)}
</p>
</div>
</div>
<span className="text-xs text-muted-foreground">Add</span>
</button>
))}
</div>
) : queryIsDirectPubkey ? (
<button
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
data-testid="agent-respond-to-add-raw-pubkey"
onClick={() => onAddRawPubkey(queryPubkey)}
type="button"
>
<div className="flex items-center gap-2 min-w-0">
<UserAvatar
avatarUrl={null}
displayName={truncateNpub(queryPubkey)}
size="xs"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium leading-5">
{truncateNpub(queryPubkey)}
</p>
<p className="truncate text-xs text-muted-foreground">
Add pubkey directly
</p>
</div>
</div>
<span className="text-xs text-muted-foreground">Add</span>
</button>
) : (
<p className="px-2 py-1 text-sm text-muted-foreground">
No matching users.
</p>
)}
</div>
) : null}
</div>
{searchError ? (
<p className="text-sm text-destructive">{searchError}</p>
) : null}
{!isPersona ? (
<div className="space-y-2">
<button
aria-controls="agent-respond-to-direct-panel"
aria-expanded={isDirectEntryOpen}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
data-testid="agent-respond-to-toggle-direct"
onClick={onToggleDirectEntry}
type="button"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isDirectEntryOpen && "rotate-180",
)}
/>
<span>Paste pubkeys</span>
</button>
{isDirectEntryOpen ? (
<div
className="space-y-2 rounded-lg border border-dashed border-border/80 bg-background/70 p-2.5"
id="agent-respond-to-direct-panel"
>
<p className="text-xs text-muted-foreground">
One per line, or comma/space-separated. Each entry is an npub
(npub1…) or a 64-char hex public key.
</p>
<Textarea
className="min-h-20 font-mono text-xs"
data-testid="agent-respond-to-paste"
disabled={disabled}
onChange={(event) => onPasteTextChange(event.target.value)}
placeholder="npub1… or abcdef0123…"
value={pasteText}
/>
{pasteInvalid.length > 0 ? (
<p className="text-xs text-destructive">
{pasteInvalid.length} entr
{pasteInvalid.length === 1 ? "y is" : "ies are"} not a valid
npub or 64-char hex public key and will be ignored.
</p>
) : null}
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{pasteValidCount > 0
? `${pasteValidCount} valid pubkey${pasteValidCount === 1 ? "" : "s"} ready.`
: "No valid pubkeys yet."}
</span>
<button
className="rounded-md border border-border/80 bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
data-testid="agent-respond-to-paste-add"
disabled={disabled || pasteValidCount === 0}
onClick={onAddFromPaste}
type="button"
>
Add people
</button>
</div>
</div>
) : null}
</div>
) : null}
</div>
);
}