feat(desktop): send minted agent cards to recipients via normal DM share path

Closes the v1 scope gap Wren flagged: the mint dialog only offered Save.
Card preview now includes PersonaShareRecipients + a Send action that
routes through useSnapshotSendController — the exact guarded
encode → upload (uploadMediaBytes) → send pipeline agent snapshots use,
with the same eligibility checkpoints and DM-safety gating. No
card-special relay path, per the kickoff contract.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-27 22:41:51 -04:00
co-authored by Tyler Longwell
parent eba5ab72bb
commit 19b449f010
@@ -1,14 +1,19 @@
import * as React from "react";
import { Download, RefreshCw, Sparkles } from "lucide-react";
import { Download, RefreshCw, Send, Sparkles } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import {
useOpenDmMutation,
useUpsertCachedChannel,
} from "@/features/channels/hooks";
import {
mintAgentCard,
NO_OPENAI_KEY_PREFIX,
saveAgentCard,
type MintedAgentCard,
} from "@/shared/api/tauriPersonas";
import type { UserSearchResult } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -19,6 +24,14 @@ import {
} from "@/shared/ui/dialog";
import { Textarea } from "@/shared/ui/textarea";
import { PersonaShareRecipients } from "./PersonaShareRecipients";
import { useSnapshotSendController } from "./useSnapshotSendController";
/** Decode base64 card bytes into the number[] shape the upload path expects. */
function cardBytesFromBase64(b64: string): number[] {
return Array.from(atob(b64), (char) => char.charCodeAt(0));
}
/**
* Mint-a-trading-card dialog: optional style notes → one long Rust-side
* Responses API call → preview with reroll → save as `.agent.png`.
@@ -39,6 +52,11 @@ export function AgentCardMintDialog({
}) {
const [styleNotes, setStyleNotes] = React.useState("");
const [card, setCard] = React.useState<MintedAgentCard | null>(null);
const [recipients, setRecipients] = React.useState<UserSearchResult[]>([]);
const sendController = useSnapshotSendController(card !== null);
const openDmMutation = useOpenDmMutation();
const upsertCachedChannel = useUpsertCachedChannel();
const mintMutation = useMutation({
mutationFn: () => mintAgentCard(agentId, styleNotes.trim() || undefined),
@@ -69,9 +87,38 @@ export function AgentCardMintDialog({
});
const isMinting = mintMutation.isPending;
const isSending =
sendController.state.phase !== "idle" &&
sendController.state.phase !== "done" &&
sendController.state.phase !== "error";
const isBusy = isMinting || saveMutation.isPending || isSending;
async function sendToRecipients(minted: MintedAgentCard) {
if (recipients.length === 0) return;
const sent = await sendController.beginSend(
async () => ({
fileBytes: cardBytesFromBase64(minted.cardPngBase64),
fileName: minted.fileName,
}),
async () => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys: recipients.map((recipient) => recipient.pubkey),
});
await upsertCachedChannel(directMessage);
return directMessage.id;
},
agentName,
);
if (sent) {
toast.success(`Sent ${agentName}'s card.`);
onOpenChange(false);
} else if (sent === false) {
toast.error("Couldn’t send the card. Try again.");
}
}
return (
<Dialog onOpenChange={(open) => !isMinting && onOpenChange(open)} open>
<Dialog onOpenChange={(open) => !isBusy && onOpenChange(open)} open>
<DialogContent className="max-w-md" data-testid="agent-card-mint-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -93,9 +140,21 @@ export function AgentCardMintDialog({
data-testid="agent-card-preview"
src={`data:image/png;base64,${card.cardPngBase64}`}
/>
<PersonaShareRecipients
disabled={isBusy || !sendController.isDmSafetyReady}
excludedPubkeys={
sendController.relaySelfPubkey
? [sendController.relaySelfPubkey]
: []
}
onSelectionChange={setRecipients}
open
selectedUsers={recipients}
testIdPrefix="agent-card-share"
/>
<div className="flex justify-end gap-2">
<Button
disabled={isMinting || saveMutation.isPending}
disabled={isBusy}
onClick={() => mintMutation.mutate()}
variant="outline"
>
@@ -103,13 +162,24 @@ export function AgentCardMintDialog({
{isMinting ? "Rerolling…" : "Reroll"}
</Button>
<Button
disabled={isMinting || saveMutation.isPending}
disabled={isBusy}
onClick={() => saveMutation.mutate(card)}
data-testid="agent-card-save"
variant={recipients.length > 0 ? "outline" : "default"}
>
<Download className="mr-2 h-4 w-4" />
Save card
</Button>
{recipients.length > 0 ? (
<Button
disabled={isBusy}
onClick={() => void sendToRecipients(card)}
data-testid="agent-card-send"
>
<Send className="mr-2 h-4 w-4" />
{isSending ? "Sending…" : "Send"}
</Button>
) : null}
</div>
</div>
) : (