Files
buzz/desktop/src/features/agents/ui/AddAgentToChannelDialog.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

252 lines
8.0 KiB
TypeScript

import * as React from "react";
import {
type AttachManagedAgentToChannelResult,
useAttachManagedAgentToChannelMutation,
} from "@/features/agents/hooks";
import {
useChannelMembersQuery,
useChannelsQuery,
} from "@/features/channels/hooks";
import type { Channel, ChannelRole, ManagedAgent } from "@/shared/api/types";
import {
canonicalNpub,
normalizePubkey,
UNAVAILABLE_KEY_LABEL,
} from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { CopyButton } from "./CopyButton";
export function AddAgentToChannelDialog({
agent,
open,
onAdded,
onOpenChange,
}: {
agent: ManagedAgent | null;
open: boolean;
onAdded: (
channel: Channel,
result: AttachManagedAgentToChannelResult,
) => void;
onOpenChange: (open: boolean) => void;
}) {
const channelsQuery = useChannelsQuery();
const [channelId, setChannelId] = React.useState("");
const [role, setRole] = React.useState<Exclude<ChannelRole, "owner">>("bot");
const attachAgentMutation = useAttachManagedAgentToChannelMutation(
channelId || null,
);
const channels = React.useMemo(
() =>
(channelsQuery.data ?? []).filter(
(channel) => channel.channelType !== "dm" && !channel.archivedAt,
),
[channelsQuery.data],
);
function reset() {
setChannelId("");
setRole("bot");
attachAgentMutation.reset();
}
function handleOpenChange(next: boolean) {
if (!next) {
reset();
}
onOpenChange(next);
}
React.useEffect(() => {
if (!open) {
return;
}
if (!channelId && channels.length > 0) {
setChannelId(channels[0].id);
}
}, [channelId, channels, open]);
const membersQuery = useChannelMembersQuery(
channelId || null,
open && !!channelId,
);
const isAlreadyMember = React.useMemo(() => {
if (!agent?.pubkey || !membersQuery.data) {
return false;
}
const normalized = normalizePubkey(agent.pubkey);
return membersQuery.data.some(
(member) => normalizePubkey(member.pubkey) === normalized,
);
}, [agent?.pubkey, membersQuery.data]);
// The agent's public key displays as its full canonical npub; an
// unencodable key renders the neutral label and is not copyable.
const agentNpub = agent?.pubkey ? canonicalNpub(agent.pubkey) : null;
const selectedChannel =
channels.find((channel) => channel.id === channelId) ?? null;
async function handleSubmit() {
if (!agent || !selectedChannel) {
return;
}
try {
const result = await attachAgentMutation.mutateAsync({
agent,
role,
});
onAdded(selectedChannel, result);
handleOpenChange(false);
} catch {
// React Query stores the error; keep the dialog open and render it inline.
}
}
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Add agent to channel</DialogTitle>
<DialogDescription>
Add {agent?.name ?? "this agent"} to a channel so desktop chat can
`@mention` it. Running agents pick up new channels automatically
via membership notifications.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-channel-id">
Channel
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
disabled={
channels.length === 0 || attachAgentMutation.isPending
}
id="agent-channel-id"
onChange={(event) => setChannelId(event.target.value)}
value={channelId}
>
{channels.length === 0 ? (
<option value="">No channels available</option>
) : null}
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name} · {channel.visibility}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Only channels accessible to the current desktop user are shown
here.
</p>
</div>
{isAlreadyMember ? (
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
<span>✓</span>
<span>Already a member of this channel</span>
</div>
) : null}
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="agent-channel-role"
>
Role
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
disabled={attachAgentMutation.isPending}
id="agent-channel-role"
onChange={(event) =>
setRole(event.target.value as Exclude<ChannelRole, "owner">)
}
value={role}
>
<option value="bot">bot</option>
<option value="member">member</option>
<option value="guest">guest</option>
<option value="admin">admin</option>
</select>
</div>
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
<p className="text-sm font-semibold tracking-tight">
Agent pubkey
</p>
<div className="mt-3 flex items-center justify-between gap-3">
<code className="min-w-0 flex-1 break-all rounded-xl border border-border/70 bg-background/80 px-3 py-2 text-xs">
{agent
? (agentNpub ?? UNAVAILABLE_KEY_LABEL)
: "No agent selected"}
</code>
{agent && agentNpub ? (
<CopyButton label="Copy pubkey" value={agentNpub} />
) : null}
</div>
</div>
{channelsQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{channelsQuery.error.message}
</p>
) : null}
{attachAgentMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{attachAgentMutation.error.message}
</p>
) : null}
</div>
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
<Button
onClick={() => handleOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={
!agent ||
!selectedChannel ||
channelsQuery.isLoading ||
attachAgentMutation.isPending
}
onClick={() => void handleSubmit()}
size="sm"
type="button"
>
{attachAgentMutation.isPending
? "Adding..."
: isAlreadyMember
? "Re-add to channel"
: "Add to channel"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}