@dbx-tools/ui 0.0.0-stage → 0.9.61

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.
@@ -0,0 +1,92 @@
1
+ import { brandUtils } from "@dbx-tools/shared-core";
2
+ import {
3
+ createContext,
4
+ type ImgHTMLAttributes,
5
+ type PropsWithChildren,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ } from "react";
10
+ import { applyBrandContext, type BrandAssetResolver, resolveBrandAsset } from "../browser.ts";
11
+
12
+ interface BrandState {
13
+ context: brandUtils.BrandContext;
14
+ resolveAsset: BrandAssetResolver;
15
+ }
16
+
17
+ const BrandReactContext = createContext<BrandState>({
18
+ context: brandUtils.defaultBrandContext,
19
+ resolveAsset: resolveBrandAsset,
20
+ });
21
+
22
+ /** Brand input, asset resolver, and document-application behavior for {@link BrandProvider}. */
23
+ export interface BrandProviderProps extends PropsWithChildren {
24
+ context?: brandUtils.BrandContextInput;
25
+ resolveAsset?: BrandAssetResolver;
26
+ applyToDocument?: boolean;
27
+ }
28
+
29
+ /** Provide a validated brand context to descendant React components. */
30
+ export function BrandProvider({
31
+ children,
32
+ context,
33
+ resolveAsset = resolveBrandAsset,
34
+ applyToDocument = false,
35
+ }: BrandProviderProps) {
36
+ const parsed = useMemo(() => brandUtils.parseBrandContext(context), [context]);
37
+ const value = useMemo(() => ({ context: parsed, resolveAsset }), [parsed, resolveAsset]);
38
+
39
+ useEffect(() => {
40
+ if (applyToDocument) applyBrandContext(parsed, { resolveAsset });
41
+ }, [applyToDocument, parsed, resolveAsset]);
42
+
43
+ return <BrandReactContext.Provider value={value}>{children}</BrandReactContext.Provider>;
44
+ }
45
+
46
+ /** Read the active validated brand context and asset resolver. */
47
+ export function useBrand(): BrandState {
48
+ return useContext(BrandReactContext);
49
+ }
50
+
51
+ /** Image attributes and light/dark selection used by branded icons and logos. */
52
+ export interface BrandImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt"> {
53
+ alt?: string;
54
+ mode?: "auto" | "light" | "dark";
55
+ }
56
+
57
+ function BrandImage({
58
+ asset,
59
+ alt,
60
+ mode = "auto",
61
+ ...props
62
+ }: BrandImageProps & { asset: brandUtils.BrandAssetSet }) {
63
+ const { resolveAsset } = useBrand();
64
+ const light = resolveAsset(asset.light);
65
+ const dark = resolveAsset(asset.dark ?? asset.light);
66
+
67
+ if (mode !== "auto") {
68
+ return <img src={mode === "dark" ? dark : light} alt={alt ?? ""} {...props} />;
69
+ }
70
+ return (
71
+ <picture>
72
+ <source media="(prefers-color-scheme: dark)" srcSet={dark} />
73
+ <img src={light} alt={alt ?? ""} {...props} />
74
+ </picture>
75
+ );
76
+ }
77
+
78
+ /** Render the active brand icon with automatic light and dark assets. */
79
+ export function BrandIcon(props: BrandImageProps) {
80
+ const { context } = useBrand();
81
+ const { alt, ...imageProps } = props;
82
+ return (
83
+ <BrandImage asset={context.assets.icon} alt={alt ?? `${context.name} icon`} {...imageProps} />
84
+ );
85
+ }
86
+
87
+ /** Render the active brand logo with automatic light and dark assets. */
88
+ export function BrandLogo(props: BrandImageProps) {
89
+ const { context } = useBrand();
90
+ const { alt, ...imageProps } = props;
91
+ return <BrandImage asset={context.assets.logo} alt={alt ?? context.name} {...imageProps} />;
92
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ BrandIcon,
3
+ BrandLogo,
4
+ BrandProvider,
5
+ useBrand,
6
+ type BrandImageProps,
7
+ type BrandProviderProps,
8
+ } from "./brand.tsx";
9
+ export type { BrandContext, BrandContextInput } from "@dbx-tools/shared-core";
@@ -0,0 +1,12 @@
1
+ :root {
2
+ --brand-color-primary: #1b3139;
3
+ --brand-color-primary-hover: #0e538b;
4
+ --brand-color-accent: #00a972;
5
+ --brand-color-foreground: #1b3139;
6
+ --brand-color-background: #ffffff;
7
+ --brand-color-surface: #f9f7f4;
8
+ --brand-color-muted: #618794;
9
+ --brand-color-border: #e4e2dd;
10
+ --brand-font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
11
+ --brand-font-mono: "DM Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
12
+ }
@@ -0,0 +1,169 @@
1
+ import {
2
+ EmailCard,
3
+ emailBrandFromContext,
4
+ resolveEmailBrand,
5
+ type EmailBrand,
6
+ } from "@dbx-tools/shared-email-template";
7
+ import { CheckIcon, MailIcon, PaperclipIcon, XIcon } from "lucide-react";
8
+
9
+ import { attachmentNames, joinAddresses, type EmailDraft } from "./fields.ts";
10
+ import { useBrand } from "../../branding/react/index.ts";
11
+ import { Button } from "../../react/index.ts";
12
+
13
+ // Presentational pieces for an outbound email awaiting a human Approve /
14
+ // Deny: a mail-client chrome (sender, recipients, subject, attachments)
15
+ // around the shared React Email card, plus an approval card wrapping it.
16
+ // State and the resolve transport belong to the caller; these components
17
+ // only render and report intent. The editable counterpart is
18
+ // `EmailComposeView` in `./email-compose`; both share `./fields` and
19
+ // `./email-body`.
20
+
21
+ export type { EmailDraft } from "./fields.ts";
22
+
23
+ /** Props for {@link EmailPreview}. */
24
+ export interface EmailPreviewProps {
25
+ email: EmailDraft;
26
+ /** Optional email-only brand. Defaults to the active `BrandProvider` context. */
27
+ brand?: EmailBrand;
28
+ }
29
+
30
+ /** Initials for the sender avatar when no logo is available. */
31
+ function senderInitials(name: string): string {
32
+ const parts = name.trim().split(/\s+/).filter(Boolean);
33
+ if (parts.length === 0) return "?";
34
+ if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
35
+ return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
36
+ }
37
+
38
+ /** One muted "Label: value" row in the client chrome. */
39
+ const ChromeRow = ({ label, value }: { label: string; value: string }) => (
40
+ <p className="truncate text-xs text-muted-foreground">
41
+ <span className="font-medium text-foreground/70">{label}</span> {value}
42
+ </p>
43
+ );
44
+
45
+ /**
46
+ * Render an email draft the way a mail client shows a received message: sender
47
+ * identity, recipients, subject, and attachment chips in chrome around the
48
+ * same branded React Email card used for delivery. Envelope fields stay out of
49
+ * the delivered HTML — they are transport metadata, not body content.
50
+ */
51
+ export const EmailPreview = ({ email, brand }: EmailPreviewProps) => {
52
+ const { context } = useBrand();
53
+ const theme = resolveEmailBrand(brand ?? emailBrandFromContext(context));
54
+ const to = joinAddresses(email.to);
55
+ const cc = joinAddresses(email.cc);
56
+ const bcc = joinAddresses(email.bcc);
57
+ const subject = email.subject?.trim() || "Message";
58
+ const files = email.attachments?.map((att) => att.filename).filter(Boolean) ?? [];
59
+ const attachmentSummary = attachmentNames(email.attachments);
60
+
61
+ return (
62
+ <div className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
63
+ <div className="space-y-3 border-b border-border bg-background px-4 py-3">
64
+ <div className="flex items-start gap-3">
65
+ <div
66
+ className="flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-semibold"
67
+ style={{ backgroundColor: theme.accent, color: theme.onAccent }}
68
+ aria-hidden
69
+ >
70
+ {senderInitials(theme.name)}
71
+ </div>
72
+ <div className="min-w-0 flex-1 space-y-1">
73
+ <div className="flex items-baseline justify-between gap-3">
74
+ <div className="min-w-0">
75
+ <p className="truncate text-sm font-semibold text-foreground">{theme.name}</p>
76
+ {theme.website ? (
77
+ <p className="truncate text-xs text-muted-foreground">{theme.website}</p>
78
+ ) : null}
79
+ </div>
80
+ <time className="shrink-0 text-xs text-muted-foreground">Just now</time>
81
+ </div>
82
+ {to ? <ChromeRow label="To" value={to} /> : null}
83
+ {cc ? <ChromeRow label="Cc" value={cc} /> : null}
84
+ {bcc ? <ChromeRow label="Bcc" value={bcc} /> : null}
85
+ <p className="pt-1 text-sm font-medium leading-snug text-foreground">{subject}</p>
86
+ </div>
87
+ </div>
88
+ {files.length > 0 ? (
89
+ <ul className="flex flex-wrap gap-1.5" aria-label={`Attachments: ${attachmentSummary}`}>
90
+ {files.map((filename) => (
91
+ <li
92
+ key={filename}
93
+ className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-muted/40 px-2 py-1 text-xs text-foreground"
94
+ >
95
+ <PaperclipIcon className="size-3 shrink-0 text-muted-foreground" />
96
+ <span className="truncate">{filename}</span>
97
+ </li>
98
+ ))}
99
+ </ul>
100
+ ) : null}
101
+ </div>
102
+ <div style={{ backgroundColor: theme.surface }}>
103
+ <EmailCard subject={subject} body={email.body || ""} brand={theme} framed={false} />
104
+ </div>
105
+ </div>
106
+ );
107
+ };
108
+
109
+ /** Props for {@link EmailApprovalCard}. */
110
+ export interface EmailApprovalCardProps {
111
+ email: EmailDraft;
112
+ /** Called when the user approves the send. */
113
+ onApprove?: () => void | Promise<void>;
114
+ /** Called when the user denies the send. */
115
+ onDeny?: () => void | Promise<void>;
116
+ /** Disable both actions while a decision is in flight. */
117
+ pending?: boolean;
118
+ /** Disable both actions regardless of pending state. */
119
+ disabled?: boolean;
120
+ /** Header label. Defaults to "Approval needed: send email". */
121
+ title?: string;
122
+ }
123
+
124
+ /**
125
+ * A drop-in approval card for the `send_email` tool: the email preview
126
+ * plus Approve / Deny actions. Wire `onApprove` / `onDeny` to whatever
127
+ * resumes the suspended tool call (e.g. the AI-SDK `addToolResult`).
128
+ */
129
+ export const EmailApprovalCard = ({
130
+ email,
131
+ onApprove,
132
+ onDeny,
133
+ pending,
134
+ disabled,
135
+ title = "Approval needed: send email",
136
+ }: EmailApprovalCardProps) => {
137
+ const blocked = Boolean(disabled) || Boolean(pending);
138
+ return (
139
+ <div className="not-prose my-2 rounded-md border border-warning/40 bg-warning/5 p-3">
140
+ <div className="mb-2 flex items-center gap-2 text-xs font-medium text-warning">
141
+ <MailIcon className="size-3.5" />
142
+ <span>{title}</span>
143
+ </div>
144
+ <EmailPreview email={email} />
145
+ <div className="mt-3 flex items-center gap-2">
146
+ <Button
147
+ type="button"
148
+ size="sm"
149
+ variant="default"
150
+ disabled={blocked || !onApprove}
151
+ onClick={() => onApprove?.()}
152
+ >
153
+ <CheckIcon className="size-3" />
154
+ Approve
155
+ </Button>
156
+ <Button
157
+ type="button"
158
+ size="sm"
159
+ variant="outline"
160
+ disabled={blocked || !onDeny}
161
+ onClick={() => onDeny?.()}
162
+ >
163
+ <XIcon className="size-3" />
164
+ Deny
165
+ </Button>
166
+ </div>
167
+ </div>
168
+ );
169
+ };
@@ -0,0 +1,23 @@
1
+ // Compact, muted Markdown renderer for an email body. Shared by the
2
+ // read-only approval preview and the compose view's live preview so both
3
+ // render the drafted Markdown identically (links, lists, emphasis, and
4
+ // tables rather than raw syntax).
5
+
6
+ import { EmailBody as SharedEmailBody, type EmailBrand } from "@dbx-tools/shared-email-template";
7
+ import { cn } from "@dbx-tools/ui/react";
8
+
9
+ /** Props for {@link EmailBody}. */
10
+ export interface EmailBodyProps {
11
+ children: string;
12
+ /** Extra classes merged onto the prose container. */
13
+ className?: string;
14
+ /** Optional brand override; dbx-tools branding is the default. */
15
+ brand?: EmailBrand;
16
+ }
17
+
18
+ /** Render an email body with the same React Email component used for delivery. */
19
+ export const EmailBody = ({ children, className, brand }: EmailBodyProps) => (
20
+ <div className={cn("max-w-none break-words", className)}>
21
+ <SharedEmailBody body={children} brand={brand} />
22
+ </div>
23
+ );
@@ -0,0 +1,346 @@
1
+ import type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email";
2
+ import { EyeIcon, PaperclipIcon, PencilIcon, SendIcon, XIcon } from "lucide-react";
3
+ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
4
+
5
+ import { EmailPreview } from "./email-approval-card.tsx";
6
+ import { joinAddresses, parseAddresses, type EmailDraft } from "./fields.ts";
7
+ import {
8
+ Button,
9
+ Input,
10
+ Label,
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ Textarea,
17
+ cn,
18
+ } from "../../react/index.ts";
19
+
20
+ // A standard, editable email compose form usable outside a chat bubble
21
+ // (a settings page, a standalone "send" view, etc.). It shares the
22
+ // address / attachment helpers (`./fields`) and the React Email body
23
+ // renderer (`./email-body`) with the read-only `EmailPreview`, so the
24
+ // two surfaces stay visually and semantically in sync.
25
+ //
26
+ // The component is presentational and self-contained: it owns the draft
27
+ // as local state (seeded from `defaultValue`), emits changes via
28
+ // `onChange`, and hands the assembled `EmailMessage` (plus the chosen
29
+ // `From`, when a sender list is provided) to `onSend`. Wiring the actual
30
+ // dispatch - and fetching the `senders` list from the plugin's
31
+ // `GET /senders` route - is the caller's job.
32
+
33
+ /** Props for {@link EmailComposeView}. */
34
+ export interface EmailComposeProps {
35
+ /** Initial draft to seed the form (uncontrolled thereafter). */
36
+ defaultValue?: EmailDraft;
37
+ /** Called on every edit with the current assembled message. */
38
+ onChange?: (message: EmailMessage) => void;
39
+ /** Called when the user submits; receives the message and chosen `From`. */
40
+ onSend?: (message: EmailMessage, from?: string) => void | Promise<void>;
41
+ /** Called when the user cancels. Omit to hide the Cancel button. */
42
+ onCancel?: () => void;
43
+ /**
44
+ * Permitted `From` addresses (e.g. from the plugin's `GET /senders`).
45
+ * When non-empty a `From` dropdown is shown and its value is passed to
46
+ * `onSend`; omit for a server-resolved sender (no dropdown).
47
+ */
48
+ senders?: string[];
49
+ /** Preselected `From`. Defaults to the first {@link senders} entry. */
50
+ defaultFrom?: string;
51
+ /** Disable actions while a send is in flight. */
52
+ pending?: boolean;
53
+ /** Disable all actions regardless of pending state. */
54
+ disabled?: boolean;
55
+ /** Show the attachment control. Defaults to `true`. */
56
+ allowAttachments?: boolean;
57
+ /** Card header label. Defaults to "New message". */
58
+ title?: string;
59
+ /** Submit button label. Defaults to "Send". */
60
+ sendLabel?: string;
61
+ }
62
+
63
+ /** Read a `File` into a base64 {@link EmailAttachment}. */
64
+ async function fileToAttachment(file: File): Promise<EmailAttachment> {
65
+ const dataUrl = await new Promise<string>((resolve, reject) => {
66
+ const reader = new FileReader();
67
+ reader.onload = () => resolve(String(reader.result));
68
+ reader.onerror = () => reject(reader.error);
69
+ reader.readAsDataURL(file);
70
+ });
71
+ // A data URL is `data:<mime>;base64,<payload>`; keep only the payload.
72
+ const content = dataUrl.slice(dataUrl.indexOf(",") + 1);
73
+ return {
74
+ filename: file.name,
75
+ content,
76
+ encoding: "base64",
77
+ ...(file.type ? { contentType: file.type } : {}),
78
+ };
79
+ }
80
+
81
+ /** A labelled field row: label above its control. */
82
+ const Field = ({
83
+ label,
84
+ htmlFor,
85
+ children,
86
+ }: {
87
+ label: string;
88
+ htmlFor?: string;
89
+ children: ReactNode;
90
+ }) => (
91
+ <div className="grid gap-1.5">
92
+ <Label htmlFor={htmlFor} className="text-xs text-muted-foreground">
93
+ {label}
94
+ </Label>
95
+ {children}
96
+ </div>
97
+ );
98
+
99
+ /**
100
+ * A standard, editable email compose form. Reuses the shared field and
101
+ * body helpers so a drafted message renders the same here as in the
102
+ * read-only approval preview.
103
+ */
104
+ export const EmailComposeView = ({
105
+ defaultValue,
106
+ onChange,
107
+ onSend,
108
+ onCancel,
109
+ senders,
110
+ defaultFrom,
111
+ pending,
112
+ disabled,
113
+ allowAttachments = true,
114
+ title = "New message",
115
+ sendLabel = "Send",
116
+ }: EmailComposeProps) => {
117
+ const [to, setTo] = useState(joinAddresses(defaultValue?.to));
118
+ const [cc, setCc] = useState(joinAddresses(defaultValue?.cc));
119
+ const [bcc, setBcc] = useState(joinAddresses(defaultValue?.bcc));
120
+ const [subject, setSubject] = useState(defaultValue?.subject ?? "");
121
+ const [body, setBody] = useState(defaultValue?.body ?? "");
122
+ const [attachments, setAttachments] = useState<EmailAttachment[]>(
123
+ defaultValue?.attachments ?? [],
124
+ );
125
+ const [from, setFrom] = useState(defaultFrom ?? senders?.[0] ?? "");
126
+ const [showPreview, setShowPreview] = useState(false);
127
+
128
+ const fileInput = useRef<HTMLInputElement>(null);
129
+ const onChangeRef = useRef(onChange);
130
+ onChangeRef.current = onChange;
131
+ const ids = useId();
132
+ const fieldId = (name: string) => `${ids}-${name}`;
133
+
134
+ const hasSenderPicker = Boolean(senders && senders.length > 0);
135
+ const blocked = Boolean(disabled) || Boolean(pending);
136
+ const recipients = parseAddresses(to);
137
+ const canSend = recipients.length > 0 && Boolean(onSend) && !blocked;
138
+
139
+ // Assemble the current draft into a wire-format message, omitting empty
140
+ // optional fields so the payload stays minimal.
141
+ const buildMessage = useCallback((): EmailMessage => {
142
+ const ccList = parseAddresses(cc);
143
+ const bccList = parseAddresses(bcc);
144
+ return {
145
+ to: parseAddresses(to),
146
+ subject,
147
+ body,
148
+ ...(ccList.length ? { cc: ccList } : {}),
149
+ ...(bccList.length ? { bcc: bccList } : {}),
150
+ ...(attachments.length ? { attachments } : {}),
151
+ };
152
+ }, [to, cc, bcc, subject, body, attachments]);
153
+
154
+ useEffect(() => {
155
+ onChangeRef.current?.(buildMessage());
156
+ }, [buildMessage]);
157
+
158
+ const addFiles = useCallback(async (files: FileList | null) => {
159
+ if (!files || files.length === 0) return;
160
+ const read = await Promise.all([...files].map(fileToAttachment));
161
+ setAttachments((prev) => [...prev, ...read]);
162
+ }, []);
163
+
164
+ const removeAttachment = useCallback((index: number) => {
165
+ setAttachments((prev) => prev.filter((_, i) => i !== index));
166
+ }, []);
167
+
168
+ const submit = useCallback(() => {
169
+ if (!canSend) return;
170
+ void onSend?.(buildMessage(), hasSenderPicker ? from : undefined);
171
+ }, [canSend, onSend, buildMessage, hasSenderPicker, from]);
172
+
173
+ return (
174
+ <div className="not-prose flex flex-col gap-3 rounded-md border border-border bg-card p-4 text-sm">
175
+ <div className="text-sm font-medium">{title}</div>
176
+
177
+ {hasSenderPicker && (
178
+ <Field label="From" htmlFor={fieldId("from")}>
179
+ <Select value={from} onValueChange={setFrom} disabled={blocked}>
180
+ <SelectTrigger id={fieldId("from")} className="h-8">
181
+ <SelectValue placeholder="Select a sender" />
182
+ </SelectTrigger>
183
+ <SelectContent>
184
+ {senders?.map((address) => (
185
+ <SelectItem key={address} value={address}>
186
+ {address}
187
+ </SelectItem>
188
+ ))}
189
+ </SelectContent>
190
+ </Select>
191
+ </Field>
192
+ )}
193
+
194
+ <Field label="To" htmlFor={fieldId("to")}>
195
+ <Input
196
+ id={fieldId("to")}
197
+ value={to}
198
+ onChange={(e) => setTo(e.target.value)}
199
+ placeholder="alice@example.com, bob@example.com"
200
+ disabled={blocked}
201
+ className="h-8"
202
+ />
203
+ </Field>
204
+
205
+ <div className="grid gap-3 sm:grid-cols-2">
206
+ <Field label="Cc" htmlFor={fieldId("cc")}>
207
+ <Input
208
+ id={fieldId("cc")}
209
+ value={cc}
210
+ onChange={(e) => setCc(e.target.value)}
211
+ placeholder="Optional"
212
+ disabled={blocked}
213
+ className="h-8"
214
+ />
215
+ </Field>
216
+ <Field label="Bcc" htmlFor={fieldId("bcc")}>
217
+ <Input
218
+ id={fieldId("bcc")}
219
+ value={bcc}
220
+ onChange={(e) => setBcc(e.target.value)}
221
+ placeholder="Optional"
222
+ disabled={blocked}
223
+ className="h-8"
224
+ />
225
+ </Field>
226
+ </div>
227
+
228
+ <Field label="Subject" htmlFor={fieldId("subject")}>
229
+ <Input
230
+ id={fieldId("subject")}
231
+ value={subject}
232
+ onChange={(e) => setSubject(e.target.value)}
233
+ placeholder="Subject"
234
+ disabled={blocked}
235
+ className="h-8"
236
+ />
237
+ </Field>
238
+
239
+ <div className="grid gap-1.5">
240
+ <div className="flex items-center justify-between">
241
+ <Label htmlFor={fieldId("body")} className="text-xs text-muted-foreground">
242
+ Body
243
+ </Label>
244
+ <Button
245
+ type="button"
246
+ size="sm"
247
+ variant="ghost"
248
+ className="h-6 px-2 text-xs"
249
+ disabled={!body}
250
+ onClick={() => setShowPreview((v) => !v)}
251
+ >
252
+ {showPreview ? (
253
+ <>
254
+ <PencilIcon className="size-3" /> Edit
255
+ </>
256
+ ) : (
257
+ <>
258
+ <EyeIcon className="size-3" /> Preview
259
+ </>
260
+ )}
261
+ </Button>
262
+ </div>
263
+ {showPreview ? (
264
+ <EmailPreview email={{ ...buildMessage(), body: body || "_Nothing to preview yet._" }} />
265
+ ) : (
266
+ <Textarea
267
+ id={fieldId("body")}
268
+ value={body}
269
+ onChange={(e) => setBody(e.target.value)}
270
+ placeholder="Write your message..."
271
+ disabled={blocked}
272
+ className="min-h-32 font-mono text-xs"
273
+ />
274
+ )}
275
+ </div>
276
+
277
+ {allowAttachments && (
278
+ <div className="grid gap-1.5">
279
+ <div className="flex items-center gap-2">
280
+ <input
281
+ ref={fileInput}
282
+ type="file"
283
+ multiple
284
+ hidden
285
+ onChange={(e) => {
286
+ void addFiles(e.target.files);
287
+ e.target.value = "";
288
+ }}
289
+ />
290
+ <Button
291
+ type="button"
292
+ size="sm"
293
+ variant="outline"
294
+ className="h-7"
295
+ disabled={blocked}
296
+ onClick={() => fileInput.current?.click()}
297
+ >
298
+ <PaperclipIcon className="size-3" /> Attach files
299
+ </Button>
300
+ </div>
301
+ {attachments.length > 0 && (
302
+ <ul className="flex flex-wrap gap-1.5">
303
+ {attachments.map((att, index) => (
304
+ <li
305
+ key={`${att.filename}-${index}`}
306
+ className="flex items-center gap-1 rounded bg-muted px-2 py-1 text-xs"
307
+ >
308
+ <span className="max-w-40 truncate">{att.filename}</span>
309
+ <button
310
+ type="button"
311
+ aria-label={`Remove ${att.filename}`}
312
+ className={cn(
313
+ "text-muted-foreground hover:text-foreground",
314
+ blocked && "pointer-events-none opacity-50",
315
+ )}
316
+ onClick={() => removeAttachment(index)}
317
+ >
318
+ <XIcon className="size-3" />
319
+ </button>
320
+ </li>
321
+ ))}
322
+ </ul>
323
+ )}
324
+ </div>
325
+ )}
326
+
327
+ <div className="mt-1 flex items-center gap-2">
328
+ <Button type="button" size="sm" disabled={!canSend} onClick={submit}>
329
+ <SendIcon className="size-3" />
330
+ {pending ? "Sending..." : sendLabel}
331
+ </Button>
332
+ {onCancel && (
333
+ <Button
334
+ type="button"
335
+ size="sm"
336
+ variant="outline"
337
+ disabled={blocked}
338
+ onClick={() => onCancel()}
339
+ >
340
+ Cancel
341
+ </Button>
342
+ )}
343
+ </div>
344
+ </div>
345
+ );
346
+ };
@@ -0,0 +1,33 @@
1
+ // Shared, presentational helpers for the email field UIs (the read-only
2
+ // approval preview and the editable compose view). Keeping the address
3
+ // and attachment formatting here lets both surfaces agree on how a draft
4
+ // is displayed and how free-text address input is normalized.
5
+
6
+ import { net } from "@dbx-tools/shared-core";
7
+ import type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email";
8
+
9
+ /** A partially-filled email, as it streams in from a tool call or a form. */
10
+ export type EmailDraft = Partial<EmailMessage>;
11
+
12
+ /** Render an address array as a single comma-separated display string. */
13
+ export const joinAddresses = (addresses: string[] | undefined): string =>
14
+ (addresses ?? [])
15
+ .map((a) => a.trim())
16
+ .filter(Boolean)
17
+ .join(", ");
18
+
19
+ /**
20
+ * Split a free-text address field (comma / semicolon / whitespace
21
+ * separated) into a trimmed, de-duplicated address array - the inverse
22
+ * of {@link joinAddresses} for compose inputs. Delegates to the shared
23
+ * {@link net.parseEmails} so the UI reads addresses the same way the
24
+ * server does.
25
+ */
26
+ export const parseAddresses = (raw: string): string[] => net.parseEmails(raw);
27
+
28
+ /** Comma-separated list of attachment filenames for a compact display. */
29
+ export const attachmentNames = (attachments: EmailAttachment[] | undefined): string =>
30
+ (attachments ?? [])
31
+ .map((a) => a.filename)
32
+ .filter(Boolean)
33
+ .join(", ");