@polderlabs/bizar-dash 3.0.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.
- package/dist/assets/index-B5X9g8B4.css +1 -0
- package/dist/assets/index-LqQuSp9d.js +388 -0
- package/dist/assets/index-LqQuSp9d.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +67 -0
- package/src/cli.mjs +228 -0
- package/src/server/agents-store.mjs +190 -0
- package/src/server/api.mjs +913 -0
- package/src/server/browser.mjs +40 -0
- package/src/server/diagnostics-store.mjs +138 -0
- package/src/server/mods-loader.mjs +361 -0
- package/src/server/projects-store.mjs +198 -0
- package/src/server/providers-store.mjs +183 -0
- package/src/server/schedules-runner.mjs +150 -0
- package/src/server/schedules-store.mjs +233 -0
- package/src/server/search-store.mjs +120 -0
- package/src/server/server.mjs +388 -0
- package/src/server/state.mjs +357 -0
- package/src/server/tailscale-store.mjs +113 -0
- package/src/server/tasks-store.mjs +275 -0
- package/src/server/tui.mjs +844 -0
- package/src/server/watcher.mjs +81 -0
- package/src/web/App.tsx +316 -0
- package/src/web/components/Button.tsx +55 -0
- package/src/web/components/Card.tsx +40 -0
- package/src/web/components/EmptyState.tsx +30 -0
- package/src/web/components/Modal.tsx +137 -0
- package/src/web/components/SearchModal.tsx +185 -0
- package/src/web/components/Spinner.tsx +19 -0
- package/src/web/components/StatusBadge.tsx +25 -0
- package/src/web/components/Tag.tsx +28 -0
- package/src/web/components/Toast.tsx +142 -0
- package/src/web/components/Topbar.tsx +203 -0
- package/src/web/index.html +17 -0
- package/src/web/lib/api.ts +71 -0
- package/src/web/lib/markdown.tsx +59 -0
- package/src/web/lib/types.ts +388 -0
- package/src/web/lib/utils.ts +79 -0
- package/src/web/lib/ws.ts +132 -0
- package/src/web/main.tsx +12 -0
- package/src/web/styles/main.css +3148 -0
- package/src/web/views/Agents.tsx +406 -0
- package/src/web/views/Chat.tsx +527 -0
- package/src/web/views/Config.tsx +683 -0
- package/src/web/views/Mods.tsx +350 -0
- package/src/web/views/Overview.tsx +350 -0
- package/src/web/views/Plans.tsx +667 -0
- package/src/web/views/Schedules.tsx +299 -0
- package/src/web/views/Settings.tsx +571 -0
- package/src/web/views/Tasks.tsx +761 -0
- package/templates/mod/FORMAT.md +76 -0
- package/templates/mod/hello-mod/README.md +19 -0
- package/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/templates/mod/hello-mod/commands/hello.md +6 -0
- package/templates/mod/hello-mod/mod.json +20 -0
- package/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// src/views/Agents.tsx — editable agent grid with CRUD modal.
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { Bot, Plus, RefreshCw, Pencil, Trash2, Play, Save, X } from 'lucide-react';
|
|
4
|
+
import { Button } from '../components/Button';
|
|
5
|
+
import { Card, CardTitle } from '../components/Card';
|
|
6
|
+
import { EmptyState } from '../components/EmptyState';
|
|
7
|
+
import { Spinner } from '../components/Spinner';
|
|
8
|
+
import { StatusBadge } from '../components/StatusBadge';
|
|
9
|
+
import { useModal } from '../components/Modal';
|
|
10
|
+
import { useToast } from '../components/Toast';
|
|
11
|
+
import { api } from '../lib/api';
|
|
12
|
+
import { formatRelative, truncate } from '../lib/utils';
|
|
13
|
+
import type { Agent, Settings, Snapshot } from '../lib/types';
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
snapshot: Snapshot;
|
|
17
|
+
settings: Settings;
|
|
18
|
+
activeTab: string;
|
|
19
|
+
setActiveTab: (id: string) => void;
|
|
20
|
+
refreshSnapshot: () => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const TOOL_OPTIONS = ['bash', 'read', 'edit', 'write', 'webfetch', 'websearch', 'task', 'glob', 'grep'];
|
|
24
|
+
const MODELS = [
|
|
25
|
+
'anthropic/claude-3-5-sonnet',
|
|
26
|
+
'anthropic/claude-3-5-haiku',
|
|
27
|
+
'openai/gpt-4o',
|
|
28
|
+
'openai/gpt-4o-mini',
|
|
29
|
+
'minimax/MiniMax-M3',
|
|
30
|
+
'minimax/MiniMax-M2.7',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export function Agents({ snapshot, refreshSnapshot }: Props) {
|
|
34
|
+
const toast = useToast();
|
|
35
|
+
const modal = useModal();
|
|
36
|
+
const [agents, setAgents] = useState<Agent[]>(snapshot.agents || []);
|
|
37
|
+
const [loading, setLoading] = useState(!snapshot.agents);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (snapshot.agents?.length || snapshot.agents) {
|
|
41
|
+
setAgents(snapshot.agents || []);
|
|
42
|
+
setLoading(false);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
reload();
|
|
46
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
47
|
+
}, [snapshot.agents]);
|
|
48
|
+
|
|
49
|
+
const reload = async () => {
|
|
50
|
+
try {
|
|
51
|
+
const d = await api.get<{ agents: Agent[] }>('/agents');
|
|
52
|
+
setAgents(d.agents || []);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
toast.error(`Agents load failed: ${(err as Error).message}`);
|
|
55
|
+
} finally {
|
|
56
|
+
setLoading(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const sorted = useMemo(
|
|
61
|
+
() => [...agents].sort((a, b) => a.name.localeCompare(b.name)),
|
|
62
|
+
[agents],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const onCreate = () => {
|
|
66
|
+
let nameEl: HTMLInputElement | null = null;
|
|
67
|
+
let descEl: HTMLTextAreaElement | null = null;
|
|
68
|
+
let modelEl: HTMLSelectElement | null = null;
|
|
69
|
+
let modeEl: HTMLSelectElement | null = null;
|
|
70
|
+
let colorEl: HTMLInputElement | null = null;
|
|
71
|
+
let promptEl: HTMLTextAreaElement | null = null;
|
|
72
|
+
let toolsContainer: HTMLDivElement | null = null;
|
|
73
|
+
|
|
74
|
+
modal.open({
|
|
75
|
+
title: 'New agent',
|
|
76
|
+
width: 640,
|
|
77
|
+
children: (
|
|
78
|
+
<div className="agent-form">
|
|
79
|
+
<label className="field-label">Name (a-z, 0-9, dashes)</label>
|
|
80
|
+
<input
|
|
81
|
+
ref={(el) => (nameEl = el)}
|
|
82
|
+
className="input"
|
|
83
|
+
type="text"
|
|
84
|
+
placeholder="my-agent"
|
|
85
|
+
autoFocus
|
|
86
|
+
/>
|
|
87
|
+
<label className="field-label">Description</label>
|
|
88
|
+
<input
|
|
89
|
+
ref={(el) => { descEl = el as unknown as HTMLTextAreaElement | null; }}
|
|
90
|
+
className="input"
|
|
91
|
+
type="text"
|
|
92
|
+
placeholder="What does this agent do?"
|
|
93
|
+
/>
|
|
94
|
+
<div className="task-form-row">
|
|
95
|
+
<div className="task-form-field">
|
|
96
|
+
<label className="field-label">Model</label>
|
|
97
|
+
<select ref={(el) => (modelEl = el)} className="select" defaultValue="">
|
|
98
|
+
<option value="">(provider default)</option>
|
|
99
|
+
{MODELS.map((m) => (
|
|
100
|
+
<option key={m} value={m}>{m}</option>
|
|
101
|
+
))}
|
|
102
|
+
</select>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="task-form-field">
|
|
105
|
+
<label className="field-label">Mode</label>
|
|
106
|
+
<select ref={(el) => (modeEl = el)} className="select" defaultValue="subagent">
|
|
107
|
+
<option value="primary">primary</option>
|
|
108
|
+
<option value="subagent">subagent</option>
|
|
109
|
+
<option value="all">all</option>
|
|
110
|
+
</select>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="task-form-field" style={{ flex: '0 0 80px' }}>
|
|
113
|
+
<label className="field-label">Color</label>
|
|
114
|
+
<input ref={(el) => (colorEl = el)} className="input" type="color" defaultValue="#8b5cf6" />
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<label className="field-label">Tools</label>
|
|
118
|
+
<div ref={(el) => (toolsContainer = el)} className="agent-tools">
|
|
119
|
+
{TOOL_OPTIONS.map((t) => (
|
|
120
|
+
<label key={t} className="checkbox-row">
|
|
121
|
+
<input type="checkbox" value={t} />
|
|
122
|
+
<span>{t}</span>
|
|
123
|
+
</label>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
<label className="field-label">System prompt</label>
|
|
127
|
+
<textarea
|
|
128
|
+
ref={(el) => (promptEl = el)}
|
|
129
|
+
className="textarea"
|
|
130
|
+
rows={6}
|
|
131
|
+
placeholder="You are a..."
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
),
|
|
135
|
+
footer: (
|
|
136
|
+
<div className="modal-footer-actions">
|
|
137
|
+
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
138
|
+
<Button
|
|
139
|
+
variant="primary"
|
|
140
|
+
onClick={async () => {
|
|
141
|
+
const name = (nameEl?.value || '').trim();
|
|
142
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/i.test(name)) {
|
|
143
|
+
toast.warning('Invalid name (a-z, 0-9, dashes).');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const tools: string[] = [];
|
|
147
|
+
if (toolsContainer) {
|
|
148
|
+
toolsContainer.querySelectorAll<HTMLInputElement>('input[type="checkbox"]:checked').forEach((cb) => {
|
|
149
|
+
tools.push(cb.value);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const created = await api.post<Agent>('/agents', {
|
|
154
|
+
name,
|
|
155
|
+
description: (descEl?.value || '').trim(),
|
|
156
|
+
model: modelEl?.value || '',
|
|
157
|
+
mode: modeEl?.value || 'subagent',
|
|
158
|
+
color: colorEl?.value || '',
|
|
159
|
+
tools,
|
|
160
|
+
prompt: promptEl?.value || '',
|
|
161
|
+
});
|
|
162
|
+
setAgents((cur) => [...cur, created]);
|
|
163
|
+
toast.success('Agent created.');
|
|
164
|
+
modal.close();
|
|
165
|
+
await refreshSnapshot();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
toast.error(`Create failed: ${(err as Error).message}`);
|
|
168
|
+
}
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<Save size={12} /> Create
|
|
172
|
+
</Button>
|
|
173
|
+
</div>
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const onEdit = async (a: Agent) => {
|
|
179
|
+
let descEl: HTMLTextAreaElement | null = null;
|
|
180
|
+
let modelEl: HTMLSelectElement | null = null;
|
|
181
|
+
let modeEl: HTMLSelectElement | null = null;
|
|
182
|
+
let colorEl: HTMLInputElement | null = null;
|
|
183
|
+
let promptEl: HTMLTextAreaElement | null = null;
|
|
184
|
+
let toolsContainer: HTMLDivElement | null = null;
|
|
185
|
+
try {
|
|
186
|
+
const full = await api.get<Agent>(`/agents/${encodeURIComponent(a.name)}`);
|
|
187
|
+
modal.open({
|
|
188
|
+
title: `Edit ${a.name}`,
|
|
189
|
+
width: 640,
|
|
190
|
+
children: (
|
|
191
|
+
<div className="agent-form">
|
|
192
|
+
<div className="muted">
|
|
193
|
+
File: <code>{full.path}</code>
|
|
194
|
+
</div>
|
|
195
|
+
<label className="field-label">Description</label>
|
|
196
|
+
<input
|
|
197
|
+
ref={(el) => { descEl = el as unknown as HTMLTextAreaElement | null; }}
|
|
198
|
+
className="input"
|
|
199
|
+
type="text"
|
|
200
|
+
defaultValue={full.description}
|
|
201
|
+
/>
|
|
202
|
+
<div className="task-form-row">
|
|
203
|
+
<div className="task-form-field">
|
|
204
|
+
<label className="field-label">Model</label>
|
|
205
|
+
<select ref={(el) => (modelEl = el)} className="select" defaultValue={full.model}>
|
|
206
|
+
<option value="">(provider default)</option>
|
|
207
|
+
{MODELS.map((m) => (
|
|
208
|
+
<option key={m} value={m}>{m}</option>
|
|
209
|
+
))}
|
|
210
|
+
</select>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="task-form-field">
|
|
213
|
+
<label className="field-label">Mode</label>
|
|
214
|
+
<select ref={(el) => (modeEl = el)} className="select" defaultValue={full.mode || 'subagent'}>
|
|
215
|
+
<option value="primary">primary</option>
|
|
216
|
+
<option value="subagent">subagent</option>
|
|
217
|
+
<option value="all">all</option>
|
|
218
|
+
</select>
|
|
219
|
+
</div>
|
|
220
|
+
<div className="task-form-field" style={{ flex: '0 0 80px' }}>
|
|
221
|
+
<label className="field-label">Color</label>
|
|
222
|
+
<input ref={(el) => (colorEl = el)} className="input" type="color" defaultValue={full.color || '#8b5cf6'} />
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
<label className="field-label">Tools</label>
|
|
226
|
+
<div ref={(el) => (toolsContainer = el)} className="agent-tools">
|
|
227
|
+
{TOOL_OPTIONS.map((t) => (
|
|
228
|
+
<label key={t} className="checkbox-row">
|
|
229
|
+
<input type="checkbox" value={t} defaultChecked={full.tools?.includes(t)} />
|
|
230
|
+
<span>{t}</span>
|
|
231
|
+
</label>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
<label className="field-label">System prompt</label>
|
|
235
|
+
<textarea
|
|
236
|
+
ref={(el) => (promptEl = el)}
|
|
237
|
+
className="textarea"
|
|
238
|
+
rows={8}
|
|
239
|
+
defaultValue={full.prompt || ''}
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
),
|
|
243
|
+
footer: (
|
|
244
|
+
<div className="modal-footer-actions">
|
|
245
|
+
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
246
|
+
<Button
|
|
247
|
+
variant="primary"
|
|
248
|
+
onClick={async () => {
|
|
249
|
+
const tools: string[] = [];
|
|
250
|
+
if (toolsContainer) {
|
|
251
|
+
toolsContainer.querySelectorAll<HTMLInputElement>('input[type="checkbox"]:checked').forEach((cb) => {
|
|
252
|
+
tools.push(cb.value);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const updated = await api.put<Agent>(
|
|
257
|
+
`/agents/${encodeURIComponent(a.name)}`,
|
|
258
|
+
{
|
|
259
|
+
description: (descEl?.value || '').trim(),
|
|
260
|
+
model: modelEl?.value || '',
|
|
261
|
+
mode: modeEl?.value || 'subagent',
|
|
262
|
+
color: colorEl?.value || '',
|
|
263
|
+
tools,
|
|
264
|
+
prompt: promptEl?.value || '',
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
setAgents((cur) => cur.map((x) => (x.name === a.name ? updated : x)));
|
|
268
|
+
toast.success('Agent saved.');
|
|
269
|
+
modal.close();
|
|
270
|
+
await refreshSnapshot();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
273
|
+
}
|
|
274
|
+
}}
|
|
275
|
+
>
|
|
276
|
+
<Save size={12} /> Save
|
|
277
|
+
</Button>
|
|
278
|
+
</div>
|
|
279
|
+
),
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
toast.error(`Load failed: ${(err as Error).message}`);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const onDelete = async (a: Agent) => {
|
|
287
|
+
if (!confirm(`Delete agent "${a.name}"? This removes ${a.path}.`)) return;
|
|
288
|
+
try {
|
|
289
|
+
await api.del(`/agents/${encodeURIComponent(a.name)}`);
|
|
290
|
+
setAgents((cur) => cur.filter((x) => x.name !== a.name));
|
|
291
|
+
toast.success('Agent deleted.');
|
|
292
|
+
} catch (err) {
|
|
293
|
+
toast.error(`Delete failed: ${(err as Error).message}`);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const onInvoke = async (a: Agent) => {
|
|
298
|
+
let promptEl: HTMLTextAreaElement | null = null;
|
|
299
|
+
modal.open({
|
|
300
|
+
title: `Invoke ${a.name}`,
|
|
301
|
+
children: (
|
|
302
|
+
<div className="invoke-form">
|
|
303
|
+
<p className="muted invoke-form-meta mono">{a.model || '—'} · {a.path}</p>
|
|
304
|
+
<p className="invoke-form-desc">{a.description}</p>
|
|
305
|
+
<label className="field-label" htmlFor="invoke-prompt">Prompt</label>
|
|
306
|
+
<textarea
|
|
307
|
+
ref={(el) => (promptEl = el)}
|
|
308
|
+
id="invoke-prompt"
|
|
309
|
+
className="textarea"
|
|
310
|
+
rows={5}
|
|
311
|
+
placeholder="What should this agent do?"
|
|
312
|
+
autoFocus
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
),
|
|
316
|
+
footer: (
|
|
317
|
+
<div className="modal-footer-actions">
|
|
318
|
+
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
319
|
+
<Button
|
|
320
|
+
variant="primary"
|
|
321
|
+
onClick={async () => {
|
|
322
|
+
const prompt = (promptEl?.value || '').trim();
|
|
323
|
+
if (!prompt) {
|
|
324
|
+
toast.warning('Prompt is required.');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
await api.post(`/agents/${encodeURIComponent(a.name)}/invoke`, { prompt });
|
|
329
|
+
toast.success(`Invoked ${a.name}.`);
|
|
330
|
+
modal.close();
|
|
331
|
+
} catch (err) {
|
|
332
|
+
toast.error(`Invoke failed: ${(err as Error).message}`);
|
|
333
|
+
}
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
<Play size={14} /> Invoke
|
|
337
|
+
</Button>
|
|
338
|
+
</div>
|
|
339
|
+
),
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<div className="view view-agents">
|
|
345
|
+
<header className="view-header">
|
|
346
|
+
<div className="view-header-text">
|
|
347
|
+
<h2 className="view-title">
|
|
348
|
+
<Bot size={18} /> Agents ({sorted.length})
|
|
349
|
+
</h2>
|
|
350
|
+
<p className="view-subtitle">
|
|
351
|
+
The Norse pantheon — click <kbd>Edit</kbd> to modify or <kbd>Invoke</kbd> to dispatch.
|
|
352
|
+
</p>
|
|
353
|
+
</div>
|
|
354
|
+
<div className="view-actions">
|
|
355
|
+
<Button variant="secondary" size="sm" onClick={reload}>
|
|
356
|
+
<RefreshCw size={14} /> Refresh
|
|
357
|
+
</Button>
|
|
358
|
+
<Button variant="primary" size="sm" onClick={onCreate}>
|
|
359
|
+
<Plus size={14} /> New agent
|
|
360
|
+
</Button>
|
|
361
|
+
</div>
|
|
362
|
+
</header>
|
|
363
|
+
|
|
364
|
+
{loading ? (
|
|
365
|
+
<div className="view-loading"><Spinner size="lg" /></div>
|
|
366
|
+
) : sorted.length === 0 ? (
|
|
367
|
+
<EmptyState
|
|
368
|
+
icon={<Bot size={32} />}
|
|
369
|
+
title="No agents found"
|
|
370
|
+
message="Run bizar in the terminal to install Bizar."
|
|
371
|
+
/>
|
|
372
|
+
) : (
|
|
373
|
+
<div className="agent-grid">
|
|
374
|
+
{sorted.map((a) => (
|
|
375
|
+
<Card key={a.name} variant="elevated" interactive className="agent-card">
|
|
376
|
+
<div className="agent-card-head">
|
|
377
|
+
<div className="agent-card-name">{a.name}</div>
|
|
378
|
+
<StatusBadge kind={a.mode === 'primary' ? 'accent' : 'neutral'}>
|
|
379
|
+
{a.mode || 'agent'}
|
|
380
|
+
</StatusBadge>
|
|
381
|
+
</div>
|
|
382
|
+
<p className="agent-card-desc">{truncate(a.description, 200)}</p>
|
|
383
|
+
<div className="agent-card-meta">
|
|
384
|
+
<span className="mono" title={a.model || ''}>
|
|
385
|
+
{a.model || '—'}
|
|
386
|
+
</span>
|
|
387
|
+
<span className="tabular-nums muted">{formatRelative(a.mtime)}</span>
|
|
388
|
+
</div>
|
|
389
|
+
<div className="agent-card-actions">
|
|
390
|
+
<Button variant="primary" size="sm" onClick={() => onInvoke(a)}>
|
|
391
|
+
<Play size={12} /> Invoke
|
|
392
|
+
</Button>
|
|
393
|
+
<Button variant="secondary" size="sm" onClick={() => onEdit(a)}>
|
|
394
|
+
<Pencil size={12} /> Edit
|
|
395
|
+
</Button>
|
|
396
|
+
<Button variant="ghost" size="sm" onClick={() => onDelete(a)}>
|
|
397
|
+
<Trash2 size={12} /> Delete
|
|
398
|
+
</Button>
|
|
399
|
+
</div>
|
|
400
|
+
</Card>
|
|
401
|
+
))}
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|