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.
Files changed (180) hide show
  1. package/README.md +82 -23
  2. package/defaults/agents/summarizer.md +16 -0
  3. package/defaults/agents/trace-extractor.md +84 -0
  4. package/defaults/bin/claude +45 -0
  5. package/defaults/bin/claude-revive +17 -0
  6. package/defaults/extensions/README.md +70 -0
  7. package/defaults/extensions/selftest.ext.ts +43 -0
  8. package/defaults/extensions/stripe-webhook.ext.ts +58 -0
  9. package/defaults/gmail-triage-prompt.md +42 -0
  10. package/defaults/scripts/README +4 -0
  11. package/defaults/scripts/agent-once.ts +67 -0
  12. package/defaults/scripts/backfill-slack-usernames.ts +82 -0
  13. package/defaults/scripts/notifier-throttle.ts +44 -0
  14. package/defaults/scripts/summarize-conversations.ts +5 -0
  15. package/defaults/shraga.config.ts +29 -0
  16. package/defaults/skills/add-skill.md +14 -0
  17. package/defaults/skills/artifacts.md +116 -0
  18. package/defaults/skills/code-review.md +26 -0
  19. package/defaults/skills/communications.md +54 -0
  20. package/defaults/skills/context-audit.md +87 -0
  21. package/defaults/skills/debug.md +10 -0
  22. package/defaults/skills/garden.md +179 -0
  23. package/defaults/skills/github-contributor.md +35 -0
  24. package/defaults/skills/identity.md +30 -0
  25. package/defaults/skills/mcp-server.md +62 -0
  26. package/defaults/skills/mcps-sync.md +105 -0
  27. package/defaults/skills/plan.md +9 -0
  28. package/defaults/skills/platform.md +177 -0
  29. package/defaults/skills/reconcile.md +239 -0
  30. package/defaults/skills/scheduler.md +192 -0
  31. package/defaults/skills/self-aware.md +136 -0
  32. package/defaults/skills/shraga-know.md +333 -0
  33. package/defaults/skills/stripe.md +55 -0
  34. package/defaults/skills/write-tests.md +10 -0
  35. package/defaults/skills-defaults.json +1 -0
  36. package/defaults/system-prompt.md +46 -0
  37. package/defaults/workspace/context.md +28 -0
  38. package/defaults/workspace.md +50 -0
  39. package/defaults/zdotdir/.gitignore +8 -0
  40. package/defaults/zdotdir/.zlogin +3 -0
  41. package/defaults/zdotdir/.zprofile +1 -0
  42. package/defaults/zdotdir/.zshenv +4 -0
  43. package/defaults/zdotdir/.zshrc +3 -0
  44. package/dist/client/assets/index-BoHttkMt.js +1940 -0
  45. package/dist/client/assets/index-DdibEb2O.css +10 -0
  46. package/dist/client/index.html +22 -0
  47. package/package.json +59 -14
  48. package/src/cli.ts +71 -46
  49. package/src/client/App.tsx +510 -0
  50. package/src/client/components/ArtifactCard.tsx +26 -0
  51. package/src/client/components/ArtifactPanel.tsx +138 -0
  52. package/src/client/components/AuthedImage.tsx +85 -0
  53. package/src/client/components/AutocompleteTextarea.tsx +149 -0
  54. package/src/client/components/ChatView.tsx +866 -0
  55. package/src/client/components/CliAuthConsent.tsx +98 -0
  56. package/src/client/components/ConfigPanel.tsx +328 -0
  57. package/src/client/components/ConversationHeader.tsx +156 -0
  58. package/src/client/components/ConversationPane.tsx +277 -0
  59. package/src/client/components/LoginPage.tsx +81 -0
  60. package/src/client/components/MachineStats.tsx +77 -0
  61. package/src/client/components/McpManager.tsx +209 -0
  62. package/src/client/components/MessageInput.tsx +263 -0
  63. package/src/client/components/OAuthConsent.tsx +103 -0
  64. package/src/client/components/SchedulesManager.tsx +99 -0
  65. package/src/client/components/Sidebar.tsx +235 -0
  66. package/src/client/components/SkillsManager.tsx +280 -0
  67. package/src/client/components/SmartChart.tsx +167 -0
  68. package/src/client/components/Toast.tsx +54 -0
  69. package/src/client/components/WorkspaceTree.tsx +313 -0
  70. package/src/client/components/ZoomableImage.tsx +123 -0
  71. package/src/client/components/artifact-presets.ts +10 -0
  72. package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
  73. package/src/client/components/schedules/ScheduleList.tsx +271 -0
  74. package/src/client/components/ui/accordion.tsx +50 -0
  75. package/src/client/components/ui/button.tsx +43 -0
  76. package/src/client/components/ui/dialog.tsx +82 -0
  77. package/src/client/components/ui/input.tsx +19 -0
  78. package/src/client/components/ui/scroll-area.tsx +39 -0
  79. package/src/client/components/ui/textarea.tsx +18 -0
  80. package/src/client/globals.css +51 -0
  81. package/src/client/hooks/useAgentSocket.ts +79 -0
  82. package/src/client/hooks/useArtifacts.ts +89 -0
  83. package/src/client/hooks/useAuth.ts +127 -0
  84. package/src/client/hooks/useConversation.ts +412 -0
  85. package/src/client/hooks/useDarkMode.ts +57 -0
  86. package/src/client/hooks/useIsMobile.ts +23 -0
  87. package/src/client/hooks/usePush.ts +127 -0
  88. package/src/client/hooks/useSchedules.ts +73 -0
  89. package/src/client/hooks/useUnread.ts +238 -0
  90. package/src/client/lib/desktopAttention.ts +75 -0
  91. package/src/client/lib/firebase.ts +32 -0
  92. package/src/client/lib/googleAuthNative.ts +94 -0
  93. package/src/client/lib/native.ts +43 -0
  94. package/src/client/lib/schedule-types.ts +34 -0
  95. package/src/client/lib/sessionApi.ts +58 -0
  96. package/src/client/lib/slots.tsx +79 -0
  97. package/src/client/lib/storage.ts +39 -0
  98. package/src/client/lib/utils.ts +26 -0
  99. package/src/client/lib/workspaceContext.tsx +54 -0
  100. package/src/client/lib/ws.ts +203 -0
  101. package/src/client/main.tsx +14 -0
  102. package/src/mcp-stdio-bridge.ts +70 -0
  103. package/src/scripts/summarize-conversations.ts +5 -0
  104. package/src/scripts/typecheck.ts +43 -0
  105. package/src/server/agents.ts +54 -0
  106. package/src/server/api-keys.ts +63 -0
  107. package/src/server/artifacts/artifacts.export.ts +85 -0
  108. package/src/server/artifacts/artifacts.handler.ts +93 -0
  109. package/src/server/artifacts/artifacts.routes.ts +43 -0
  110. package/src/server/artifacts/artifacts.service.ts +100 -0
  111. package/src/server/artifacts/artifacts.types.ts +31 -0
  112. package/src/server/auth.ts +262 -0
  113. package/src/server/claude.ts +394 -0
  114. package/src/server/commands.ts +21 -0
  115. package/src/server/contacts.ts +177 -0
  116. package/src/server/conversation-summarizer.ts +204 -0
  117. package/src/server/data-sync.ts +664 -0
  118. package/src/server/directives.ts +91 -0
  119. package/src/server/engine/claude-code.ts +514 -0
  120. package/src/server/engine/index.ts +41 -0
  121. package/src/server/engine/registry.ts +21 -0
  122. package/src/server/engine/shared.ts +47 -0
  123. package/src/server/engine/types.ts +48 -0
  124. package/src/server/env-resolve.ts +71 -0
  125. package/src/server/env-sanitize.ts +9 -0
  126. package/src/server/events/bus.ts +29 -0
  127. package/src/server/events/dispatcher.ts +48 -0
  128. package/src/server/events/routes.ts +19 -0
  129. package/src/server/events/types.ts +9 -0
  130. package/src/server/extensions.ts +101 -0
  131. package/src/server/features.ts +109 -0
  132. package/src/server/file-inject.ts +45 -0
  133. package/src/server/hooks.ts +142 -0
  134. package/src/server/idempotency.ts +25 -0
  135. package/src/server/index.ts +1715 -0
  136. package/src/server/integrity-audit.ts +132 -0
  137. package/src/server/mcp-catalog.ts +70 -0
  138. package/src/server/mcp-oauth.ts +198 -0
  139. package/src/server/mcp-progress.ts +45 -0
  140. package/src/server/mcp-server.ts +456 -0
  141. package/src/server/mcp-sidecar.ts +87 -0
  142. package/src/server/mcp.ts +291 -0
  143. package/src/server/model-aliases.ts +76 -0
  144. package/src/server/paths.ts +24 -0
  145. package/src/server/polls.ts +175 -0
  146. package/src/server/push/apns.ts +113 -0
  147. package/src/server/push/fcm.ts +108 -0
  148. package/src/server/push/push.ts +66 -0
  149. package/src/server/push/store.ts +84 -0
  150. package/src/server/push/triggers.ts +99 -0
  151. package/src/server/scheduler/builtins.ts +157 -0
  152. package/src/server/scheduler/engine.ts +432 -0
  153. package/src/server/scheduler/index.ts +4 -0
  154. package/src/server/scheduler/runner.ts +334 -0
  155. package/src/server/scheduler/storage.ts +98 -0
  156. package/src/server/scheduler/timing.ts +70 -0
  157. package/src/server/scheduler/types.ts +62 -0
  158. package/src/server/sdk-utils.ts +45 -0
  159. package/src/server/seed.ts +174 -0
  160. package/src/server/session-bus.ts +18 -0
  161. package/src/server/sessions.ts +559 -0
  162. package/src/server/shraga-config.ts +167 -0
  163. package/src/server/skills.ts +372 -0
  164. package/src/server/slack/api.ts +37 -0
  165. package/src/server/slack/bot.ts +391 -0
  166. package/src/server/slack/context-cache.ts +42 -0
  167. package/src/server/slack/feature.ts +59 -0
  168. package/src/server/slack/mention-rewrite.ts +59 -0
  169. package/src/server/slack/oauth.ts +102 -0
  170. package/src/server/slack/questions.ts +112 -0
  171. package/src/server/slack/sessions.ts +139 -0
  172. package/src/server/stats.ts +106 -0
  173. package/src/server/summarize.ts +11 -0
  174. package/src/server/turn-context.ts +61 -0
  175. package/src/server/unclaw-config.ts +19 -0
  176. package/src/server/unread.ts +79 -0
  177. package/src/server/user-context.ts +33 -0
  178. package/src/server/vendor-sync.ts +52 -0
  179. package/src/server/voice-provider.ts +74 -0
  180. 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 };