@dbx-tools/ui-mastra 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.
@@ -0,0 +1,139 @@
1
+ import {
2
+ Button,
3
+ Label,
4
+ Popover,
5
+ PopoverContent,
6
+ PopoverTrigger,
7
+ Textarea,
8
+ Tooltip,
9
+ TooltipContent,
10
+ TooltipTrigger,
11
+ cn,
12
+ } from "@dbx-tools/ui-appkit/react";
13
+ import { MessageSquareTextIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
14
+ import { useState } from "react";
15
+ import type { FeedbackSubmission, FeedbackValue } from "./types";
16
+
17
+ // Per-message feedback action row: thumbs up/down that log immediately,
18
+ // plus a separate comment affordance that opens a popover for freeform
19
+ // text. Rendered inside the assistant bubble's action row when the host
20
+ // wires feedback (which only happens when MLflow logging is enabled and
21
+ // the turn has a captured trace id).
22
+
23
+ export const FeedbackControls = ({
24
+ value,
25
+ onSubmit,
26
+ }: {
27
+ /** Last thumbs the user chose, so the active button stays highlighted. */
28
+ value?: FeedbackValue;
29
+ onSubmit: (submission: FeedbackSubmission) => void | Promise<void>;
30
+ }) => {
31
+ const [open, setOpen] = useState(false);
32
+ const [comment, setComment] = useState("");
33
+ const [sending, setSending] = useState(false);
34
+
35
+ const submitComment = async () => {
36
+ const text = comment.trim();
37
+ if (!text || sending) return;
38
+ setSending(true);
39
+ try {
40
+ await onSubmit({ comment: text });
41
+ setComment("");
42
+ setOpen(false);
43
+ } finally {
44
+ setSending(false);
45
+ }
46
+ };
47
+
48
+ return (
49
+ <>
50
+ <Tooltip>
51
+ <TooltipTrigger asChild>
52
+ <Button
53
+ type="button"
54
+ size="icon"
55
+ variant="ghost"
56
+ className={cn("size-7", value === "up" && "text-success")}
57
+ aria-label="Good response"
58
+ aria-pressed={value === "up"}
59
+ onClick={() => void onSubmit({ value: "up" })}
60
+ >
61
+ <ThumbsUpIcon className="size-3" />
62
+ </Button>
63
+ </TooltipTrigger>
64
+ <TooltipContent>Good response</TooltipContent>
65
+ </Tooltip>
66
+ <Tooltip>
67
+ <TooltipTrigger asChild>
68
+ <Button
69
+ type="button"
70
+ size="icon"
71
+ variant="ghost"
72
+ className={cn("size-7", value === "down" && "text-destructive")}
73
+ aria-label="Bad response"
74
+ aria-pressed={value === "down"}
75
+ onClick={() => void onSubmit({ value: "down" })}
76
+ >
77
+ <ThumbsDownIcon className="size-3" />
78
+ </Button>
79
+ </TooltipTrigger>
80
+ <TooltipContent>Bad response</TooltipContent>
81
+ </Tooltip>
82
+ <Popover open={open} onOpenChange={setOpen}>
83
+ <Tooltip>
84
+ <TooltipTrigger asChild>
85
+ <PopoverTrigger asChild>
86
+ <Button
87
+ type="button"
88
+ size="icon"
89
+ variant="ghost"
90
+ className="size-7"
91
+ aria-label="Leave a comment"
92
+ >
93
+ <MessageSquareTextIcon className="size-3" />
94
+ </Button>
95
+ </PopoverTrigger>
96
+ </TooltipTrigger>
97
+ <TooltipContent>Leave a comment</TooltipContent>
98
+ </Tooltip>
99
+ <PopoverContent align="start" className="w-80">
100
+ <div className="grid gap-2">
101
+ <Label className="text-xs">Share feedback</Label>
102
+ <Textarea
103
+ value={comment}
104
+ onChange={(e) => setComment(e.target.value)}
105
+ onKeyDown={(e) => {
106
+ // Cmd/Ctrl+Enter submits, matching the composer's feel.
107
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
108
+ e.preventDefault();
109
+ void submitComment();
110
+ }
111
+ }}
112
+ placeholder="What worked well, or what could be better?"
113
+ rows={3}
114
+ autoFocus
115
+ />
116
+ <div className="flex justify-end gap-2">
117
+ <Button
118
+ type="button"
119
+ size="sm"
120
+ variant="ghost"
121
+ onClick={() => setOpen(false)}
122
+ >
123
+ Cancel
124
+ </Button>
125
+ <Button
126
+ type="button"
127
+ size="sm"
128
+ disabled={!comment.trim() || sending}
129
+ onClick={() => void submitComment()}
130
+ >
131
+ {sending ? "Sending..." : "Send"}
132
+ </Button>
133
+ </div>
134
+ </div>
135
+ </PopoverContent>
136
+ </Popover>
137
+ </>
138
+ );
139
+ };
@@ -0,0 +1,42 @@
1
+ // Public surface of @dbx-tools/ui-mastra/react.
2
+ //
3
+ // - `MastraChat` / `useMastraChat`: the self-contained drop-in (and its
4
+ // headless driver) that wire themselves from the Mastra plugin config.
5
+ // - `ChatView`: the controlled, presentational shell for callers that
6
+ // own message state and transport themselves.
7
+ // - The Mastra plugin client + hooks (model catalogue, history paging,
8
+ // suggestions, embed fetches) the controlled path needs.
9
+ //
10
+ // Internal building blocks (bubbles, tool pills, markdown, data grid,
11
+ // embed slots, suggestions) are intentionally not re-exported.
12
+
13
+ export {
14
+ MastraPluginClient,
15
+ useChartFetch,
16
+ useMastraClient,
17
+ useMastraConfig,
18
+ useMastraModels,
19
+ useMastraSuggestions,
20
+ useMastraThreads,
21
+ useStatementFetch,
22
+ } from "../support/mastra-client";
23
+ export type { ByIdFetchState } from "../support/mastra-client";
24
+ export { ChatView } from "./chat-view";
25
+ export { ExportMenu } from "./export-menu";
26
+ export { MastraChat, useMastraChat } from "./mastra-chat";
27
+ export type { MastraChatProps, UseMastraChatOptions } from "./mastra-chat";
28
+ export { ThreadSidebar } from "./thread-sidebar";
29
+ export type {
30
+ ApprovalDecision,
31
+ ChatModelOption,
32
+ ChatStatus,
33
+ ChatViewProps,
34
+ ExportFormat,
35
+ FeedbackSubmission,
36
+ FeedbackValue,
37
+ MessageFeedback,
38
+ PendingApproval,
39
+ ThreadSummary,
40
+ ToolEvent,
41
+ ToolProgress,
42
+ } from "./types";
@@ -0,0 +1,387 @@
1
+ import {
2
+ Button,
3
+ Table,
4
+ TableBody,
5
+ TableCell,
6
+ TableFooter,
7
+ TableHead,
8
+ TableHeader,
9
+ TableRow,
10
+ Tooltip,
11
+ TooltipContent,
12
+ TooltipTrigger,
13
+ cn,
14
+ } from "@dbx-tools/ui-appkit/react";
15
+ import { CheckIcon, CopyIcon } from "lucide-react";
16
+ import React, { useEffect, useMemo, useRef, useState } from "react";
17
+ import { format as formatSql } from "sql-formatter";
18
+ import { Streamdown } from "streamdown";
19
+ import { createShikiPlugin, highlightToHtml } from "../support/shiki-plugin";
20
+ import {
21
+ DataGrid,
22
+ TABLE_WRAPPER_CLASSES,
23
+ colorizeDelta,
24
+ type DataRow,
25
+ } from "./data-grid";
26
+
27
+ // Markdown rendering for the chat: the streaming `Streamdown` engine
28
+ // wired with shiki highlighting and AppKit table primitives, plus the
29
+ // standalone syntax-highlighted SQL block and a compact copy button.
30
+
31
+ /**
32
+ * Minimal hast node shape we walk to lift a GFM markdown table out of
33
+ * Streamdown's parsed tree (handed to component overrides as the
34
+ * `node` prop). Only the fields the extractor reads are modeled; the
35
+ * real node carries more.
36
+ */
37
+ interface MarkdownNode {
38
+ type?: string;
39
+ tagName?: string;
40
+ value?: string;
41
+ children?: MarkdownNode[];
42
+ }
43
+
44
+ /** Concatenate all descendant text of a hast node (cell -> plain string). */
45
+ function markdownNodeText(node: MarkdownNode): string {
46
+ if (node.type === "text") return node.value ?? "";
47
+ return (node.children ?? []).map(markdownNodeText).join("");
48
+ }
49
+
50
+ /**
51
+ * Lift a markdown `<table>` hast node into the column/row shape
52
+ * {@link DataGrid} consumes. Header cells become column keys; blank or
53
+ * duplicate headers are made unique (`Column N`, `Name (2)`) so each
54
+ * key can double as both the row-record key and the tanstack column
55
+ * id. Rich cell content (links, bold, code) is flattened to text - the
56
+ * grid sorts and exports on plain values. Returns `null` for anything
57
+ * that isn't a parseable table with at least one header cell.
58
+ */
59
+ function markdownTableData(
60
+ node: MarkdownNode | undefined,
61
+ ): { columns: string[]; rows: DataRow[] } | null {
62
+ if (!node || node.tagName !== "table") return null;
63
+ const sections = node.children ?? [];
64
+ const sectionRows = (tag: string): MarkdownNode[] =>
65
+ sections
66
+ .find((s) => s.tagName === tag)
67
+ ?.children?.filter((r) => r.tagName === "tr") ?? [];
68
+
69
+ const headerCells = (sectionRows("thead")[0]?.children ?? []).filter(
70
+ (c) => c.tagName === "th" || c.tagName === "td",
71
+ );
72
+ if (headerCells.length === 0) return null;
73
+
74
+ const columns: string[] = [];
75
+ const seen = new Map<string, number>();
76
+ for (const [i, cell] of headerCells.entries()) {
77
+ let name = markdownNodeText(cell).trim() || `Column ${i + 1}`;
78
+ const count = seen.get(name) ?? 0;
79
+ seen.set(name, count + 1);
80
+ if (count > 0) name = `${name} (${count + 1})`;
81
+ columns.push(name);
82
+ }
83
+
84
+ const rows: DataRow[] = sectionRows("tbody").map((tr) => {
85
+ const cells = (tr.children ?? []).filter(
86
+ (c) => c.tagName === "td" || c.tagName === "th",
87
+ );
88
+ const row: DataRow = {};
89
+ columns.forEach((col, i) => {
90
+ const cell = cells[i];
91
+ row[col] = cell ? markdownNodeText(cell).trim() : "";
92
+ });
93
+ return row;
94
+ });
95
+
96
+ return { columns, rows };
97
+ }
98
+
99
+ /**
100
+ * Static AppKit-Table rendering of a markdown table - the fallback
101
+ * when a table can't be lifted into a {@link DataGrid}, and the
102
+ * renderer tool-detail copy uses unconditionally (a sort/column/export
103
+ * toolbar would dwarf the tiny inline pills it renders in).
104
+ */
105
+ const plainMarkdownTable = ({
106
+ children,
107
+ ...rest
108
+ }: React.HTMLAttributes<HTMLTableElement>) => (
109
+ <div className={TABLE_WRAPPER_CLASSES}>
110
+ <Table {...rest}>{children}</Table>
111
+ </div>
112
+ );
113
+
114
+ /**
115
+ * Cell/section overrides shared by every markdown table renderer: map
116
+ * the GFM table parts onto AppKit's Table primitives so chat tables
117
+ * match the rest of the app instead of inheriting
118
+ * `@tailwindcss/typography`'s defaults. The `td` override also runs
119
+ * each cell through `colorizeDelta` so signed numeric tokens (e.g.
120
+ * `+1.8%`, `-3.1 pts`) render in green/red. These only take effect on
121
+ * the {@link plainMarkdownTable} path; the {@link DataGrid} path builds
122
+ * its own cells from the parsed data.
123
+ */
124
+ const MARKDOWN_TABLE_PARTS = {
125
+ thead: ({ children, ...rest }: React.HTMLAttributes<HTMLTableSectionElement>) => (
126
+ <TableHeader {...rest}>{children}</TableHeader>
127
+ ),
128
+ tbody: ({ children, ...rest }: React.HTMLAttributes<HTMLTableSectionElement>) => (
129
+ <TableBody {...rest}>{children}</TableBody>
130
+ ),
131
+ tfoot: ({ children, ...rest }: React.HTMLAttributes<HTMLTableSectionElement>) => (
132
+ <TableFooter {...rest}>{children}</TableFooter>
133
+ ),
134
+ tr: ({ children, ...rest }: React.HTMLAttributes<HTMLTableRowElement>) => (
135
+ <TableRow {...rest}>{children}</TableRow>
136
+ ),
137
+ th: ({ children, ...rest }: React.HTMLAttributes<HTMLTableCellElement>) => (
138
+ <TableHead {...rest}>{children}</TableHead>
139
+ ),
140
+ td: ({ children, ...rest }: React.HTMLAttributes<HTMLTableCellElement>) => {
141
+ const colored = Array.isArray(children)
142
+ ? children.map((c, i) => (
143
+ <React.Fragment key={i}>{colorizeDelta(c)}</React.Fragment>
144
+ ))
145
+ : colorizeDelta(children as React.ReactNode);
146
+ return <TableCell {...rest}>{colored}</TableCell>;
147
+ },
148
+ };
149
+
150
+ /**
151
+ * Markdown component map for the main assistant reply. Tables are
152
+ * lifted out of the parsed `node` and rendered through the interactive
153
+ * {@link DataGrid} (sortable, column show/hide, CSV export) so they
154
+ * behave exactly like statement-result tables; anything that doesn't
155
+ * parse cleanly falls back to {@link plainMarkdownTable}.
156
+ */
157
+ const MARKDOWN_COMPONENTS = {
158
+ ...MARKDOWN_TABLE_PARTS,
159
+ table: ({
160
+ node,
161
+ children,
162
+ ...rest
163
+ }: React.HTMLAttributes<HTMLTableElement> & { node?: MarkdownNode }) => {
164
+ const parsed = markdownTableData(node);
165
+ if (parsed && parsed.columns.length > 0) {
166
+ return (
167
+ <DataGrid
168
+ columns={parsed.columns}
169
+ rows={parsed.rows}
170
+ truncated={false}
171
+ rowCount={parsed.rows.length}
172
+ />
173
+ );
174
+ }
175
+ return plainMarkdownTable({ children, ...rest });
176
+ },
177
+ };
178
+
179
+ /**
180
+ * Markdown component map for tool-detail copy (Genie summaries, SQL
181
+ * descriptions). Same cell/section overrides, but tables stay static
182
+ * via {@link plainMarkdownTable} - these render inside tiny muted pills
183
+ * where a full {@link DataGrid} toolbar would be oversized.
184
+ */
185
+ const TOOL_MARKDOWN_COMPONENTS = {
186
+ ...MARKDOWN_TABLE_PARTS,
187
+ table: plainMarkdownTable,
188
+ };
189
+
190
+ /**
191
+ * Shared shiki highlighter for every `Streamdown` instance in the chat.
192
+ * Streamdown 2.x ships highlighting as an opt-in plugin (no built-in
193
+ * shiki), so without this the SQL/code blocks render as uncolored
194
+ * plaintext. One instance keeps a single lazily-loaded highlighter.
195
+ */
196
+ const SHIKI_PLUGIN = { code: createShikiPlugin() };
197
+
198
+ /**
199
+ * Per-word fade-in applied while a reply is still streaming. Streamdown
200
+ * wraps each newly arrived token in a `[data-sd-animate]` span and only
201
+ * animates the delta since the previous render (tokens already on
202
+ * screen don't re-animate), so the text eases in instead of snapping
203
+ * in whole SSE chunks. Kept short with an `ease-out` curve so the
204
+ * reveal is quick and smooth. The keyframes ship in
205
+ * `streamdown/styles.css`, imported by this package's `styles.css`.
206
+ *
207
+ * `stagger: 0` is load-bearing. Streamdown 2.5 defaults `stagger` to
208
+ * 40ms, and stagger has NO inter-block coordination: each block's
209
+ * delay counter restarts at 0, so a freshly-appeared paragraph starts
210
+ * fading at delay 0 while the previous block's staggered tail words are
211
+ * still queued at 40ms x N - making sibling sections visibly animate in
212
+ * parallel ("new paragraphs reveal before the previous finishes").
213
+ * Pinning `stagger: 0` makes each batch of newly-arrived words fade
214
+ * together within `duration`, so blocks reveal in order with no overlap
215
+ * window. See https://github.com/vercel/streamdown/issues/482.
216
+ */
217
+ const ANIMATE_OPTIONS = {
218
+ animation: "fadeIn",
219
+ sep: "word",
220
+ duration: 120,
221
+ easing: "ease-out",
222
+ stagger: 0,
223
+ } as const;
224
+
225
+ /**
226
+ * Streamdown ships GFM (tables, task lists, strikethrough, autolink),
227
+ * KaTeX math, Mermaid diagrams, copy/download controls on code +
228
+ * tables, and incremental-parse handling for partial markdown chunks -
229
+ * all out of the box. Syntax highlighting is provided via the
230
+ * {@link SHIKI_PLUGIN} `code` plugin. We layer on the project's heading
231
+ * rhythm and route tables through AppKit's Table primitives via
232
+ * {@link MARKDOWN_COMPONENTS}, then disable the noisy in-block copy/
233
+ * download buttons since this UI lives inside a chat bubble that
234
+ * already has its own copy button. `animate` opts the block into the
235
+ * {@link ANIMATE_OPTIONS} word-by-word fade-in (and drives
236
+ * `isAnimating` for the streaming caret); callers pass it only for the
237
+ * actively streaming bubble so settled history renders plain.
238
+ */
239
+ export const AssistantMarkdown = ({
240
+ children,
241
+ animate = false,
242
+ }: {
243
+ children: string;
244
+ animate?: boolean;
245
+ }) => (
246
+ <Streamdown
247
+ components={MARKDOWN_COMPONENTS}
248
+ plugins={SHIKI_PLUGIN}
249
+ controls={false}
250
+ animated={animate ? ANIMATE_OPTIONS : false}
251
+ isAnimating={animate}
252
+ className={cn(
253
+ "prose prose-sm dark:prose-invert max-w-none break-words",
254
+ "prose-headings:font-semibold prose-headings:tracking-tight",
255
+ "prose-h1:text-lg prose-h1:mt-4 prose-h1:mb-2",
256
+ "prose-h2:text-base prose-h2:mt-4 prose-h2:mb-2",
257
+ "prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1.5 prose-h3:text-muted-foreground prose-h3:uppercase prose-h3:tracking-wider",
258
+ )}
259
+ >
260
+ {children}
261
+ </Streamdown>
262
+ );
263
+
264
+ /**
265
+ * Tighter, muted markdown variant for inline tool detail copy: Genie
266
+ * attachment text (the natural-language summary that arrives below
267
+ * the SQL) and SQL descriptions. Same `Streamdown` engine as
268
+ * {@link AssistantMarkdown} so we keep GFM, shiki, and table primitive
269
+ * mapping, but everything is squeezed: smaller font, tighter leading,
270
+ * and near-zero block margins so a few short lines don't take more
271
+ * vertical space than the SQL block above them. Lists get a shallow
272
+ * indent (`pl-4`) because the default `prose-sm` indent is sized for
273
+ * chat-bubble copy and looks oversized inside a sub-pill. Strong
274
+ * (bold) gets the foreground color so KPI names still pop against
275
+ * the muted body.
276
+ */
277
+ export const ToolMarkdown = ({ children }: { children: string }) => (
278
+ <Streamdown
279
+ components={TOOL_MARKDOWN_COMPONENTS}
280
+ plugins={SHIKI_PLUGIN}
281
+ controls={false}
282
+ className={cn(
283
+ "prose prose-sm dark:prose-invert max-w-none break-words",
284
+ "text-[11px] leading-snug text-muted-foreground",
285
+ "prose-p:my-0.5 prose-p:leading-snug",
286
+ "prose-ul:my-0.5 prose-ul:pl-4 prose-ol:my-0.5 prose-ol:pl-4",
287
+ "prose-li:my-0 prose-li:leading-snug prose-li:marker:text-muted-foreground/60",
288
+ "prose-headings:my-1 prose-headings:text-xs prose-headings:font-semibold",
289
+ "prose-strong:text-foreground/90 prose-strong:font-medium",
290
+ "prose-code:text-[10px] prose-code:font-medium",
291
+ )}
292
+ >
293
+ {children}
294
+ </Streamdown>
295
+ );
296
+
297
+ /**
298
+ * Pretty-print a Genie SQL string for display. Genie often emits the
299
+ * query as one long line; `sql-formatter`'s Spark dialect (the closest
300
+ * fit to Databricks SQL) re-indents it with uppercased keywords. The
301
+ * formatter throws on syntax it can't parse (e.g. Databricks-specific
302
+ * constructs or a partial query), so we fall back to the raw string
303
+ * rather than dropping the preview.
304
+ */
305
+ function prettySql(sql: string): string {
306
+ try {
307
+ return formatSql(sql, { language: "spark", keywordCase: "upper" });
308
+ } catch {
309
+ return sql;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Copy-to-clipboard button with a transient confirmation state: the
315
+ * icon flips to a check for ~1.5s after a successful copy. Shared by
316
+ * the SQL preview (and available to any block that needs a compact
317
+ * copy affordance).
318
+ */
319
+ const CopyButton = ({ value, className }: { value: string; className?: string }) => {
320
+ const [copied, setCopied] = useState(false);
321
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
322
+ useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
323
+ const onCopy = () => {
324
+ void navigator.clipboard.writeText(value).then(() => {
325
+ setCopied(true);
326
+ clearTimeout(timer.current ?? undefined);
327
+ timer.current = setTimeout(() => setCopied(false), 1500);
328
+ });
329
+ };
330
+ return (
331
+ <Tooltip>
332
+ <TooltipTrigger asChild>
333
+ <Button
334
+ type="button"
335
+ size="icon"
336
+ variant="ghost"
337
+ className={cn("size-6", className)}
338
+ onClick={onCopy}
339
+ >
340
+ {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
341
+ </Button>
342
+ </TooltipTrigger>
343
+ <TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
344
+ </Tooltip>
345
+ );
346
+ };
347
+
348
+ /**
349
+ * Render a SQL string as a syntax-highlighted code block. The query is
350
+ * first run through {@link prettySql} so one-line Genie output reads as
351
+ * formatted SQL, then highlighted to minimal inline HTML via
352
+ * {@link highlightToHtml} (shiki). Unlike Streamdown's code renderer,
353
+ * this emits a plain `<pre><code>` with only per-token color spans - no
354
+ * line-number gutter or per-line wrappers - so the SQL selects and
355
+ * copies cleanly. A {@link CopyButton} copies the formatted source. The
356
+ * highlighter loads asynchronously, so we render uncolored text until
357
+ * the tokens are ready to avoid a flash of empty space.
358
+ */
359
+ export const SqlBlock = ({ sql }: { sql: string }) => {
360
+ const formatted = useMemo(() => prettySql(sql), [sql]);
361
+ const [html, setHtml] = useState<string | null>(null);
362
+ useEffect(() => {
363
+ let active = true;
364
+ setHtml(null);
365
+ void highlightToHtml(formatted, "sql").then((result) => {
366
+ if (active) setHtml(result);
367
+ });
368
+ return () => {
369
+ active = false;
370
+ };
371
+ }, [formatted]);
372
+ return (
373
+ <div className="group relative">
374
+ <CopyButton
375
+ value={formatted}
376
+ className="absolute right-1.5 top-1.5 z-10 bg-background/70 opacity-0 backdrop-blur transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
377
+ />
378
+ <pre className="max-w-full overflow-x-auto rounded-md border border-border bg-background p-3 font-mono text-[11px] leading-relaxed">
379
+ {html === null ? (
380
+ <code>{formatted}</code>
381
+ ) : (
382
+ <code dangerouslySetInnerHTML={{ __html: html }} />
383
+ )}
384
+ </pre>
385
+ </div>
386
+ );
387
+ };