mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
🤖 ## 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>
539 lines
20 KiB
TypeScript
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'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>
|
|
);
|
|
}
|