@aphrody/m3-ai 3.3.2 → 3.3.5

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 (56) hide show
  1. package/README.md +29 -7
  2. package/dist/A2APeerInspector.d.ts +41 -0
  3. package/dist/A2APeerInspector.d.ts.map +1 -0
  4. package/dist/A2ATaskTimeline.d.ts +28 -0
  5. package/dist/A2ATaskTimeline.d.ts.map +1 -0
  6. package/dist/AgUiEventStream.d.ts +87 -0
  7. package/dist/AgUiEventStream.d.ts.map +1 -0
  8. package/dist/GeminiAccountMenu.d.ts +55 -0
  9. package/dist/GeminiAccountMenu.d.ts.map +1 -0
  10. package/dist/GeminiPersonalContextEdit.d.ts +29 -0
  11. package/dist/GeminiPersonalContextEdit.d.ts.map +1 -0
  12. package/dist/GoogleSpotlightBar.d.ts +32 -0
  13. package/dist/GoogleSpotlightBar.d.ts.map +1 -0
  14. package/dist/Markdown.d.ts +2 -1
  15. package/dist/Markdown.d.ts.map +1 -1
  16. package/dist/McpAppCard.d.ts +24 -0
  17. package/dist/McpAppCard.d.ts.map +1 -0
  18. package/dist/ModelSelector.d.ts.map +1 -1
  19. package/dist/TaskRow.d.ts.map +1 -1
  20. package/dist/ToolCall.d.ts.map +1 -1
  21. package/dist/index.d.ts +15 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3629 -1950
  24. package/dist/index.js.map +32 -1
  25. package/package.json +17 -20
  26. package/src/A2APeerInspector.tsx +370 -0
  27. package/src/A2ATaskTimeline.tsx +218 -0
  28. package/src/AgUiEventStream.ts +492 -0
  29. package/src/ChatComposer.tsx +261 -0
  30. package/src/ConversationList.tsx +207 -0
  31. package/src/GeminiAccountMenu.tsx +212 -0
  32. package/src/GeminiPersonalContextEdit.tsx +147 -0
  33. package/src/GoogleSpotlightBar.tsx +165 -0
  34. package/src/InlineBranchCard.tsx +97 -0
  35. package/src/InlineWorkerCard.tsx +182 -0
  36. package/src/Markdown.tsx +769 -0
  37. package/src/McpAppCard.tsx +161 -0
  38. package/src/MemoryPanel.tsx +176 -0
  39. package/src/MessageBubble.tsx +143 -0
  40. package/src/ModelSelector.tsx +319 -0
  41. package/src/ModelUnavailable.tsx +116 -0
  42. package/src/TaskCreateForm.tsx +123 -0
  43. package/src/TaskDetail.tsx +224 -0
  44. package/src/TaskList.tsx +136 -0
  45. package/src/TaskPriorityIcon.tsx +39 -0
  46. package/src/TaskRow.tsx +177 -0
  47. package/src/TaskStatusIcon.tsx +39 -0
  48. package/src/ToolCall.tsx +644 -0
  49. package/src/chatIcons.tsx +105 -0
  50. package/src/chatLabels.ts +243 -0
  51. package/src/citations.tsx +286 -0
  52. package/src/index.ts +173 -0
  53. package/src/internal.tsx +107 -0
  54. package/src/openaiChatStream.ts +400 -0
  55. package/src/types.ts +212 -0
  56. package/src/useOpenAIChat.ts +126 -0
@@ -0,0 +1,319 @@
1
+ // SPDX-License-Identifier: Apache-2.0 AND MIT
2
+ // Derived from SpaceUI (packages/aphrody-spaceui/packages/ai/src/ModelSelector.tsx), MIT, rebuilt on Material Design 3.
3
+
4
+ "use client";
5
+
6
+ import { Popover, usePopover, SelectPill, type SelectPillProps } from "@aphrody/m3-primitives";
7
+ import { clsx } from "clsx";
8
+ import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
9
+
10
+ import { type ChatLabels, formatLabel, resolveChatLabels } from "./chatLabels";
11
+ import { ChatIcon } from "./chatIcons";
12
+ import { focusRing } from "./internal";
13
+ import type { ModelOption } from "./types";
14
+
15
+ export interface ModelSelectorProps {
16
+ /** Available models */
17
+ models: ModelOption[];
18
+ /** Currently selected model ID */
19
+ value: string;
20
+ /** Called when a model is selected */
21
+ onChange: (value: string) => void;
22
+ /** Placeholder when nothing selected */
23
+ placeholder?: string;
24
+ /** Search placeholder */
25
+ searchPlaceholder?: string;
26
+ /** SelectPill variant */
27
+ variant?: SelectPillProps["variant"];
28
+ /** SelectPill size */
29
+ size?: SelectPillProps["size"];
30
+ /** Controlled popover (optional) */
31
+ popover?: ReturnType<typeof usePopover>;
32
+ /** Additional trigger className */
33
+ className?: string;
34
+ /** Disabled state */
35
+ disabled?: boolean;
36
+ /** Visible strings; `placeholder` / `searchPlaceholder` win over them. English defaults. */
37
+ labels?: Partial<ChatLabels>;
38
+ }
39
+
40
+ /** Group models by provider, keeping the order of first appearance. */
41
+ export function groupModelsByProvider(
42
+ models: ModelOption[],
43
+ search = "",
44
+ ): Record<string, ModelOption[]> {
45
+ const q = search.trim().toLowerCase();
46
+ const filtered = q
47
+ ? models.filter((m) => m.name.toLowerCase().includes(q) || m.provider.toLowerCase().includes(q))
48
+ : models;
49
+
50
+ return filtered.reduce(
51
+ (acc, model) => {
52
+ if (!acc[model.provider]) acc[model.provider] = [];
53
+ acc[model.provider].push(model);
54
+ return acc;
55
+ },
56
+ Object.create(null) as Record<string, ModelOption[]>,
57
+ );
58
+ }
59
+
60
+ export interface ModelOptionListProps {
61
+ /** Models grouped by provider (see {@link groupModelsByProvider}). */
62
+ grouped: Record<string, ModelOption[]>;
63
+ /** Currently selected model ID */
64
+ value: string;
65
+ /** Called when a model is picked */
66
+ onSelect: (value: string) => void;
67
+ className?: string;
68
+ labels?: Partial<ChatLabels>;
69
+ }
70
+
71
+ /**
72
+ * The M3 menu list of the model selector: provider section headers (`text-title-small`),
73
+ * 48 dp two-line items (`text-label-large` + `text-body-small` supporting text), the selected
74
+ * item on `secondary-container`, capabilities as small outlined chips.
75
+ */
76
+ export function ModelOptionList({
77
+ grouped,
78
+ value,
79
+ onSelect,
80
+ className,
81
+ labels,
82
+ }: ModelOptionListProps) {
83
+ const l = resolveChatLabels(labels);
84
+ const providers = Object.entries(grouped);
85
+ const models = providers.flatMap(([, providerModels]) => providerModels);
86
+ const [focusedId, setFocusedId] = useState(value);
87
+ const focusId = models.some((model) => model.id === focusedId)
88
+ ? focusedId
89
+ : (models.find((model) => model.id === value)?.id ?? models[0]?.id);
90
+ return (
91
+ <div
92
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 menu whose options are rich rows (headers, supporting text, chips); a native <select>/<datalist> cannot render them.
93
+ role="listbox"
94
+ aria-label={l.models}
95
+ tabIndex={-1}
96
+ onKeyDown={(event) => {
97
+ if (event.defaultPrevented) return;
98
+ const options = Array.from(
99
+ event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'),
100
+ );
101
+ const index = options.indexOf(event.target as HTMLButtonElement);
102
+ if (index < 0) return;
103
+ let next: number;
104
+ switch (event.key) {
105
+ case "ArrowDown":
106
+ next = (index + 1) % options.length;
107
+ break;
108
+ case "ArrowUp":
109
+ next = (index - 1 + options.length) % options.length;
110
+ break;
111
+ case "Home":
112
+ next = 0;
113
+ break;
114
+ case "End":
115
+ next = options.length - 1;
116
+ break;
117
+ default:
118
+ return;
119
+ }
120
+ event.preventDefault();
121
+ options[next]!.focus();
122
+ }}
123
+ className={clsx("flex flex-col", className)}
124
+ >
125
+ {providers.map(([provider, providerModels], groupIndex) => (
126
+ <div
127
+ key={provider}
128
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- provider section of the custom listbox with a visible M3 header row; <optgroup> only exists inside a native <select>.
129
+ role="group"
130
+ aria-label={provider}
131
+ className={clsx(groupIndex > 0 && "mt-1 border-t border-outline-variant pt-1")}
132
+ >
133
+ <div className="px-3 pt-2 pb-1 text-title-small text-on-surface-variant">{provider}</div>
134
+ {providerModels.map((model) => {
135
+ const selected = model.id === value;
136
+ return (
137
+ <button
138
+ key={model.id}
139
+ type="button"
140
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 two-line menu item (label, supporting text, capability chips, check icon); a native <option> can only hold text.
141
+ role="option"
142
+ aria-selected={selected}
143
+ tabIndex={model.id === focusId ? 0 : -1}
144
+ onFocus={() => setFocusedId(model.id)}
145
+ onClick={() => onSelect(model.id)}
146
+ className={clsx(
147
+ "flex min-h-12 w-full items-center gap-3 px-3 py-2 text-left",
148
+ "transition-colors duration-200 ease-standard",
149
+ selected
150
+ ? "bg-secondary-container text-on-secondary-container"
151
+ : "text-on-surface hover:bg-on-surface/8 active:bg-on-surface/10",
152
+ focusRing,
153
+ "focus-visible:outline-offset-[-3px]",
154
+ )}
155
+ >
156
+ <div className="min-w-0 flex-1">
157
+ <div className="truncate text-label-large">{model.name}</div>
158
+ {model.context_window || model.capabilities?.length ? (
159
+ <div className="mt-0.5 flex flex-wrap items-center gap-1">
160
+ {model.context_window ? (
161
+ <span
162
+ className={clsx(
163
+ "text-body-small",
164
+ selected ? "text-on-secondary-container" : "text-on-surface-variant",
165
+ )}
166
+ >
167
+ {formatLabel(l.contextTokens, {
168
+ count: model.context_window.toLocaleString(),
169
+ })}
170
+ </span>
171
+ ) : null}
172
+ {model.capabilities?.map((capability) => (
173
+ <span
174
+ key={capability}
175
+ className={clsx(
176
+ "inline-flex h-5 items-center rounded-small border border-outline-variant px-1.5 text-label-small",
177
+ selected ? "text-on-secondary-container" : "text-on-surface-variant",
178
+ )}
179
+ >
180
+ {capability}
181
+ </span>
182
+ ))}
183
+ </div>
184
+ ) : null}
185
+ </div>
186
+ {selected && <ChatIcon name="check" size={20} />}
187
+ </button>
188
+ );
189
+ })}
190
+ </div>
191
+ ))}
192
+ {providers.length === 0 && (
193
+ <div className="px-3 py-3 text-center text-body-small text-on-surface-variant">
194
+ {l.noModels}
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * The menu's search field. It mounts with the popover content and takes focus then (replaces
203
+ * `autoFocus`), so typing filters the list as soon as the menu opens.
204
+ */
205
+ function ModelSearchField({
206
+ value,
207
+ onChange,
208
+ placeholder,
209
+ }: {
210
+ value: string;
211
+ onChange: (value: string) => void;
212
+ placeholder: string;
213
+ }) {
214
+ const inputRef = useRef<HTMLInputElement>(null);
215
+
216
+ useEffect(() => {
217
+ inputRef.current?.focus();
218
+ }, []);
219
+
220
+ return (
221
+ <div className="px-2 pb-2">
222
+ <label className="flex h-10 items-center gap-2 rounded-full bg-surface-container-high px-3 text-on-surface-variant focus-within:bg-surface-container-highest">
223
+ <ChatIcon name="search" size={20} />
224
+ <input
225
+ ref={inputRef}
226
+ value={value}
227
+ onChange={(event) => onChange(event.target.value)}
228
+ placeholder={placeholder}
229
+ aria-label={placeholder}
230
+ className="min-w-0 flex-1 bg-transparent text-body-large text-on-surface outline-none placeholder:text-on-surface-variant"
231
+ />
232
+ </label>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ export const ModelSelector = forwardRef<HTMLButtonElement, ModelSelectorProps>(
238
+ (
239
+ {
240
+ models,
241
+ value,
242
+ onChange,
243
+ placeholder,
244
+ searchPlaceholder,
245
+ variant,
246
+ size,
247
+ popover: externalPopover,
248
+ className,
249
+ disabled,
250
+ labels,
251
+ },
252
+ ref,
253
+ ) => {
254
+ const l = resolveChatLabels(labels);
255
+ const internalPopover = usePopover();
256
+ const popover = externalPopover ?? internalPopover;
257
+ const [search, setSearch] = useState("");
258
+
259
+ const selectedModel = models.find((m) => m.id === value);
260
+
261
+ const grouped = useMemo(() => groupModelsByProvider(models, search), [models, search]);
262
+
263
+ return (
264
+ <Popover.Root
265
+ open={popover.open}
266
+ onOpenChange={(open) => {
267
+ popover.setOpen(open);
268
+ if (!open) setSearch("");
269
+ }}
270
+ >
271
+ <Popover.Trigger asChild>
272
+ <SelectPill
273
+ ref={ref}
274
+ variant={variant}
275
+ size={size}
276
+ disabled={disabled}
277
+ aria-haspopup="listbox"
278
+ hideCaret
279
+ className={clsx("w-full", className)}
280
+ >
281
+ <span className="min-w-0 flex-1 truncate">
282
+ {selectedModel?.name ?? placeholder ?? l.selectModel}
283
+ </span>
284
+ <ChatIcon name="expand_more" size={18} />
285
+ </SelectPill>
286
+ </Popover.Trigger>
287
+ <Popover.Content
288
+ align="end"
289
+ sideOffset={8}
290
+ style={{ width: 280 }}
291
+ className="overflow-hidden rounded-extra-small! py-2"
292
+ >
293
+ {/* Search */}
294
+ <ModelSearchField
295
+ value={search}
296
+ onChange={setSearch}
297
+ placeholder={searchPlaceholder ?? l.searchModels}
298
+ />
299
+
300
+ {/* Grouped results */}
301
+ <div className="max-h-[320px] overflow-y-auto">
302
+ <ModelOptionList
303
+ grouped={grouped}
304
+ value={value}
305
+ labels={labels}
306
+ onSelect={(id) => {
307
+ onChange(id);
308
+ popover.setOpen(false);
309
+ setSearch("");
310
+ }}
311
+ />
312
+ </div>
313
+ </Popover.Content>
314
+ </Popover.Root>
315
+ );
316
+ },
317
+ );
318
+
319
+ ModelSelector.displayName = "ModelSelector";
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // "Model unavailable" state of a local chat: downloading, loading, or down, with an optional
3
+ // determinate M3 linear progress indicator and a retry action.
4
+
5
+ "use client";
6
+
7
+ import { clsx } from "clsx";
8
+
9
+ import { type ChatLabels, formatLabel, resolveChatLabels } from "./chatLabels";
10
+ import { ChatIcon } from "./chatIcons";
11
+ import { CircularIndicator, focusRing } from "./internal";
12
+
13
+ export type ModelAvailabilityPhase = "downloading" | "loading" | "unavailable";
14
+
15
+ export interface ModelUnavailableProps {
16
+ phase?: ModelAvailabilityPhase;
17
+ /** Why the model cannot answer (backend message, already localized by the caller). */
18
+ reason?: string;
19
+ /** Download or load progress, 0..1; omit for an indeterminate state. */
20
+ progress?: number | null;
21
+ onRetry?: () => void;
22
+ labels?: Partial<ChatLabels>;
23
+ className?: string;
24
+ }
25
+
26
+ /** Clamp a progress fraction to 0..1; non-finite values give `null` (indeterminate). */
27
+ export function clampProgress(progress: number | null | undefined): number | null {
28
+ if (progress === null || progress === undefined || !Number.isFinite(progress)) return null;
29
+ return Math.min(1, Math.max(0, progress));
30
+ }
31
+
32
+ export function ModelUnavailable({
33
+ phase = "unavailable",
34
+ reason,
35
+ progress,
36
+ onRetry,
37
+ labels,
38
+ className,
39
+ }: ModelUnavailableProps) {
40
+ const l = resolveChatLabels(labels);
41
+ const value = clampProgress(progress);
42
+ const busy = phase !== "unavailable";
43
+ const title =
44
+ phase === "downloading"
45
+ ? l.modelDownloading
46
+ : phase === "loading"
47
+ ? l.modelLoading
48
+ : l.modelUnavailable;
49
+ const percent = value === null ? null : Math.round(value * 100);
50
+
51
+ return (
52
+ <div
53
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- live region wrapping block content (title, progress, action); <output> only holds phrasing content.
54
+ role="status"
55
+ aria-live="polite"
56
+ aria-busy={busy || undefined}
57
+ className={clsx(
58
+ "m3-model-unavailable flex flex-col gap-3 rounded-large px-4 py-4",
59
+ busy
60
+ ? "bg-surface-container-high text-on-surface"
61
+ : "bg-error-container text-on-error-container",
62
+ className,
63
+ )}
64
+ >
65
+ <div className="flex items-start gap-3">
66
+ {busy ? (
67
+ <CircularIndicator size={24} label={title} className="mt-0.5" />
68
+ ) : (
69
+ <ChatIcon name="cloud_off" size={24} className="mt-0.5" />
70
+ )}
71
+ <div className="min-w-0 flex-1">
72
+ <p className="m-0 text-title-medium">{title}</p>
73
+ {reason ? <p className="m-0 mt-1 text-body-medium opacity-90">{reason}</p> : null}
74
+ </div>
75
+ {percent !== null ? (
76
+ <span className="shrink-0 text-label-large">
77
+ {formatLabel(l.progressPercent, { percent })}
78
+ </span>
79
+ ) : null}
80
+ </div>
81
+ {busy && value !== null ? (
82
+ <div
83
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 linear determinate indicator with a track gap; a native <progress> cannot be styled to the spec.
84
+ role="progressbar"
85
+ aria-label={title}
86
+ aria-valuemin={0}
87
+ aria-valuemax={100}
88
+ aria-valuenow={percent ?? undefined}
89
+ className="flex h-1 w-full items-center gap-1"
90
+ >
91
+ <span
92
+ className="h-1 rounded-full bg-primary transition-[width] duration-300 ease-standard"
93
+ style={{ width: `${(value * 100).toFixed(1)}%` }}
94
+ />
95
+ <span className="h-1 flex-1 rounded-full bg-secondary-container" />
96
+ </div>
97
+ ) : null}
98
+ {onRetry && !busy ? (
99
+ <div className="flex justify-end">
100
+ <button
101
+ type="button"
102
+ onClick={onRetry}
103
+ className={clsx(
104
+ "inline-flex h-10 items-center gap-2 rounded-full bg-error px-6 text-label-large text-on-error",
105
+ "transition-colors duration-200 ease-standard hover:bg-error/92",
106
+ focusRing,
107
+ )}
108
+ >
109
+ <ChatIcon name="refresh" size={18} />
110
+ <span>{l.retry}</span>
111
+ </button>
112
+ </div>
113
+ ) : null}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: Apache-2.0 AND MIT
2
+ // Derived from SpaceUI (packages/aphrody-spaceui/packages/ai/src/TaskCreateForm.tsx), MIT, rebuilt on Material Design 3.
3
+
4
+ import { Button, Input, Select, SelectOption, TextArea } from "@aphrody/m3-primitives";
5
+ import { clsx } from "clsx";
6
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
7
+
8
+ import type { TaskPriority } from "./types";
9
+ import { TASK_PRIORITY_LABEL } from "./types";
10
+
11
+ export interface TaskCreateFormData {
12
+ title: string;
13
+ description: string;
14
+ priority: TaskPriority;
15
+ }
16
+
17
+ export interface TaskCreateFormProps {
18
+ onSubmit: (data: TaskCreateFormData) => void;
19
+ onCancel?: () => void;
20
+ defaultPriority?: TaskPriority;
21
+ isSubmitting?: boolean;
22
+ className?: string;
23
+ }
24
+
25
+ const PRIORITIES: TaskPriority[] = ["critical", "high", "medium", "low"];
26
+
27
+ /**
28
+ * Inline task creation on an M3 surface: title and description text fields, a priority select,
29
+ * and M3 actions (filled `Create`, text `Cancel`).
30
+ */
31
+ export function TaskCreateForm({
32
+ onSubmit,
33
+ onCancel,
34
+ defaultPriority = "medium",
35
+ isSubmitting,
36
+ className,
37
+ }: TaskCreateFormProps) {
38
+ const [title, setTitle] = useState("");
39
+ const [description, setDescription] = useState("");
40
+ const [priority, setPriority] = useState<TaskPriority>(defaultPriority);
41
+ const titleInputId = useId();
42
+ const descriptionInputId = useId();
43
+ const titleInputRef = useRef<HTMLInputElement>(null);
44
+
45
+ // Focus the title when the form mounts (replaces `autoFocus`).
46
+ useEffect(() => {
47
+ titleInputRef.current?.focus();
48
+ }, []);
49
+
50
+ const canSubmit = title.trim().length > 0 && !isSubmitting;
51
+
52
+ const handleSubmit = useCallback(() => {
53
+ if (!canSubmit) return;
54
+ onSubmit({ title: title.trim(), description: description.trim(), priority });
55
+ setTitle("");
56
+ setDescription("");
57
+ setPriority(defaultPriority);
58
+ }, [canSubmit, onSubmit, title, description, priority, defaultPriority]);
59
+
60
+ return (
61
+ <div
62
+ className={clsx(
63
+ "flex flex-col gap-3 rounded-medium bg-surface-container-low p-4 text-on-surface",
64
+ className,
65
+ )}
66
+ >
67
+ <div className="flex items-center gap-2">
68
+ <label htmlFor={titleInputId} className="sr-only">
69
+ Task Title
70
+ </label>
71
+ <Input
72
+ ref={titleInputRef}
73
+ id={titleInputId}
74
+ type="text"
75
+ size="lg"
76
+ value={title}
77
+ onChange={(e) => setTitle(e.target.value)}
78
+ onKeyDown={(e) => {
79
+ if (e.key === "Enter") handleSubmit();
80
+ if (e.key === "Escape") onCancel?.();
81
+ }}
82
+ placeholder="Task title..."
83
+ className="min-w-0 flex-1"
84
+ disabled={isSubmitting}
85
+ />
86
+ <Select
87
+ value={priority}
88
+ size="lg"
89
+ onChange={(value) => setPriority(value as TaskPriority)}
90
+ disabled={isSubmitting}
91
+ >
92
+ {PRIORITIES.map((p) => (
93
+ <SelectOption key={p} value={p}>
94
+ {TASK_PRIORITY_LABEL[p]}
95
+ </SelectOption>
96
+ ))}
97
+ </Select>
98
+ </div>
99
+ <label htmlFor={descriptionInputId} className="sr-only">
100
+ Description
101
+ </label>
102
+ <TextArea
103
+ id={descriptionInputId}
104
+ value={description}
105
+ onChange={(e) => setDescription(e.target.value)}
106
+ placeholder="Description (optional)"
107
+ rows={2}
108
+ className="w-full resize-none"
109
+ disabled={isSubmitting}
110
+ />
111
+ <div className="flex items-center justify-end gap-2">
112
+ {onCancel && (
113
+ <Button variant="text" size="lg" onClick={onCancel} disabled={isSubmitting}>
114
+ Cancel
115
+ </Button>
116
+ )}
117
+ <Button variant="filled" size="lg" disabled={!canSubmit} onClick={handleSubmit}>
118
+ Create
119
+ </Button>
120
+ </div>
121
+ </div>
122
+ );
123
+ }