shraga 0.0.3 → 0.1.1
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/README.md +82 -23
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +59 -14
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- package/src/server/workspace.ts +249 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import cronstrue from 'cronstrue';
|
|
3
|
+
import type { Schedule, Trigger, Task } from '@/lib/schedule-types';
|
|
4
|
+
import { Button } from '../ui/button';
|
|
5
|
+
import { Input } from '../ui/input';
|
|
6
|
+
import { Textarea } from '../ui/textarea';
|
|
7
|
+
import { AutocompleteTextarea } from '../AutocompleteTextarea';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
initial?: Schedule;
|
|
11
|
+
onSave: (s: Partial<Schedule>) => Promise<void>;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
skills?: string[];
|
|
14
|
+
workspaceFiles?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function localDateTimeValue(ts?: number): string {
|
|
18
|
+
const d = new Date(ts ?? Date.now() + 60_000);
|
|
19
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
20
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const INTERVAL_PRESETS = [
|
|
24
|
+
{ label: '1 min', ms: 60_000 },
|
|
25
|
+
{ label: '5 min', ms: 5 * 60_000 },
|
|
26
|
+
{ label: '15 min', ms: 15 * 60_000 },
|
|
27
|
+
{ label: '1 hour', ms: 60 * 60_000 },
|
|
28
|
+
{ label: '6 hours', ms: 6 * 60 * 60_000 },
|
|
29
|
+
{ label: '1 day', ms: 24 * 60 * 60_000 },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const CRON_PRESETS = [
|
|
33
|
+
{ label: 'Every hour', expr: '0 * * * *' },
|
|
34
|
+
{ label: 'Daily 9am', expr: '0 9 * * *' },
|
|
35
|
+
{ label: 'Weekdays 9am', expr: '0 9 * * 1-5' },
|
|
36
|
+
{ label: 'Mondays 9am', expr: '0 9 * * 1' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Edits the optional `match` filter on an event trigger: a map of payload
|
|
40
|
+
* dot-paths → expected values. All entries must match (AND). Empty → fires on any. */
|
|
41
|
+
function MatchEditor({ match, onChange }: { match: Record<string, string>; onChange: (m: Record<string, string>) => void }) {
|
|
42
|
+
const rows = Object.entries(match);
|
|
43
|
+
const setRow = (i: number, key: string, value: string) => {
|
|
44
|
+
const next: Record<string, string> = {};
|
|
45
|
+
rows.forEach(([k, v], idx) => { next[idx === i ? key : k] = idx === i ? value : v; });
|
|
46
|
+
onChange(next);
|
|
47
|
+
};
|
|
48
|
+
const removeRow = (i: number) => onChange(Object.fromEntries(rows.filter((_, idx) => idx !== i)));
|
|
49
|
+
const addRow = () => onChange({ ...match, '': '' });
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="space-y-1">
|
|
53
|
+
<label className="text-xs text-muted-foreground">Match filter (optional — all must match)</label>
|
|
54
|
+
{rows.map(([k, v], i) => (
|
|
55
|
+
<div key={i} className="flex gap-1">
|
|
56
|
+
<Input className="text-xs font-mono" value={k} onChange={(e) => setRow(i, e.target.value, v)} placeholder="payload.path (e.g. type)" />
|
|
57
|
+
<Input className="text-xs font-mono" value={v} onChange={(e) => setRow(i, k, e.target.value)} placeholder="value (e.g. invoice.paid)" />
|
|
58
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => removeRow(i)}>×</Button>
|
|
59
|
+
</div>
|
|
60
|
+
))}
|
|
61
|
+
<Button type="button" variant="outline" size="sm" onClick={addRow}>+ Add filter</Button>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ScheduleEditor({ initial, onSave, onCancel, skills = [], workspaceFiles = [] }: Props) {
|
|
67
|
+
const tz = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []);
|
|
68
|
+
const [name, setName] = useState(initial?.name ?? 'New schedule');
|
|
69
|
+
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
|
70
|
+
const [trigger, setTrigger] = useState<Trigger>(
|
|
71
|
+
initial?.trigger ?? { kind: 'cron', expr: '0 9 * * *', tz }
|
|
72
|
+
);
|
|
73
|
+
const [task, setTask] = useState<Task>(
|
|
74
|
+
initial?.task ?? { kind: 'prompt', prompt: '' }
|
|
75
|
+
);
|
|
76
|
+
const [saving, setSaving] = useState(false);
|
|
77
|
+
const [err, setErr] = useState<string | null>(null);
|
|
78
|
+
|
|
79
|
+
const cronDesc = useMemo(() => {
|
|
80
|
+
if (trigger.kind !== 'cron') return '';
|
|
81
|
+
try { return cronstrue.toString(trigger.expr); } catch { return 'Invalid cron'; }
|
|
82
|
+
}, [trigger]);
|
|
83
|
+
|
|
84
|
+
const save = async () => {
|
|
85
|
+
setSaving(true);
|
|
86
|
+
setErr(null);
|
|
87
|
+
try {
|
|
88
|
+
await onSave({ name, enabled, trigger, task });
|
|
89
|
+
} catch (e: any) {
|
|
90
|
+
setErr(e.message);
|
|
91
|
+
} finally {
|
|
92
|
+
setSaving(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="space-y-4">
|
|
98
|
+
<div className="space-y-1">
|
|
99
|
+
<label className="text-xs text-muted-foreground">Name</label>
|
|
100
|
+
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Daily standup summary" />
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Trigger */}
|
|
104
|
+
<div className="space-y-2">
|
|
105
|
+
<label className="text-xs text-muted-foreground">Trigger</label>
|
|
106
|
+
<div className="flex gap-1">
|
|
107
|
+
{(['once', 'interval', 'cron', 'event'] as const).map((k) => (
|
|
108
|
+
<Button
|
|
109
|
+
key={k}
|
|
110
|
+
type="button"
|
|
111
|
+
variant={trigger.kind === k ? 'default' : 'outline'}
|
|
112
|
+
size="sm"
|
|
113
|
+
onClick={() => {
|
|
114
|
+
if (k === 'once') setTrigger({ kind: 'once', at: Date.now() + 5 * 60_000 });
|
|
115
|
+
else if (k === 'interval') setTrigger({ kind: 'interval', everyMs: 60 * 60_000 });
|
|
116
|
+
else if (k === 'event') setTrigger({ kind: 'event', source: '' });
|
|
117
|
+
else setTrigger({ kind: 'cron', expr: '0 9 * * *', tz });
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{k}
|
|
121
|
+
</Button>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{trigger.kind === 'once' && (
|
|
126
|
+
<Input
|
|
127
|
+
type="datetime-local"
|
|
128
|
+
value={localDateTimeValue(trigger.at)}
|
|
129
|
+
onChange={(e) => {
|
|
130
|
+
const v = new Date(e.target.value);
|
|
131
|
+
if (!isNaN(v.getTime())) setTrigger({ kind: 'once', at: v.getTime() });
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{trigger.kind === 'interval' && (
|
|
137
|
+
<div className="space-y-2">
|
|
138
|
+
<div className="flex gap-1 flex-wrap">
|
|
139
|
+
{INTERVAL_PRESETS.map((p) => (
|
|
140
|
+
<Button
|
|
141
|
+
key={p.ms}
|
|
142
|
+
type="button"
|
|
143
|
+
variant={trigger.everyMs === p.ms ? 'default' : 'outline'}
|
|
144
|
+
size="sm"
|
|
145
|
+
onClick={() => setTrigger({ kind: 'interval', everyMs: p.ms })}
|
|
146
|
+
>
|
|
147
|
+
{p.label}
|
|
148
|
+
</Button>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
<Input
|
|
152
|
+
type="number"
|
|
153
|
+
min={1}
|
|
154
|
+
value={Math.round(trigger.everyMs / 1000)}
|
|
155
|
+
onChange={(e) => setTrigger({ kind: 'interval', everyMs: Math.max(1, Number(e.target.value)) * 1000 })}
|
|
156
|
+
placeholder="seconds"
|
|
157
|
+
/>
|
|
158
|
+
<p className="text-xs text-muted-foreground">Every {Math.round(trigger.everyMs / 1000)}s</p>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{trigger.kind === 'cron' && (
|
|
163
|
+
<div className="space-y-2">
|
|
164
|
+
<div className="flex gap-1 flex-wrap">
|
|
165
|
+
{CRON_PRESETS.map((p) => (
|
|
166
|
+
<Button
|
|
167
|
+
key={p.expr}
|
|
168
|
+
type="button"
|
|
169
|
+
variant="outline"
|
|
170
|
+
size="sm"
|
|
171
|
+
onClick={() => setTrigger({ kind: 'cron', expr: p.expr, tz })}
|
|
172
|
+
>
|
|
173
|
+
{p.label}
|
|
174
|
+
</Button>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
<Input
|
|
178
|
+
className="font-mono text-xs"
|
|
179
|
+
value={trigger.expr}
|
|
180
|
+
onChange={(e) => setTrigger({ ...trigger, expr: e.target.value })}
|
|
181
|
+
placeholder="0 9 * * *"
|
|
182
|
+
/>
|
|
183
|
+
<Input
|
|
184
|
+
className="text-xs"
|
|
185
|
+
value={trigger.tz}
|
|
186
|
+
onChange={(e) => setTrigger({ ...trigger, tz: e.target.value })}
|
|
187
|
+
placeholder="IANA timezone"
|
|
188
|
+
/>
|
|
189
|
+
<p className="text-xs text-muted-foreground">{cronDesc} ({trigger.tz})</p>
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{trigger.kind === 'event' && (
|
|
194
|
+
<div className="space-y-2">
|
|
195
|
+
<Input
|
|
196
|
+
value={trigger.source}
|
|
197
|
+
onChange={(e) => setTrigger({ ...trigger, source: e.target.value })}
|
|
198
|
+
placeholder="Event source (e.g. stripe, github, deploy)"
|
|
199
|
+
/>
|
|
200
|
+
<p className="text-xs text-muted-foreground">
|
|
201
|
+
Fires when an event with this source arrives via <code>POST /api/events/{trigger.source || ':source'}</code> or <code>ctx.emitEvent()</code>.
|
|
202
|
+
</p>
|
|
203
|
+
<MatchEditor
|
|
204
|
+
match={trigger.match ?? {}}
|
|
205
|
+
onChange={(match) => setTrigger({ ...trigger, match })}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Task */}
|
|
212
|
+
<div className="space-y-2">
|
|
213
|
+
<label className="text-xs text-muted-foreground">Task</label>
|
|
214
|
+
<div className="flex gap-1">
|
|
215
|
+
{(['prompt', 'bash'] as const).map((k) => (
|
|
216
|
+
<Button
|
|
217
|
+
key={k}
|
|
218
|
+
type="button"
|
|
219
|
+
variant={task.kind === k ? 'default' : 'outline'}
|
|
220
|
+
size="sm"
|
|
221
|
+
onClick={() => setTask(k === 'prompt' ? { kind: 'prompt', prompt: '' } : { kind: 'bash', command: '' })}
|
|
222
|
+
>
|
|
223
|
+
{k === 'prompt' ? 'Prompt' : 'Bash command'}
|
|
224
|
+
</Button>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
{task.kind === 'prompt' ? (
|
|
228
|
+
<AutocompleteTextarea
|
|
229
|
+
rows={5}
|
|
230
|
+
value={task.prompt}
|
|
231
|
+
onChange={(val) => setTask({ ...task, prompt: val })}
|
|
232
|
+
placeholder="What should the agent do? (@ for skills, / for commands)"
|
|
233
|
+
skills={skills}
|
|
234
|
+
workspaceFiles={workspaceFiles}
|
|
235
|
+
autoResize={false}
|
|
236
|
+
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
237
|
+
/>
|
|
238
|
+
) : task.kind === 'bash' ? (
|
|
239
|
+
<Textarea
|
|
240
|
+
rows={3}
|
|
241
|
+
className="font-mono text-xs"
|
|
242
|
+
value={task.command}
|
|
243
|
+
onChange={(e) => setTask({ ...task, command: e.target.value })}
|
|
244
|
+
placeholder="ls -la ~/"
|
|
245
|
+
/>
|
|
246
|
+
) : (
|
|
247
|
+
<p className="text-xs text-muted-foreground">Command: {task.command}</p>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div className="flex items-center gap-2">
|
|
252
|
+
<input id="sched-enabled" type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
|
253
|
+
<label htmlFor="sched-enabled" className="text-sm">Enabled</label>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{err && <p className="text-xs text-destructive">{err}</p>}
|
|
257
|
+
|
|
258
|
+
<div className="flex justify-end gap-2 pt-2">
|
|
259
|
+
<Button variant="outline" onClick={onCancel} disabled={saving}>Cancel</Button>
|
|
260
|
+
<Button onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import cronstrue from 'cronstrue';
|
|
3
|
+
import { Loader2, Play, Square, Pencil, Trash2, Plus } from 'lucide-react';
|
|
4
|
+
import type { Schedule } from '@/lib/schedule-types';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
|
|
7
|
+
type FilterKey = 'mine' | 'others' | 'system';
|
|
8
|
+
type TriggerFilterKey = 'once' | 'interval' | 'cron' | 'event';
|
|
9
|
+
|
|
10
|
+
function triggerKind(s: Schedule): TriggerFilterKey {
|
|
11
|
+
return s.trigger.kind as TriggerFilterKey;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
schedules: Schedule[];
|
|
16
|
+
currentUserUid?: string;
|
|
17
|
+
onCreate: () => void;
|
|
18
|
+
onEdit: (s: Schedule) => void;
|
|
19
|
+
onDelete: (id: string) => void;
|
|
20
|
+
onToggle: (id: string, enabled: boolean) => void;
|
|
21
|
+
onRunNow: (id: string, override?: string) => Promise<string | undefined>;
|
|
22
|
+
onCancel: (id: string) => void;
|
|
23
|
+
onOpenRun: (sessionId: string) => void;
|
|
24
|
+
runningIds?: Set<string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function describeTrigger(s: Schedule): string {
|
|
28
|
+
const t = s.trigger;
|
|
29
|
+
if (t.kind === 'once') return `Once at ${new Date(t.at).toLocaleString()}`;
|
|
30
|
+
if (t.kind === 'interval') {
|
|
31
|
+
const sec = Math.round(t.everyMs / 1000);
|
|
32
|
+
if (sec >= 86400) return `Every ${Math.round(sec / 86400)}d`;
|
|
33
|
+
if (sec >= 3600) return `Every ${Math.round(sec / 3600)}h`;
|
|
34
|
+
if (sec >= 60) return `Every ${Math.round(sec / 60)}m`;
|
|
35
|
+
return `Every ${sec}s`;
|
|
36
|
+
}
|
|
37
|
+
if (t.kind === 'event') {
|
|
38
|
+
const filters = t.match ? Object.entries(t.match).map(([k, v]) => `${k}=${v}`).join(', ') : '';
|
|
39
|
+
return `On event "${t.source}"${filters ? ` where ${filters}` : ''}`;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const tz = t.tz || 'UTC';
|
|
43
|
+
const utcDesc = formatCronUTC(t.expr, tz);
|
|
44
|
+
const humanDesc = cronstrue.toString(t.expr);
|
|
45
|
+
const tzLabel = tz === 'UTC' ? 'UTC' : tz.split('/').pop()?.replace(/_/g, ' ') || tz;
|
|
46
|
+
return utcDesc ? `${utcDesc} (${humanDesc} ${tzLabel})` : `${humanDesc} ${tzLabel}`;
|
|
47
|
+
} catch { return t.expr; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatCronUTC(expr: string, tz: string): string | null {
|
|
51
|
+
try {
|
|
52
|
+
const parts = expr.split(/\s+/);
|
|
53
|
+
if (parts.length < 5) return null;
|
|
54
|
+
const min = parseInt(parts[0], 10);
|
|
55
|
+
const hour = parseInt(parts[1], 10);
|
|
56
|
+
if (isNaN(min) || isNaN(hour)) return null;
|
|
57
|
+
// Cron hour:min is in `tz` — convert to UTC
|
|
58
|
+
const now = new Date();
|
|
59
|
+
const tzOffset = new Date(now.toLocaleString('en-US', { timeZone: tz })).getTime()
|
|
60
|
+
- new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })).getTime();
|
|
61
|
+
const ref = new Date();
|
|
62
|
+
ref.setUTCHours(hour, min, 0, 0);
|
|
63
|
+
const utc = new Date(ref.getTime() - tzOffset);
|
|
64
|
+
return utc.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' });
|
|
65
|
+
} catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function relTime(ts?: number): string {
|
|
69
|
+
if (!ts) return '—';
|
|
70
|
+
const diff = ts - Date.now();
|
|
71
|
+
const abs = Math.abs(diff);
|
|
72
|
+
const mins = Math.round(abs / 60000);
|
|
73
|
+
if (abs < 60000) return diff > 0 ? 'in <1m' : '<1m ago';
|
|
74
|
+
if (mins < 60) return diff > 0 ? `in ${mins}m` : `${mins}m ago`;
|
|
75
|
+
const hrs = Math.round(mins / 60);
|
|
76
|
+
if (hrs < 24) return diff > 0 ? `in ${hrs}h` : `${hrs}h ago`;
|
|
77
|
+
const days = Math.round(hrs / 24);
|
|
78
|
+
return diff > 0 ? `in ${days}d` : `${days}d ago`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
82
|
+
ok: 'bg-green-50 text-green-700 dark:bg-green-950/50 dark:text-green-300',
|
|
83
|
+
error: 'bg-red-50 text-red-700 dark:bg-red-950/50 dark:text-red-300',
|
|
84
|
+
running: 'bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300',
|
|
85
|
+
aborted: 'bg-amber-50 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function isSystemSchedule(s: Schedule): boolean {
|
|
89
|
+
return s.scope === 'system';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function overrideLabel(kind: string): string {
|
|
93
|
+
if (kind === 'prompt') return 'Additional instructions (appended to prompt)';
|
|
94
|
+
if (kind === 'bash') return 'Override command';
|
|
95
|
+
return 'Override args';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function overridePlaceholder(kind: string): string {
|
|
99
|
+
if (kind === 'prompt') return 'e.g. Dry run — compose the message but do NOT post to Slack';
|
|
100
|
+
if (kind === 'bash') return 'e.g. bun run report:fb-ads-spend --dry-run';
|
|
101
|
+
return 'e.g. --dry-run';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function classifySchedule(s: Schedule, currentUid?: string): FilterKey {
|
|
105
|
+
if (s.scope === 'system') return 'system';
|
|
106
|
+
return s.createdBy?.uid === currentUid ? 'mine' : 'others';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function ScheduleList({ schedules, currentUserUid, onCreate, onEdit, onDelete, onToggle, onRunNow, onCancel, onOpenRun, runningIds = new Set() }: Props) {
|
|
110
|
+
const [runModalId, setRunModalId] = useState<string | null>(null);
|
|
111
|
+
const [overrideText, setOverrideText] = useState('');
|
|
112
|
+
const [filters, setFilters] = useState<Record<FilterKey, boolean>>({ mine: true, others: true, system: true });
|
|
113
|
+
const [triggerFilters, setTriggerFilters] = useState<Record<TriggerFilterKey, boolean>>({ once: true, interval: true, cron: true, event: true });
|
|
114
|
+
const [running, setRunning] = useState(false);
|
|
115
|
+
|
|
116
|
+
const toggleFilter = (key: FilterKey) => setFilters((f) => {
|
|
117
|
+
const allOn = Object.values(f).every(Boolean);
|
|
118
|
+
if (allOn) return { mine: false, others: false, system: false, [key]: true };
|
|
119
|
+
const next = { ...f, [key]: !f[key] };
|
|
120
|
+
if (!Object.values(next).some(Boolean)) return { mine: true, others: true, system: true };
|
|
121
|
+
return next;
|
|
122
|
+
});
|
|
123
|
+
const toggleTriggerFilter = (key: TriggerFilterKey) => setTriggerFilters((f) => {
|
|
124
|
+
const allOn = Object.values(f).every(Boolean);
|
|
125
|
+
if (allOn) return { once: false, interval: false, cron: false, event: false, [key]: true };
|
|
126
|
+
const next = { ...f, [key]: !f[key] };
|
|
127
|
+
if (!Object.values(next).some(Boolean)) return { once: true, interval: true, cron: true, event: true };
|
|
128
|
+
return next;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const filtered = schedules.filter((s) => filters[classifySchedule(s, currentUserUid)] && triggerFilters[triggerKind(s)]);
|
|
132
|
+
|
|
133
|
+
const handlePlay = (s: Schedule) => {
|
|
134
|
+
const saved = localStorage.getItem(`schedule-override-${s.id}`);
|
|
135
|
+
setOverrideText(saved ?? '');
|
|
136
|
+
setRunModalId(s.id);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleRunConfirm = async () => {
|
|
140
|
+
if (!runModalId) return;
|
|
141
|
+
setRunning(true);
|
|
142
|
+
try {
|
|
143
|
+
const override = overrideText.trim() || undefined;
|
|
144
|
+
if (override) {
|
|
145
|
+
localStorage.setItem(`schedule-override-${runModalId}`, override);
|
|
146
|
+
}
|
|
147
|
+
const sessionId = await onRunNow(runModalId, override);
|
|
148
|
+
setRunModalId(null);
|
|
149
|
+
setOverrideText('');
|
|
150
|
+
if (sessionId) onOpenRun(sessionId);
|
|
151
|
+
} finally {
|
|
152
|
+
setRunning(false);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div className="space-y-3">
|
|
158
|
+
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
159
|
+
<div className="flex gap-1 flex-wrap">
|
|
160
|
+
{(['mine', 'others', 'system'] as const).map((key) => (
|
|
161
|
+
<button
|
|
162
|
+
key={key}
|
|
163
|
+
onClick={() => toggleFilter(key)}
|
|
164
|
+
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors ${filters[key] ? 'bg-primary text-primary-foreground border-primary' : 'bg-transparent text-muted-foreground border-border hover:border-muted-foreground'}`}
|
|
165
|
+
>
|
|
166
|
+
{key}
|
|
167
|
+
</button>
|
|
168
|
+
))}
|
|
169
|
+
<span className="w-px h-4 bg-border mx-1" />
|
|
170
|
+
{(['once', 'interval', 'cron', 'event'] as const).map((key) => (
|
|
171
|
+
<button
|
|
172
|
+
key={key}
|
|
173
|
+
onClick={() => toggleTriggerFilter(key)}
|
|
174
|
+
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors ${triggerFilters[key] ? 'bg-primary text-primary-foreground border-primary' : 'bg-transparent text-muted-foreground border-border hover:border-muted-foreground'}`}
|
|
175
|
+
>
|
|
176
|
+
{key}
|
|
177
|
+
</button>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
<Button size="sm" onClick={onCreate} className="gap-1"><Plus className="w-4 h-4" /> New schedule</Button>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{filtered.length === 0 && (
|
|
184
|
+
<p className="text-sm text-muted-foreground text-center py-8">
|
|
185
|
+
{schedules.length === 0 ? 'No schedules yet.' : 'No schedules match filters.'}
|
|
186
|
+
</p>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{filtered.map((s) => (
|
|
190
|
+
<div key={s.id} className="border rounded-lg p-3 space-y-2 overflow-hidden">
|
|
191
|
+
<div className="flex items-start justify-between gap-2 flex-wrap">
|
|
192
|
+
<div className="min-w-0 flex-1">
|
|
193
|
+
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
|
194
|
+
<input
|
|
195
|
+
type="checkbox"
|
|
196
|
+
checked={s.enabled}
|
|
197
|
+
onChange={(e) => onToggle(s.id, e.target.checked)}
|
|
198
|
+
title={s.enabled ? 'Disable' : 'Enable'}
|
|
199
|
+
className="shrink-0"
|
|
200
|
+
/>
|
|
201
|
+
<span className="font-medium text-sm truncate max-w-[280px]" title={s.name}>{s.name}</span>
|
|
202
|
+
<span className="shrink-0 text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
|
203
|
+
{s.task.kind}
|
|
204
|
+
</span>
|
|
205
|
+
<span className="shrink-0 text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
|
206
|
+
{s.trigger.kind}
|
|
207
|
+
</span>
|
|
208
|
+
{runningIds.has(s.id) && (
|
|
209
|
+
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300">
|
|
210
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
211
|
+
running
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300">
|
|
215
|
+
{isSystemSchedule(s) ? 'system' : s.createdBy?.email?.split('@')[0] ?? 'unknown'}
|
|
216
|
+
</span>
|
|
217
|
+
</div>
|
|
218
|
+
<div className="mt-1 text-xs text-muted-foreground truncate">{describeTrigger(s)}</div>
|
|
219
|
+
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
|
220
|
+
<span>Next: {s.enabled ? relTime(s.nextRun) : 'disabled'}</span>
|
|
221
|
+
{s.lastRun && (
|
|
222
|
+
<button
|
|
223
|
+
className={`px-1.5 py-0.5 rounded text-[10px] ${STATUS_COLORS[s.lastRun.status] ?? ''}`}
|
|
224
|
+
onClick={() => onOpenRun(s.lastRun!.sessionId)}
|
|
225
|
+
title="Open last run"
|
|
226
|
+
>
|
|
227
|
+
last: {s.lastRun.status} {relTime(s.lastRun.at)}
|
|
228
|
+
</button>
|
|
229
|
+
)}
|
|
230
|
+
<span>runs: {s.runCount ?? 0}</span>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
234
|
+
{runningIds.has(s.id) ? (
|
|
235
|
+
<Button variant="ghost" size="icon" title="Stop run" onClick={() => onCancel(s.id)}>
|
|
236
|
+
<Square className="w-4 h-4 text-destructive" />
|
|
237
|
+
</Button>
|
|
238
|
+
) : (
|
|
239
|
+
<Button variant="ghost" size="icon" title="Run now" onClick={() => handlePlay(s)}>
|
|
240
|
+
<Play className="w-4 h-4" />
|
|
241
|
+
</Button>
|
|
242
|
+
)}
|
|
243
|
+
<Button variant="ghost" size="icon" title="Edit" onClick={() => onEdit(s)}><Pencil className="w-4 h-4" /></Button>
|
|
244
|
+
<Button variant="ghost" size="icon" title="Delete" onClick={() => onDelete(s.id)}><Trash2 className="w-4 h-4 text-destructive" /></Button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{runModalId === s.id && (
|
|
249
|
+
<div className="border-t pt-2 mt-2 space-y-2">
|
|
250
|
+
<label className="text-xs text-muted-foreground">{overrideLabel(s.task.kind)}</label>
|
|
251
|
+
<input
|
|
252
|
+
type="text"
|
|
253
|
+
className="w-full text-sm border rounded px-2 py-1.5 bg-background"
|
|
254
|
+
placeholder={overridePlaceholder(s.task.kind)}
|
|
255
|
+
value={overrideText}
|
|
256
|
+
onChange={(e) => setOverrideText(e.target.value)}
|
|
257
|
+
onKeyDown={(e) => { if (e.key === 'Enter' && !running) handleRunConfirm(); if (e.key === 'Escape') setRunModalId(null); }}
|
|
258
|
+
disabled={running}
|
|
259
|
+
autoFocus
|
|
260
|
+
/>
|
|
261
|
+
<div className="flex gap-2 justify-end">
|
|
262
|
+
<Button size="sm" variant="ghost" onClick={() => setRunModalId(null)} disabled={running}>Cancel</Button>
|
|
263
|
+
<Button size="sm" onClick={handleRunConfirm} disabled={running}>{running ? 'Running...' : 'Run'}</Button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
|
3
|
+
import { ChevronDown } from 'lucide-react';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
const Accordion = AccordionPrimitive.Root;
|
|
7
|
+
|
|
8
|
+
const AccordionItem = React.forwardRef<
|
|
9
|
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
10
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
11
|
+
>(({ className, ...props }, ref) => (
|
|
12
|
+
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
|
|
13
|
+
));
|
|
14
|
+
AccordionItem.displayName = 'AccordionItem';
|
|
15
|
+
|
|
16
|
+
const AccordionTrigger = React.forwardRef<
|
|
17
|
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
18
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
19
|
+
>(({ className, children, ...props }, ref) => (
|
|
20
|
+
<AccordionPrimitive.Header className="flex">
|
|
21
|
+
<AccordionPrimitive.Trigger
|
|
22
|
+
ref={ref}
|
|
23
|
+
className={cn(
|
|
24
|
+
'flex flex-1 items-center justify-between py-2 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
31
|
+
</AccordionPrimitive.Trigger>
|
|
32
|
+
</AccordionPrimitive.Header>
|
|
33
|
+
));
|
|
34
|
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
|
35
|
+
|
|
36
|
+
const AccordionContent = React.forwardRef<
|
|
37
|
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
38
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
39
|
+
>(({ className, children, ...props }, ref) => (
|
|
40
|
+
<AccordionPrimitive.Content
|
|
41
|
+
ref={ref}
|
|
42
|
+
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
<div className={cn('pb-3 pt-0', className)}>{children}</div>
|
|
46
|
+
</AccordionPrimitive.Content>
|
|
47
|
+
));
|
|
48
|
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
|
49
|
+
|
|
50
|
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
12
|
+
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
|
13
|
+
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
|
14
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
15
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
16
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
default: 'h-10 px-4 py-2',
|
|
20
|
+
sm: 'h-8 rounded-md px-3',
|
|
21
|
+
lg: 'h-11 rounded-md px-8',
|
|
22
|
+
icon: 'h-9 w-9',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: { variant: 'default', size: 'default' },
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export interface ButtonProps
|
|
30
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
31
|
+
VariantProps<typeof buttonVariants> {
|
|
32
|
+
asChild?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
36
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
37
|
+
const Comp = asChild ? Slot : 'button';
|
|
38
|
+
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
Button.displayName = 'Button';
|
|
42
|
+
|
|
43
|
+
export { Button, buttonVariants };
|