botschat 0.1.0

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 (88) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +213 -0
  3. package/migrations/0001_initial.sql +88 -0
  4. package/migrations/0002_rename_projects_to_channels.sql +53 -0
  5. package/migrations/0003_messages.sql +14 -0
  6. package/migrations/0004_jobs.sql +15 -0
  7. package/migrations/0005_deleted_cron_jobs.sql +6 -0
  8. package/migrations/0006_tasks_add_model.sql +2 -0
  9. package/migrations/0007_sessions.sql +25 -0
  10. package/migrations/0008_remove_openclaw_fields.sql +8 -0
  11. package/package.json +53 -0
  12. package/packages/api/package.json +17 -0
  13. package/packages/api/src/do/connection-do.ts +929 -0
  14. package/packages/api/src/env.ts +8 -0
  15. package/packages/api/src/index.ts +297 -0
  16. package/packages/api/src/routes/agents.ts +68 -0
  17. package/packages/api/src/routes/auth.ts +105 -0
  18. package/packages/api/src/routes/channels.ts +185 -0
  19. package/packages/api/src/routes/jobs.ts +65 -0
  20. package/packages/api/src/routes/models.ts +22 -0
  21. package/packages/api/src/routes/pairing.ts +76 -0
  22. package/packages/api/src/routes/projects.ts +177 -0
  23. package/packages/api/src/routes/sessions.ts +171 -0
  24. package/packages/api/src/routes/tasks.ts +375 -0
  25. package/packages/api/src/routes/upload.ts +52 -0
  26. package/packages/api/src/utils/auth.ts +101 -0
  27. package/packages/api/src/utils/id.ts +19 -0
  28. package/packages/api/tsconfig.json +18 -0
  29. package/packages/plugin/dist/index.d.ts +19 -0
  30. package/packages/plugin/dist/index.d.ts.map +1 -0
  31. package/packages/plugin/dist/index.js +17 -0
  32. package/packages/plugin/dist/index.js.map +1 -0
  33. package/packages/plugin/dist/src/accounts.d.ts +12 -0
  34. package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
  35. package/packages/plugin/dist/src/accounts.js +103 -0
  36. package/packages/plugin/dist/src/accounts.js.map +1 -0
  37. package/packages/plugin/dist/src/channel.d.ts +206 -0
  38. package/packages/plugin/dist/src/channel.d.ts.map +1 -0
  39. package/packages/plugin/dist/src/channel.js +1248 -0
  40. package/packages/plugin/dist/src/channel.js.map +1 -0
  41. package/packages/plugin/dist/src/runtime.d.ts +3 -0
  42. package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
  43. package/packages/plugin/dist/src/runtime.js +18 -0
  44. package/packages/plugin/dist/src/runtime.js.map +1 -0
  45. package/packages/plugin/dist/src/types.d.ts +179 -0
  46. package/packages/plugin/dist/src/types.d.ts.map +1 -0
  47. package/packages/plugin/dist/src/types.js +6 -0
  48. package/packages/plugin/dist/src/types.js.map +1 -0
  49. package/packages/plugin/dist/src/ws-client.d.ts +51 -0
  50. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
  51. package/packages/plugin/dist/src/ws-client.js +170 -0
  52. package/packages/plugin/dist/src/ws-client.js.map +1 -0
  53. package/packages/plugin/openclaw.plugin.json +11 -0
  54. package/packages/plugin/package.json +39 -0
  55. package/packages/plugin/tsconfig.json +20 -0
  56. package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
  57. package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
  58. package/packages/web/dist/index.html +17 -0
  59. package/packages/web/index.html +16 -0
  60. package/packages/web/package.json +29 -0
  61. package/packages/web/postcss.config.js +6 -0
  62. package/packages/web/src/App.tsx +827 -0
  63. package/packages/web/src/api.ts +242 -0
  64. package/packages/web/src/components/ChatWindow.tsx +864 -0
  65. package/packages/web/src/components/CronDetail.tsx +943 -0
  66. package/packages/web/src/components/CronSidebar.tsx +123 -0
  67. package/packages/web/src/components/DebugLogPanel.tsx +258 -0
  68. package/packages/web/src/components/IconRail.tsx +163 -0
  69. package/packages/web/src/components/JobList.tsx +120 -0
  70. package/packages/web/src/components/LoginPage.tsx +178 -0
  71. package/packages/web/src/components/MessageContent.tsx +1082 -0
  72. package/packages/web/src/components/ModelSelect.tsx +87 -0
  73. package/packages/web/src/components/ScheduleEditor.tsx +403 -0
  74. package/packages/web/src/components/SessionTabs.tsx +246 -0
  75. package/packages/web/src/components/Sidebar.tsx +331 -0
  76. package/packages/web/src/components/TaskBar.tsx +413 -0
  77. package/packages/web/src/components/ThreadPanel.tsx +212 -0
  78. package/packages/web/src/debug-log.ts +58 -0
  79. package/packages/web/src/index.css +170 -0
  80. package/packages/web/src/main.tsx +10 -0
  81. package/packages/web/src/store.ts +492 -0
  82. package/packages/web/src/ws.ts +99 -0
  83. package/packages/web/tailwind.config.js +65 -0
  84. package/packages/web/tsconfig.json +18 -0
  85. package/packages/web/vite.config.ts +20 -0
  86. package/scripts/dev.sh +122 -0
  87. package/tsconfig.json +18 -0
  88. package/wrangler.toml +40 -0
@@ -0,0 +1,87 @@
1
+ import React, { useRef, useEffect, useState, useCallback } from "react";
2
+ import type { ModelInfo } from "../api";
3
+
4
+ type ModelSelectProps = {
5
+ value: string;
6
+ onChange: (modelId: string) => void;
7
+ models: ModelInfo[];
8
+ disabled?: boolean;
9
+ placeholder?: string;
10
+ compact?: boolean;
11
+ };
12
+
13
+ export function ModelSelect({
14
+ value,
15
+ onChange,
16
+ models,
17
+ disabled,
18
+ placeholder = "Select model...",
19
+ compact,
20
+ }: ModelSelectProps) {
21
+ // Try exact match by id first; fall back to matching by model name
22
+ // (e.g. value="gpt-5.2-chat" should resolve to id="azure-gpt52/gpt-5.2-chat")
23
+ const exactMatch = models.find((m) => m.id === value);
24
+ const nameMatch = !exactMatch && value ? models.find((m) => m.name === value) : null;
25
+ const effectiveValue = nameMatch ? nameMatch.id : value;
26
+
27
+ const currentInList = !effectiveValue || models.some((m) => m.id === effectiveValue);
28
+
29
+ // Auto-correct: persist the full model id when we resolved via name match
30
+ useEffect(() => {
31
+ if (nameMatch && value !== nameMatch.id) {
32
+ onChange(nameMatch.id);
33
+ }
34
+ }, [nameMatch, value, onChange]);
35
+
36
+ const selectRef = useRef<HTMLSelectElement>(null);
37
+ const [selectWidth, setSelectWidth] = useState<number | undefined>(undefined);
38
+
39
+ const displayText = effectiveValue || placeholder;
40
+
41
+ // Measure the text width using Canvas to match the select's actual font
42
+ const updateWidth = useCallback(() => {
43
+ if (!compact || !selectRef.current) return;
44
+ const cs = getComputedStyle(selectRef.current);
45
+ const canvas = document.createElement("canvas");
46
+ const ctx = canvas.getContext("2d");
47
+ if (!ctx) return;
48
+ ctx.font = `${cs.fontSize} ${cs.fontFamily}`;
49
+ const textW = ctx.measureText(displayText).width;
50
+ const padL = parseFloat(cs.paddingLeft) || 6;
51
+ const padR = parseFloat(cs.paddingRight) || 6;
52
+ // padding + ~20px native dropdown arrow + 2px buffer
53
+ setSelectWidth(Math.ceil(textW) + padL + padR + 22);
54
+ }, [compact, displayText]);
55
+
56
+ useEffect(() => { updateWidth(); }, [updateWidth]);
57
+
58
+ return (
59
+ <select
60
+ ref={selectRef}
61
+ value={effectiveValue}
62
+ onChange={(e) => onChange(e.target.value)}
63
+ disabled={disabled}
64
+ className={`rounded-sm focus:outline-none ${compact ? "text-caption py-0.5 px-1.5" : "text-body py-1.5 px-2.5"}`}
65
+ style={{
66
+ background: compact ? "transparent" : "var(--bg-hover)",
67
+ color: effectiveValue ? "var(--text-primary)" : "var(--text-muted)",
68
+ border: compact ? "1px solid transparent" : "1px solid var(--border)",
69
+ cursor: disabled ? "not-allowed" : "pointer",
70
+ opacity: disabled ? 0.5 : 1,
71
+ width: compact && selectWidth ? selectWidth : undefined,
72
+ maxWidth: "100%",
73
+ fontFamily: "var(--font-mono)",
74
+ textOverflow: "ellipsis",
75
+ overflow: "hidden",
76
+ }}
77
+ >
78
+ {!effectiveValue && <option value="">{placeholder}</option>}
79
+ {effectiveValue && !currentInList && (
80
+ <option value={effectiveValue}>{effectiveValue}</option>
81
+ )}
82
+ {models.map((m) => (
83
+ <option key={m.id} value={m.id}>{m.id}</option>
84
+ ))}
85
+ </select>
86
+ );
87
+ }
@@ -0,0 +1,403 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // OpenClaw CronService uses structured schedules:
5
+ // { kind: "every", everyMs: number } → interval-based
6
+ // { kind: "at", at: string } → fixed daily time
7
+ //
8
+ // The string format stored in D1 and sent to OpenClaw:
9
+ // "every 30m", "every 2h", "every 10s"
10
+ // "at 09:00", "at 14:30"
11
+ // ---------------------------------------------------------------------------
12
+
13
+ type ScheduleKind = "every" | "at";
14
+ type IntervalUnit = "s" | "m" | "h";
15
+
16
+ interface ParsedSchedule {
17
+ kind: ScheduleKind;
18
+ // "every" fields
19
+ intervalValue?: number;
20
+ intervalUnit?: IntervalUnit;
21
+ // "at" fields
22
+ atTime?: string; // HH:MM
23
+ }
24
+
25
+ /** Parse a human-readable schedule string into structured parts */
26
+ function parseSchedule(raw: string): ParsedSchedule | null {
27
+ if (!raw) return null;
28
+ const s = raw.trim().toLowerCase();
29
+
30
+ // Match "every Xh", "every Xm", "every Xs"
31
+ const everyMatch = s.match(/^every\s+(\d+(?:\.\d+)?)\s*(s|m|h)$/);
32
+ if (everyMatch) {
33
+ return {
34
+ kind: "every",
35
+ intervalValue: parseFloat(everyMatch[1]),
36
+ intervalUnit: everyMatch[2] as IntervalUnit,
37
+ };
38
+ }
39
+
40
+ // Match "at HH:MM"
41
+ const atMatch = s.match(/^at\s+(\d{1,2}:\d{2})$/);
42
+ if (atMatch) {
43
+ return {
44
+ kind: "at",
45
+ atTime: atMatch[1].padStart(5, "0"), // ensure "9:00" → "09:00"
46
+ };
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ /** Build a schedule string from structured parts */
53
+ function buildSchedule(parsed: ParsedSchedule): string {
54
+ if (parsed.kind === "every" && parsed.intervalValue && parsed.intervalUnit) {
55
+ return `every ${parsed.intervalValue}${parsed.intervalUnit}`;
56
+ }
57
+ if (parsed.kind === "at" && parsed.atTime) {
58
+ return `at ${parsed.atTime}`;
59
+ }
60
+ return "";
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Component
65
+ // ---------------------------------------------------------------------------
66
+
67
+ type ScheduleEditorProps = {
68
+ value: string;
69
+ onChange: (schedule: string) => void;
70
+ onSave: () => void;
71
+ onCancel: () => void;
72
+ saving?: boolean;
73
+ };
74
+
75
+ export function ScheduleEditor({
76
+ value,
77
+ onChange,
78
+ onSave,
79
+ onCancel,
80
+ saving,
81
+ }: ScheduleEditorProps) {
82
+ const parsed = parseSchedule(value);
83
+
84
+ const [kind, setKind] = useState<ScheduleKind>(parsed?.kind ?? "every");
85
+ const [intervalValue, setIntervalValue] = useState<number>(parsed?.intervalValue ?? 1);
86
+ const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(parsed?.intervalUnit ?? "h");
87
+ const [atTime, setAtTime] = useState<string>(parsed?.atTime ?? "09:00");
88
+
89
+ // Re-sync when external value changes
90
+ useEffect(() => {
91
+ const p = parseSchedule(value);
92
+ if (p) {
93
+ setKind(p.kind);
94
+ if (p.kind === "every") {
95
+ setIntervalValue(p.intervalValue ?? 1);
96
+ setIntervalUnit(p.intervalUnit ?? "h");
97
+ } else {
98
+ setAtTime(p.atTime ?? "09:00");
99
+ }
100
+ }
101
+ }, [value]);
102
+
103
+ // Emit the schedule string whenever fields change
104
+ const emitChange = useCallback(
105
+ (k: ScheduleKind, iv: number, iu: IntervalUnit, at: string) => {
106
+ const schedule = buildSchedule(
107
+ k === "every"
108
+ ? { kind: "every", intervalValue: iv, intervalUnit: iu }
109
+ : { kind: "at", atTime: at },
110
+ );
111
+ onChange(schedule);
112
+ },
113
+ [onChange],
114
+ );
115
+
116
+ const handleKindChange = (k: ScheduleKind) => {
117
+ setKind(k);
118
+ emitChange(k, intervalValue, intervalUnit, atTime);
119
+ };
120
+
121
+ const handleIntervalValueChange = (v: number) => {
122
+ const clamped = Math.max(1, Math.min(v, 999));
123
+ setIntervalValue(clamped);
124
+ emitChange(kind, clamped, intervalUnit, atTime);
125
+ };
126
+
127
+ const handleIntervalUnitChange = (u: IntervalUnit) => {
128
+ setIntervalUnit(u);
129
+ emitChange(kind, intervalValue, u, atTime);
130
+ };
131
+
132
+ const handleAtTimeChange = (t: string) => {
133
+ setAtTime(t);
134
+ emitChange(kind, intervalValue, intervalUnit, t);
135
+ };
136
+
137
+ const handleKeyDown = (e: React.KeyboardEvent) => {
138
+ if (e.key === "Escape") onCancel();
139
+ if (e.key === "Enter") {
140
+ e.preventDefault();
141
+ onSave();
142
+ }
143
+ };
144
+
145
+ return (
146
+ <div className="flex flex-col gap-2" onKeyDown={handleKeyDown}>
147
+ {/* Row 1: Kind selector tabs */}
148
+ <div className="flex items-center gap-1">
149
+ <KindTab
150
+ active={kind === "every"}
151
+ onClick={() => handleKindChange("every")}
152
+ label="Interval"
153
+ icon={
154
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
155
+ <path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
156
+ </svg>
157
+ }
158
+ />
159
+ <KindTab
160
+ active={kind === "at"}
161
+ onClick={() => handleKindChange("at")}
162
+ label="Daily at"
163
+ icon={
164
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
165
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
166
+ </svg>
167
+ }
168
+ />
169
+ </div>
170
+
171
+ {/* Row 2: Schedule-specific inputs */}
172
+ <div className="flex items-center gap-2">
173
+ {kind === "every" ? (
174
+ <>
175
+ <span className="text-caption flex-shrink-0" style={{ color: "var(--text-muted)" }}>
176
+ Every
177
+ </span>
178
+ <input
179
+ type="number"
180
+ min={1}
181
+ max={999}
182
+ value={intervalValue}
183
+ onChange={(e) => handleIntervalValueChange(parseInt(e.target.value, 10) || 1)}
184
+ className="text-body px-2 py-1 rounded-sm focus:outline-none w-16 text-center"
185
+ style={{
186
+ background: "var(--bg-hover)",
187
+ color: "var(--text-primary)",
188
+ border: "1px solid var(--bg-active)",
189
+ }}
190
+ autoFocus
191
+ />
192
+ <div className="flex items-center gap-0.5">
193
+ <UnitButton
194
+ active={intervalUnit === "m"}
195
+ onClick={() => handleIntervalUnitChange("m")}
196
+ label="min"
197
+ />
198
+ <UnitButton
199
+ active={intervalUnit === "h"}
200
+ onClick={() => handleIntervalUnitChange("h")}
201
+ label="hr"
202
+ />
203
+ <UnitButton
204
+ active={intervalUnit === "s"}
205
+ onClick={() => handleIntervalUnitChange("s")}
206
+ label="sec"
207
+ />
208
+ </div>
209
+ </>
210
+ ) : (
211
+ <>
212
+ <span className="text-caption flex-shrink-0" style={{ color: "var(--text-muted)" }}>
213
+ Daily at
214
+ </span>
215
+ <input
216
+ type="time"
217
+ value={atTime}
218
+ onChange={(e) => handleAtTimeChange(e.target.value)}
219
+ className="text-body px-2 py-1 rounded-sm focus:outline-none"
220
+ style={{
221
+ background: "var(--bg-hover)",
222
+ color: "var(--text-primary)",
223
+ border: "1px solid var(--bg-active)",
224
+ }}
225
+ autoFocus
226
+ />
227
+ </>
228
+ )}
229
+
230
+ {/* Save / Cancel */}
231
+ <div className="flex items-center gap-1 ml-auto flex-shrink-0">
232
+ <button
233
+ onClick={onSave}
234
+ disabled={saving}
235
+ className="px-2 py-1 text-tiny font-bold text-white rounded-sm disabled:opacity-50"
236
+ style={{ background: "var(--bg-active)" }}
237
+ >
238
+ {saving ? "..." : "Save"}
239
+ </button>
240
+ <button
241
+ onClick={onCancel}
242
+ className="px-2 py-1 text-tiny rounded-sm"
243
+ style={{ color: "var(--text-muted)" }}
244
+ >
245
+ Cancel
246
+ </button>
247
+ </div>
248
+ </div>
249
+
250
+ {/* Preview */}
251
+ <div className="text-tiny" style={{ color: "var(--text-muted)" }}>
252
+ {kind === "every"
253
+ ? `Runs every ${intervalValue} ${intervalUnit === "h" ? "hour" : intervalUnit === "m" ? "minute" : "second"}${intervalValue !== 1 ? "s" : ""}`
254
+ : `Runs daily at ${atTime}`}
255
+ </div>
256
+ </div>
257
+ );
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Display-only component: shows schedule in a readable format
262
+ // ---------------------------------------------------------------------------
263
+
264
+ export function ScheduleDisplay({
265
+ schedule,
266
+ onClick,
267
+ }: {
268
+ schedule: string | null;
269
+ onClick: () => void;
270
+ }) {
271
+ if (!schedule) {
272
+ return (
273
+ <span
274
+ className="text-body cursor-pointer hover:underline"
275
+ style={{ color: "var(--text-muted)" }}
276
+ onClick={onClick}
277
+ title="Click to set schedule"
278
+ >
279
+ Not set
280
+ </span>
281
+ );
282
+ }
283
+
284
+ const parsed = parseSchedule(schedule);
285
+
286
+ if (!parsed) {
287
+ // Unrecognised format — show raw string, let user fix
288
+ return (
289
+ <span
290
+ className="text-body cursor-pointer hover:underline"
291
+ style={{ color: "var(--text-primary)" }}
292
+ onClick={onClick}
293
+ title="Click to edit schedule"
294
+ >
295
+ {schedule}
296
+ </span>
297
+ );
298
+ }
299
+
300
+ return (
301
+ <button
302
+ className="flex items-center gap-1.5 cursor-pointer group"
303
+ onClick={onClick}
304
+ title="Click to edit schedule"
305
+ >
306
+ {parsed.kind === "every" ? (
307
+ <>
308
+ <svg
309
+ className="w-3.5 h-3.5 flex-shrink-0"
310
+ fill="none"
311
+ viewBox="0 0 24 24"
312
+ stroke="currentColor"
313
+ strokeWidth={1.5}
314
+ style={{ color: "var(--text-muted)" }}
315
+ >
316
+ <path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
317
+ </svg>
318
+ <span
319
+ className="text-body group-hover:underline"
320
+ style={{ color: "var(--text-primary)" }}
321
+ >
322
+ Every {parsed.intervalValue}
323
+ {parsed.intervalUnit === "h" ? "h" : parsed.intervalUnit === "m" ? "m" : "s"}
324
+ </span>
325
+ </>
326
+ ) : (
327
+ <>
328
+ <svg
329
+ className="w-3.5 h-3.5 flex-shrink-0"
330
+ fill="none"
331
+ viewBox="0 0 24 24"
332
+ stroke="currentColor"
333
+ strokeWidth={1.5}
334
+ style={{ color: "var(--text-muted)" }}
335
+ >
336
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
337
+ </svg>
338
+ <span
339
+ className="text-body group-hover:underline"
340
+ style={{ color: "var(--text-primary)" }}
341
+ >
342
+ Daily at {parsed.atTime}
343
+ </span>
344
+ </>
345
+ )}
346
+ </button>
347
+ );
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Sub-components
352
+ // ---------------------------------------------------------------------------
353
+
354
+ function KindTab({
355
+ active,
356
+ onClick,
357
+ label,
358
+ icon,
359
+ }: {
360
+ active: boolean;
361
+ onClick: () => void;
362
+ label: string;
363
+ icon: React.ReactNode;
364
+ }) {
365
+ return (
366
+ <button
367
+ onClick={onClick}
368
+ className="flex items-center gap-1.5 px-2.5 py-1 text-caption rounded-sm transition-colors"
369
+ style={{
370
+ background: active ? "rgba(29,155,209,0.15)" : "transparent",
371
+ color: active ? "var(--text-link)" : "var(--text-muted)",
372
+ border: active ? "1px solid rgba(29,155,209,0.3)" : "1px solid transparent",
373
+ }}
374
+ >
375
+ {icon}
376
+ {label}
377
+ </button>
378
+ );
379
+ }
380
+
381
+ function UnitButton({
382
+ active,
383
+ onClick,
384
+ label,
385
+ }: {
386
+ active: boolean;
387
+ onClick: () => void;
388
+ label: string;
389
+ }) {
390
+ return (
391
+ <button
392
+ onClick={onClick}
393
+ className="px-2 py-1 text-caption rounded-sm transition-colors"
394
+ style={{
395
+ background: active ? "var(--bg-active)" : "var(--bg-hover)",
396
+ color: active ? "#fff" : "var(--text-secondary)",
397
+ border: active ? "1px solid var(--bg-active)" : "1px solid var(--border)",
398
+ }}
399
+ >
400
+ {label}
401
+ </button>
402
+ );
403
+ }