@clawpump/claw-agent 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/agent/.mailmap +4 -0
  2. package/agent/apps/desktop/README.md +3 -3
  3. package/agent/apps/desktop/assets/icon.icns +0 -0
  4. package/agent/apps/desktop/assets/icon.ico +0 -0
  5. package/agent/apps/desktop/assets/icon.png +0 -0
  6. package/agent/apps/desktop/electron/backend-ready.cjs +2 -2
  7. package/agent/apps/desktop/electron/dashboard-token.cjs +3 -3
  8. package/agent/apps/desktop/electron/hardening.cjs +1 -1
  9. package/agent/apps/desktop/electron/main.cjs +65 -65
  10. package/agent/apps/desktop/index.html +1 -1
  11. package/agent/apps/desktop/package.json +11 -11
  12. package/agent/apps/desktop/public/apple-touch-icon.png +0 -0
  13. package/agent/apps/desktop/public/claw-mark.png +0 -0
  14. package/agent/apps/desktop/scripts/set-exe-identity.cjs +2 -2
  15. package/agent/apps/desktop/src/app/chat/composer/controls.tsx +2 -0
  16. package/agent/apps/desktop/src/app/chat/composer/index.tsx +10 -0
  17. package/agent/apps/desktop/src/app/chat/composer/pod-credits.tsx +49 -0
  18. package/agent/apps/desktop/src/app/chat/index.tsx +1 -1
  19. package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +4 -2
  20. package/agent/apps/desktop/src/app/desktop-controller.tsx +18 -0
  21. package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +1 -1
  22. package/agent/apps/desktop/src/app/messaging/index.tsx +5 -5
  23. package/agent/apps/desktop/src/app/routes.ts +9 -1
  24. package/agent/apps/desktop/src/app/session/hooks/use-message-stream.ts +3 -3
  25. package/agent/apps/desktop/src/app/settings/constants.ts +5 -5
  26. package/agent/apps/desktop/src/app/settings/model-settings.tsx +1 -1
  27. package/agent/apps/desktop/src/app/settings/providers-settings.tsx +46 -1
  28. package/agent/apps/desktop/src/app/settings/uninstall-section.tsx +5 -5
  29. package/agent/apps/desktop/src/app/types.ts +9 -1
  30. package/agent/apps/desktop/src/app/wallet/index.tsx +244 -0
  31. package/agent/apps/desktop/src/app/x402/index.tsx +162 -0
  32. package/agent/apps/desktop/src/components/assistant-ui/thread.tsx +1 -1
  33. package/agent/apps/desktop/src/components/brand-mark.tsx +2 -2
  34. package/agent/apps/desktop/src/components/chat/intro-copy.jsonl +6 -6
  35. package/agent/apps/desktop/src/components/chat/intro.tsx +4 -4
  36. package/agent/apps/desktop/src/components/model-picker.tsx +64 -4
  37. package/agent/apps/desktop/src/components/pod-setup-dialog.tsx +227 -0
  38. package/agent/apps/desktop/src/hermes.ts +109 -3
  39. package/agent/apps/desktop/src/i18n/en.ts +80 -78
  40. package/agent/apps/desktop/src/i18n/ja.ts +82 -82
  41. package/agent/apps/desktop/src/i18n/runtime.test.ts +2 -2
  42. package/agent/apps/desktop/src/i18n/zh-hant.ts +82 -82
  43. package/agent/apps/desktop/src/i18n/zh.ts +87 -87
  44. package/agent/apps/desktop/src/lib/desktop-fs.ts +1 -1
  45. package/agent/apps/desktop/src/lib/desktop-slash-commands.ts +4 -4
  46. package/agent/apps/desktop/src/store/composer.ts +7 -0
  47. package/agent/apps/desktop/src/store/onboarding.ts +5 -5
  48. package/agent/apps/desktop/src/themes/presets.ts +54 -54
  49. package/agent/cli.py +184 -10
  50. package/agent/hermes_cli/distribution.py +188 -8
  51. package/agent/hermes_cli/providers.py +29 -0
  52. package/agent/hermes_cli/web_server.py +403 -34
  53. package/agent/plugins/model-providers/usepod/__init__.py +7 -1
  54. package/agent/scripts/release.py +1 -0
  55. package/agent/web/public/claw-logo.png +0 -0
  56. package/agent/web/src/App.tsx +6 -4
  57. package/agent/web/src/components/ChatSidebar.tsx +5 -0
  58. package/agent/web/src/components/ModelPickerDialog.tsx +28 -1
  59. package/agent/web/src/components/PodCredits.tsx +57 -0
  60. package/agent/web/src/components/PodSetupDialog.tsx +240 -0
  61. package/agent/web/src/lib/api.ts +135 -0
  62. package/agent/web/src/pages/AgentMailPage.tsx +684 -0
  63. package/agent/web/src/pages/WalletPage.tsx +53 -5
  64. package/package.json +1 -1
@@ -0,0 +1,684 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import {
3
+ ArrowLeft,
4
+ Check,
5
+ Copy,
6
+ Inbox,
7
+ Mail,
8
+ PenSquare,
9
+ RefreshCw,
10
+ Send,
11
+ ShieldCheck,
12
+ } from "lucide-react";
13
+ import { api } from "@/lib/api";
14
+ import type { AgentWalletBalance, MailInbox, MailMessage } from "@/lib/api";
15
+ import { Button } from "@nous-research/ui/ui/components/button";
16
+ import { Badge } from "@nous-research/ui/ui/components/badge";
17
+ import { Spinner } from "@nous-research/ui/ui/components/spinner";
18
+ import {
19
+ Card,
20
+ CardContent,
21
+ CardHeader,
22
+ CardTitle,
23
+ } from "@nous-research/ui/ui/components/card";
24
+
25
+ type View = "list" | "read" | "compose";
26
+ type Filter = "all" | "inbound" | "outbound";
27
+
28
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
29
+
30
+ function parseRecipients(raw: string): string[] {
31
+ return raw
32
+ .split(/[\s,;]+/)
33
+ .map((s) => s.trim())
34
+ .filter(Boolean);
35
+ }
36
+
37
+ function formatDate(iso: string | null): string {
38
+ if (!iso) return "";
39
+ const d = new Date(iso);
40
+ if (Number.isNaN(d.getTime())) return "";
41
+ return d.toLocaleString(undefined, {
42
+ month: "short",
43
+ day: "numeric",
44
+ hour: "2-digit",
45
+ minute: "2-digit",
46
+ });
47
+ }
48
+
49
+ const inputCls =
50
+ "w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary";
51
+
52
+ function CopyButton({ value }: { value: string }) {
53
+ const [copied, setCopied] = useState(false);
54
+ const onCopy = useCallback(() => {
55
+ navigator.clipboard
56
+ .writeText(value)
57
+ .then(() => {
58
+ setCopied(true);
59
+ setTimeout(() => setCopied(false), 1500);
60
+ })
61
+ .catch(() => {});
62
+ }, [value]);
63
+ return (
64
+ <button
65
+ type="button"
66
+ onClick={onCopy}
67
+ title="Copy email address"
68
+ aria-label="Copy email address"
69
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
70
+ >
71
+ {copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
72
+ </button>
73
+ );
74
+ }
75
+
76
+ export default function AgentMailPage() {
77
+ // ── Agent selection (the MCP requires an explicit agent_id) ────────
78
+ const [agents, setAgents] = useState<AgentWalletBalance[]>([]);
79
+ const [agentId, setAgentId] = useState("");
80
+
81
+ // ── Inbox state ────────────────────────────────────────────────────
82
+ const [inbox, setInbox] = useState<MailInbox | null>(null);
83
+ const [hasInbox, setHasInbox] = useState(false);
84
+ const [inboxLoading, setInboxLoading] = useState(true);
85
+ const [inboxError, setInboxError] = useState<string | null>(null);
86
+
87
+ // ── Provisioning ───────────────────────────────────────────────────
88
+ const [username, setUsername] = useState("");
89
+ const [creating, setCreating] = useState(false);
90
+ const [createArmed, setCreateArmed] = useState(false);
91
+ const [createError, setCreateError] = useState<string | null>(null);
92
+
93
+ // ── Messages ───────────────────────────────────────────────────────
94
+ const [view, setView] = useState<View>("list");
95
+ const [filter, setFilter] = useState<Filter>("all");
96
+ const [messages, setMessages] = useState<MailMessage[]>([]);
97
+ const [messagesLoading, setMessagesLoading] = useState(false);
98
+ const [messagesError, setMessagesError] = useState<string | null>(null);
99
+ const [selected, setSelected] = useState<MailMessage | null>(null);
100
+ const [selectedLoading, setSelectedLoading] = useState(false);
101
+
102
+ // ── Compose ────────────────────────────────────────────────────────
103
+ const [to, setTo] = useState("");
104
+ const [cc, setCc] = useState("");
105
+ const [bcc, setBcc] = useState("");
106
+ const [subject, setSubject] = useState("");
107
+ const [bodyText, setBodyText] = useState("");
108
+ const [replyTo, setReplyTo] = useState("");
109
+ const [sending, setSending] = useState(false);
110
+ const [sendArmed, setSendArmed] = useState(false);
111
+ const [sendError, setSendError] = useState<string | null>(null);
112
+
113
+ const loadInbox = useCallback(() => {
114
+ if (!agentId) return;
115
+ setInboxLoading(true);
116
+ setInboxError(null);
117
+ api
118
+ .getMailAddress(agentId)
119
+ .then((resp) => {
120
+ if (resp.ok) {
121
+ setInbox(resp.inbox);
122
+ setHasInbox(resp.has_inbox);
123
+ } else {
124
+ setInbox(null);
125
+ setHasInbox(false);
126
+ setInboxError(resp.error ?? "Could not load inbox");
127
+ }
128
+ })
129
+ .catch((e) => setInboxError(e instanceof Error ? e.message : String(e)))
130
+ .finally(() => setInboxLoading(false));
131
+ }, [agentId]);
132
+
133
+ const loadMessages = useCallback(() => {
134
+ if (!agentId) return;
135
+ setMessagesLoading(true);
136
+ setMessagesError(null);
137
+ api
138
+ .listMail({ agentId, direction: filter === "all" ? undefined : filter, limit: 100 })
139
+ .then((resp) => {
140
+ if (resp.ok) {
141
+ setMessages(resp.messages ?? []);
142
+ } else {
143
+ setMessagesError(resp.error ?? "Could not load messages");
144
+ setMessages([]);
145
+ }
146
+ })
147
+ .catch((e) => setMessagesError(e instanceof Error ? e.message : String(e)))
148
+ .finally(() => setMessagesLoading(false));
149
+ }, [agentId, filter]);
150
+
151
+ // Load the agent list once; default to the first agent.
152
+ useEffect(() => {
153
+ api
154
+ .getWalletBalances()
155
+ .then((r) => {
156
+ if (r.ok && r.wallets.length) {
157
+ setAgents(r.wallets);
158
+ setAgentId((cur) => cur || r.wallets[0].agent_id);
159
+ }
160
+ })
161
+ .catch(() => {});
162
+ }, []);
163
+
164
+ useEffect(() => {
165
+ // eslint-disable-next-line react-hooks/set-state-in-effect
166
+ loadInbox();
167
+ }, [loadInbox]);
168
+
169
+ useEffect(() => {
170
+ // eslint-disable-next-line react-hooks/set-state-in-effect
171
+ if (hasInbox) loadMessages();
172
+ }, [hasInbox, loadMessages]);
173
+
174
+ const createInbox = useCallback(() => {
175
+ setCreating(true);
176
+ setCreateError(null);
177
+ api
178
+ .createInbox({ agent_id: agentId, username: username.trim() || undefined, confirm: true })
179
+ .then((resp) => {
180
+ if (resp.ok) {
181
+ setCreateArmed(false);
182
+ if (resp.inbox) {
183
+ setInbox(resp.inbox);
184
+ setHasInbox(true);
185
+ } else {
186
+ loadInbox();
187
+ }
188
+ } else {
189
+ setCreateError(resp.error ?? "Could not create inbox");
190
+ }
191
+ })
192
+ .catch((e) => setCreateError(e instanceof Error ? e.message : String(e)))
193
+ .finally(() => setCreating(false));
194
+ }, [agentId, username, loadInbox]);
195
+
196
+ const openMessage = useCallback(
197
+ (m: MailMessage) => {
198
+ setSelected(m);
199
+ setView("read");
200
+ setSelectedLoading(true);
201
+ api
202
+ .readMail(m.messageId, agentId)
203
+ .then((resp) => {
204
+ if (resp.ok && resp.message) setSelected(resp.message);
205
+ })
206
+ .catch(() => {})
207
+ .finally(() => setSelectedLoading(false));
208
+ },
209
+ [agentId],
210
+ );
211
+
212
+ const recipients = useMemo(() => parseRecipients(to), [to]);
213
+ const selectedWallet = useMemo(
214
+ () => agents.find((a) => a.agent_id === agentId) ?? null,
215
+ [agents, agentId],
216
+ );
217
+
218
+ const startCompose = useCallback(() => {
219
+ setSendError(null);
220
+ setSendArmed(false);
221
+ setView("compose");
222
+ }, []);
223
+
224
+ const sendMail = useCallback(() => {
225
+ const toList = parseRecipients(to);
226
+ const ccList = parseRecipients(cc);
227
+ const bccList = parseRecipients(bcc);
228
+ const bad = [...toList, ...ccList, ...bccList].find((a) => !EMAIL_RE.test(a));
229
+ if (toList.length === 0) {
230
+ setSendError("Add at least one recipient.");
231
+ setSendArmed(false);
232
+ return;
233
+ }
234
+ if (bad) {
235
+ setSendError(`Not a valid email: ${bad}`);
236
+ setSendArmed(false);
237
+ return;
238
+ }
239
+ if (!subject.trim()) {
240
+ setSendError("Add a subject.");
241
+ setSendArmed(false);
242
+ return;
243
+ }
244
+ if (!bodyText.trim()) {
245
+ setSendError("Write a message.");
246
+ setSendArmed(false);
247
+ return;
248
+ }
249
+ setSending(true);
250
+ setSendError(null);
251
+ api
252
+ .sendMail({
253
+ agent_id: agentId,
254
+ to: toList,
255
+ cc: ccList.length ? ccList : undefined,
256
+ bcc: bccList.length ? bccList : undefined,
257
+ subject: subject.trim(),
258
+ text: bodyText,
259
+ reply_to: replyTo.trim() || undefined,
260
+ confirm: true,
261
+ })
262
+ .then((resp) => {
263
+ if (resp.ok) {
264
+ setTo("");
265
+ setCc("");
266
+ setBcc("");
267
+ setSubject("");
268
+ setBodyText("");
269
+ setReplyTo("");
270
+ setSendArmed(false);
271
+ setView("list");
272
+ loadMessages();
273
+ } else {
274
+ setSendError(resp.error ?? "Send failed");
275
+ setSendArmed(false);
276
+ }
277
+ })
278
+ .catch((e) => {
279
+ setSendError(e instanceof Error ? e.message : String(e));
280
+ setSendArmed(false);
281
+ })
282
+ .finally(() => setSending(false));
283
+ }, [agentId, to, cc, bcc, subject, bodyText, replyTo, loadMessages]);
284
+
285
+ // ── Render ─────────────────────────────────────────────────────────
286
+ return (
287
+ <div className="mx-auto max-w-3xl space-y-4 p-4">
288
+ <div className="flex flex-wrap items-center justify-between gap-2">
289
+ <div className="flex items-center gap-2">
290
+ <Mail className="h-5 w-5 text-muted-foreground" />
291
+ <h1 className="text-lg font-semibold">Agent Mail</h1>
292
+ {agents.length > 0 && (
293
+ <select
294
+ value={agentId}
295
+ onChange={(e) => {
296
+ setAgentId(e.target.value);
297
+ setView("list");
298
+ setSelected(null);
299
+ }}
300
+ title="Select agent"
301
+ className="ml-1 max-w-[180px] rounded-md border border-border bg-background px-2 py-1 text-sm outline-none focus:border-primary"
302
+ >
303
+ {agents.map((a) => (
304
+ <option key={a.agent_id} value={a.agent_id}>
305
+ {a.name || a.agent_id.slice(0, 8)}
306
+ </option>
307
+ ))}
308
+ </select>
309
+ )}
310
+ </div>
311
+ {hasInbox && view === "list" && (
312
+ <div className="flex items-center gap-2">
313
+ <button
314
+ type="button"
315
+ onClick={loadMessages}
316
+ title="Refresh"
317
+ aria-label="Refresh"
318
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
319
+ >
320
+ <RefreshCw className={`h-4 w-4 ${messagesLoading ? "animate-spin" : ""}`} />
321
+ </button>
322
+ <Button size="sm" prefix={<PenSquare className="h-4 w-4" />} onClick={startCompose}>
323
+ Compose
324
+ </Button>
325
+ </div>
326
+ )}
327
+ </div>
328
+
329
+ {/* Inbox address bar */}
330
+ {hasInbox && inbox && (
331
+ <Card>
332
+ <CardContent className="flex flex-wrap items-center justify-between gap-2 py-3">
333
+ <div className="flex min-w-0 items-center gap-2">
334
+ <Inbox className="h-4 w-4 shrink-0 text-muted-foreground" />
335
+ <span className="truncate font-mono text-sm text-emerald-300">
336
+ {inbox.emailAddress}
337
+ </span>
338
+ <CopyButton value={inbox.emailAddress} />
339
+ </div>
340
+ <div className="flex items-center gap-1.5">
341
+ {inbox.verified && (
342
+ <Badge tone="success" className="shrink-0">
343
+ <ShieldCheck className="mr-1 h-3 w-3" /> verified
344
+ </Badge>
345
+ )}
346
+ {inbox.status && inbox.status !== "active" && (
347
+ <Badge tone="secondary">{inbox.status}</Badge>
348
+ )}
349
+ </div>
350
+ </CardContent>
351
+ </Card>
352
+ )}
353
+
354
+ {inboxError && (
355
+ <Card className="border-destructive/40">
356
+ <CardContent className="py-3 text-sm text-destructive">{inboxError}</CardContent>
357
+ </Card>
358
+ )}
359
+
360
+ {inboxLoading ? (
361
+ <div className="flex justify-center py-12">
362
+ <Spinner />
363
+ </div>
364
+ ) : !hasInbox ? (
365
+ /* ── Provision an inbox ─────────────────────────────────────── */
366
+ <Card>
367
+ <CardHeader className="pb-2">
368
+ <CardTitle className="text-sm font-semibold">No inbox yet</CardTitle>
369
+ </CardHeader>
370
+ <CardContent className="space-y-3">
371
+ <p className="text-sm text-muted-foreground">
372
+ Give this agent a real email address (e.g.{" "}
373
+ <span className="font-mono text-foreground">name@agentmail.to</span>) so it can
374
+ send and receive mail. Provisioning is a one-time{" "}
375
+ <span className="font-semibold text-foreground">~$2 USDC</span> payment from the
376
+ agent&apos;s own wallet over x402.
377
+ </p>
378
+ {selectedWallet && (
379
+ <div className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2">
380
+ <span className="text-sm text-muted-foreground">This agent&apos;s USDC balance</span>
381
+ <span
382
+ className={`font-mono text-sm font-semibold ${
383
+ (selectedWallet.usdc_balance ?? 0) >= 2 ? "text-emerald-300" : "text-amber-300"
384
+ }`}
385
+ >
386
+ {selectedWallet.usdc_balance != null
387
+ ? `$${selectedWallet.usdc_balance.toFixed(2)}`
388
+ : "—"}
389
+ </span>
390
+ </div>
391
+ )}
392
+ {selectedWallet && (selectedWallet.usdc_balance ?? 0) < 2 && (
393
+ <p className="text-xs text-amber-300">
394
+ Not enough USDC for the ~$2 fee — add USDC to this agent&apos;s wallet (or swap
395
+ SOL&nbsp;→&nbsp;USDC) before creating the inbox.
396
+ </p>
397
+ )}
398
+ <div className="flex flex-col gap-2 sm:flex-row">
399
+ <input
400
+ value={username}
401
+ onChange={(e) => setUsername(e.target.value)}
402
+ placeholder="optional username (a-z, 0-9, dot, dash) — omit to auto-generate"
403
+ className={inputCls + " sm:flex-1"}
404
+ />
405
+ </div>
406
+ {createError && <p className="text-sm text-destructive">{createError}</p>}
407
+ {createArmed ? (
408
+ <div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
409
+ <span className="text-sm text-amber-200">
410
+ This pays <span className="font-semibold">~$2 USDC</span> from the agent wallet.
411
+ Continue?
412
+ </span>
413
+ <div className="ml-auto flex gap-2">
414
+ <button
415
+ type="button"
416
+ onClick={() => setCreateArmed(false)}
417
+ className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
418
+ >
419
+ Cancel
420
+ </button>
421
+ <Button size="sm" onClick={createInbox} disabled={creating}>
422
+ {creating ? "Creating…" : "Confirm & pay"}
423
+ </Button>
424
+ </div>
425
+ </div>
426
+ ) : (
427
+ <Button onClick={() => setCreateArmed(true)} disabled={creating}>
428
+ Create inbox
429
+ </Button>
430
+ )}
431
+ </CardContent>
432
+ </Card>
433
+ ) : view === "compose" ? (
434
+ /* ── Compose ────────────────────────────────────────────────── */
435
+ <Card>
436
+ <CardHeader className="flex flex-row items-center justify-between gap-2 pb-2">
437
+ <CardTitle className="text-sm font-semibold">New email</CardTitle>
438
+ <button
439
+ type="button"
440
+ onClick={() => setView("list")}
441
+ className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
442
+ >
443
+ <ArrowLeft className="h-4 w-4" /> Back
444
+ </button>
445
+ </CardHeader>
446
+ <CardContent className="space-y-3">
447
+ <div className="space-y-1">
448
+ <label className="text-xs text-muted-foreground">To</label>
449
+ <input
450
+ value={to}
451
+ onChange={(e) => setTo(e.target.value)}
452
+ placeholder="alice@example.com, bob@example.com"
453
+ className={inputCls}
454
+ />
455
+ </div>
456
+ <div className="grid gap-3 sm:grid-cols-2">
457
+ <div className="space-y-1">
458
+ <label className="text-xs text-muted-foreground">Cc (optional)</label>
459
+ <input value={cc} onChange={(e) => setCc(e.target.value)} className={inputCls} />
460
+ </div>
461
+ <div className="space-y-1">
462
+ <label className="text-xs text-muted-foreground">Bcc (optional)</label>
463
+ <input value={bcc} onChange={(e) => setBcc(e.target.value)} className={inputCls} />
464
+ </div>
465
+ </div>
466
+ <div className="space-y-1">
467
+ <label className="text-xs text-muted-foreground">Subject</label>
468
+ <input
469
+ value={subject}
470
+ onChange={(e) => setSubject(e.target.value)}
471
+ className={inputCls}
472
+ />
473
+ </div>
474
+ <div className="space-y-1">
475
+ <label className="text-xs text-muted-foreground">Message</label>
476
+ <textarea
477
+ value={bodyText}
478
+ onChange={(e) => setBodyText(e.target.value)}
479
+ rows={10}
480
+ className={inputCls + " resize-y font-sans"}
481
+ />
482
+ </div>
483
+ <div className="space-y-1">
484
+ <label className="text-xs text-muted-foreground">Reply-To (optional)</label>
485
+ <input
486
+ value={replyTo}
487
+ onChange={(e) => setReplyTo(e.target.value)}
488
+ className={inputCls}
489
+ />
490
+ </div>
491
+
492
+ {sendError && <p className="text-sm text-destructive">{sendError}</p>}
493
+
494
+ {sendArmed ? (
495
+ <div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
496
+ <span className="text-sm text-amber-200">
497
+ Send a real email from{" "}
498
+ <span className="font-mono">{inbox?.emailAddress}</span> to{" "}
499
+ {recipients.length} recipient{recipients.length === 1 ? "" : "s"}? Any per-send
500
+ fee is paid in USDC from the agent wallet.
501
+ </span>
502
+ <div className="ml-auto flex gap-2">
503
+ <button
504
+ type="button"
505
+ onClick={() => setSendArmed(false)}
506
+ className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
507
+ >
508
+ Cancel
509
+ </button>
510
+ <Button
511
+ size="sm"
512
+ prefix={<Send className="h-4 w-4" />}
513
+ onClick={sendMail}
514
+ disabled={sending}
515
+ >
516
+ {sending ? "Sending…" : "Send now"}
517
+ </Button>
518
+ </div>
519
+ </div>
520
+ ) : (
521
+ <Button
522
+ prefix={<Send className="h-4 w-4" />}
523
+ onClick={() => {
524
+ setSendError(null);
525
+ setSendArmed(true);
526
+ }}
527
+ disabled={sending}
528
+ >
529
+ Send
530
+ </Button>
531
+ )}
532
+ </CardContent>
533
+ </Card>
534
+ ) : view === "read" && selected ? (
535
+ /* ── Read one message ───────────────────────────────────────── */
536
+ <Card>
537
+ <CardHeader className="space-y-2 pb-2">
538
+ <button
539
+ type="button"
540
+ onClick={() => setView("list")}
541
+ className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
542
+ >
543
+ <ArrowLeft className="h-4 w-4" /> Back to inbox
544
+ </button>
545
+ <CardTitle className="text-base font-semibold">
546
+ {selected.subject || "(no subject)"}
547
+ </CardTitle>
548
+ <div className="space-y-0.5 text-xs text-muted-foreground">
549
+ <div>
550
+ <Badge
551
+ tone={selected.direction === "inbound" ? "secondary" : "success"}
552
+ className="mr-2"
553
+ >
554
+ {selected.direction === "inbound" ? "received" : "sent"}
555
+ </Badge>
556
+ {formatDate(selected.agentmailCreatedAt || selected.createdAt)}
557
+ </div>
558
+ {selected.fromAddress && (
559
+ <div>
560
+ <span className="text-foreground/70">From:</span> {selected.fromAddress}
561
+ </div>
562
+ )}
563
+ {selected.toAddresses?.length > 0 && (
564
+ <div>
565
+ <span className="text-foreground/70">To:</span> {selected.toAddresses.join(", ")}
566
+ </div>
567
+ )}
568
+ {selected.ccAddresses?.length > 0 && (
569
+ <div>
570
+ <span className="text-foreground/70">Cc:</span> {selected.ccAddresses.join(", ")}
571
+ </div>
572
+ )}
573
+ </div>
574
+ </CardHeader>
575
+ <CardContent>
576
+ {selectedLoading ? (
577
+ <div className="flex justify-center py-8">
578
+ <Spinner />
579
+ </div>
580
+ ) : selected.textBody ? (
581
+ <pre className="whitespace-pre-wrap break-words font-sans text-sm text-foreground/90">
582
+ {selected.textBody}
583
+ </pre>
584
+ ) : selected.htmlBody ? (
585
+ <iframe
586
+ title="email body"
587
+ sandbox=""
588
+ srcDoc={selected.htmlBody}
589
+ className="h-[60vh] w-full rounded-md border border-border bg-white"
590
+ />
591
+ ) : (
592
+ <p className="text-sm text-muted-foreground">{selected.preview || "(empty message)"}</p>
593
+ )}
594
+ </CardContent>
595
+ </Card>
596
+ ) : (
597
+ /* ── Inbox list ─────────────────────────────────────────────── */
598
+ <div className="space-y-3">
599
+ <div className="flex gap-1">
600
+ {(["all", "inbound", "outbound"] as Filter[]).map((f) => (
601
+ <button
602
+ key={f}
603
+ type="button"
604
+ onClick={() => setFilter(f)}
605
+ className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
606
+ filter === f
607
+ ? "bg-muted font-medium text-foreground"
608
+ : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
609
+ }`}
610
+ >
611
+ {f === "all" ? "All" : f === "inbound" ? "Inbox" : "Sent"}
612
+ </button>
613
+ ))}
614
+ </div>
615
+
616
+ {messagesError && (
617
+ <Card className="border-destructive/40">
618
+ <CardContent className="py-3 text-sm text-destructive">{messagesError}</CardContent>
619
+ </Card>
620
+ )}
621
+
622
+ {messagesLoading ? (
623
+ <div className="flex justify-center py-12">
624
+ <Spinner />
625
+ </div>
626
+ ) : messages.length === 0 ? (
627
+ <Card>
628
+ <CardContent className="py-8 text-center text-sm text-muted-foreground">
629
+ No messages yet.
630
+ </CardContent>
631
+ </Card>
632
+ ) : (
633
+ <div className="space-y-2">
634
+ {messages.map((m) => {
635
+ const who =
636
+ m.direction === "inbound"
637
+ ? m.fromAddress || "unknown sender"
638
+ : `To: ${m.toAddresses?.join(", ") || "—"}`;
639
+ return (
640
+ <button
641
+ key={m.id}
642
+ type="button"
643
+ onClick={() => openMessage(m)}
644
+ className="w-full rounded-lg border border-border bg-background p-3 text-left transition-colors hover:border-primary/50 hover:bg-muted/30"
645
+ >
646
+ <div className="flex items-center justify-between gap-2">
647
+ <div className="flex min-w-0 items-center gap-2">
648
+ {m.direction === "inbound" ? (
649
+ <Inbox className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
650
+ ) : (
651
+ <Send className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
652
+ )}
653
+ <span
654
+ className={`truncate text-sm ${
655
+ m.direction === "inbound" && !m.read
656
+ ? "font-semibold text-foreground"
657
+ : "text-foreground/80"
658
+ }`}
659
+ >
660
+ {who}
661
+ </span>
662
+ </div>
663
+ <span className="shrink-0 text-xs text-muted-foreground">
664
+ {formatDate(m.agentmailCreatedAt || m.createdAt)}
665
+ </span>
666
+ </div>
667
+ <div className="mt-1 truncate text-sm text-foreground/90">
668
+ {m.subject || "(no subject)"}
669
+ </div>
670
+ {m.preview && (
671
+ <div className="mt-0.5 truncate text-xs text-muted-foreground">
672
+ {m.preview}
673
+ </div>
674
+ )}
675
+ </button>
676
+ );
677
+ })}
678
+ </div>
679
+ )}
680
+ </div>
681
+ )}
682
+ </div>
683
+ );
684
+ }