@saadnvd1/agent-os 0.3.533 → 0.3.534
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 +68 -2
- package/app/api/features/pieces/[id]/release/route.ts +16 -0
- package/app/api/features/route.ts +8 -0
- package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
- package/app/api/recipes/[id]/preview/route.ts +19 -0
- package/app/api/recipes/[id]/route.ts +51 -0
- package/app/api/recipes/[id]/run/route.ts +35 -0
- package/app/api/recipes/[id]/runs/route.ts +23 -0
- package/app/api/recipes/draft/route.ts +14 -0
- package/app/api/recipes/route.ts +35 -0
- package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
- package/components/CommandPalette/useAppCommands.ts +20 -0
- package/components/Features/FeaturesSection.tsx +100 -0
- package/components/Features/index.ts +1 -0
- package/components/Recipes/RecipeForm.tsx +251 -0
- package/components/Recipes/RecipePage.tsx +127 -0
- package/components/Recipes/RecipesPanel.tsx +225 -0
- package/components/Recipes/RunChip.tsx +16 -0
- package/components/Recipes/RunHistory.tsx +88 -0
- package/components/Recipes/RunSheet.tsx +84 -0
- package/components/Recipes/TimerFields.tsx +193 -0
- package/components/Recipes/format.ts +51 -0
- package/components/Recipes/index.ts +1 -0
- package/components/Schedules/SchedulesPanel.tsx +1 -0
- package/components/SessionList/RowSubtitle.tsx +16 -0
- package/components/SessionList/SessionRowMenu.tsx +29 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Tasks/PlanReview.tsx +253 -0
- package/components/Tasks/TaskRow.tsx +19 -44
- package/components/Tasks/TasksDialog.tsx +2 -0
- package/data/features/index.ts +40 -0
- package/data/push/topics.ts +8 -0
- package/data/recipes/index.ts +129 -0
- package/data/tasks/index.ts +14 -0
- package/lib/away/run.ts +4 -1
- package/lib/db/changes.ts +8 -0
- package/lib/db/migrations.ts +127 -0
- package/lib/db/queries.ts +4 -1
- package/lib/db/types.ts +6 -0
- package/lib/models/plan-step.ts +67 -6
- package/lib/notify/copy.ts +28 -10
- package/lib/notify/push/events.ts +36 -2
- package/lib/notify/push/policy.ts +2 -0
- package/lib/notify/push-types.ts +7 -3
- package/lib/orchestrator/brief.ts +13 -0
- package/lib/orchestrator/conditions.ts +3 -1
- package/lib/orchestrator/deliver.ts +2 -0
- package/lib/orchestrator/digest.ts +27 -1
- package/lib/orchestrator/plan-pieces.ts +41 -0
- package/lib/orchestrator/recipe-tools.ts +159 -0
- package/lib/orchestrator/review.ts +2 -1
- package/lib/orchestrator/serve.ts +25 -0
- package/lib/orchestrator/tool-names.ts +5 -0
- package/lib/orchestrator/tool-schemas.ts +54 -0
- package/lib/orchestrator/tools.ts +10 -0
- package/lib/plans/features.ts +200 -0
- package/lib/plans/pieces.ts +366 -0
- package/lib/plans/release.ts +323 -0
- package/lib/plans/view.ts +85 -0
- package/lib/recipes/api.ts +19 -0
- package/lib/recipes/chip.ts +40 -0
- package/lib/recipes/compose.ts +78 -0
- package/lib/recipes/draft.ts +93 -0
- package/lib/recipes/index.ts +54 -0
- package/lib/recipes/result.ts +59 -0
- package/lib/recipes/run.ts +202 -0
- package/lib/recipes/settle.ts +465 -0
- package/lib/recipes/store.ts +378 -0
- package/lib/recipes/test-fixtures.ts +49 -0
- package/lib/recipes/timer.ts +107 -0
- package/lib/recipes/types.ts +116 -0
- package/lib/schedules/index.ts +4 -1
- package/lib/schedules/start.ts +33 -9
- package/lib/schedules/store.ts +6 -2
- package/lib/security/demo.ts +1 -0
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/loop-types.ts +23 -0
- package/lib/tasks/plan-view.ts +38 -1
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/recipesUi.ts +53 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { useProjectsQuery } from "@/data/projects";
|
|
9
|
+
import { useWorkspacesQuery } from "@/data/workspaces";
|
|
10
|
+
import {
|
|
11
|
+
useCreateRecipe,
|
|
12
|
+
useUpdateRecipe,
|
|
13
|
+
type RecipeFormInput,
|
|
14
|
+
} from "@/data/recipes";
|
|
15
|
+
import type { RecipeDraft } from "@/lib/recipes/draft";
|
|
16
|
+
import {
|
|
17
|
+
MAX_BRIEF,
|
|
18
|
+
MAX_NAME,
|
|
19
|
+
type RecipeKind,
|
|
20
|
+
type RecipeView,
|
|
21
|
+
} from "@/lib/recipes/types";
|
|
22
|
+
import {
|
|
23
|
+
TimerFields,
|
|
24
|
+
timerCron,
|
|
25
|
+
timerProblem,
|
|
26
|
+
timerValue,
|
|
27
|
+
type TimerValue,
|
|
28
|
+
} from "./TimerFields";
|
|
29
|
+
|
|
30
|
+
const KINDS: { value: RecipeKind; label: string; hint: string }[] = [
|
|
31
|
+
{
|
|
32
|
+
value: "task",
|
|
33
|
+
label: "Task",
|
|
34
|
+
hint: "Ends in a PR and goes through the gates",
|
|
35
|
+
},
|
|
36
|
+
{ value: "session", label: "Session", hint: "Changes no code and has no PR" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// A new recipe, an edit, or "Save as recipe" from a finished session
|
|
40
|
+
// (`draft`): the brief can be swapped for a cleaned-up one first.
|
|
41
|
+
export function RecipeForm({
|
|
42
|
+
workspaceId,
|
|
43
|
+
existing,
|
|
44
|
+
draft,
|
|
45
|
+
sourceSessionId,
|
|
46
|
+
onDone,
|
|
47
|
+
onCancel,
|
|
48
|
+
}: {
|
|
49
|
+
workspaceId: string | null;
|
|
50
|
+
existing?: RecipeView;
|
|
51
|
+
draft?: RecipeDraft | null;
|
|
52
|
+
sourceSessionId?: string | null;
|
|
53
|
+
onDone: (id: string) => void;
|
|
54
|
+
onCancel: () => void;
|
|
55
|
+
}) {
|
|
56
|
+
const { data: workspaces = [] } = useWorkspacesQuery();
|
|
57
|
+
const { data: projects = [] } = useProjectsQuery();
|
|
58
|
+
const create = useCreateRecipe();
|
|
59
|
+
const update = useUpdateRecipe();
|
|
60
|
+
const mutation = existing ? update : create;
|
|
61
|
+
|
|
62
|
+
const [name, setName] = useState(existing?.name ?? draft?.name ?? "");
|
|
63
|
+
const [kind, setKind] = useState<RecipeKind>(
|
|
64
|
+
existing?.kind ?? draft?.kind ?? "task"
|
|
65
|
+
);
|
|
66
|
+
const [brief, setBrief] = useState(existing?.brief ?? draft?.prompt ?? "");
|
|
67
|
+
const [suggested, setSuggested] = useState(draft?.draft ?? null);
|
|
68
|
+
const [projectId, setProjectId] = useState(
|
|
69
|
+
existing?.project_id ?? draft?.projectId ?? ""
|
|
70
|
+
);
|
|
71
|
+
const [timer, setTimer] = useState<TimerValue>(() =>
|
|
72
|
+
timerValue(existing?.timer ?? null)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const workspace =
|
|
76
|
+
existing?.workspace_id ??
|
|
77
|
+
draft?.workspaceId ??
|
|
78
|
+
workspaceId ??
|
|
79
|
+
workspaces[0]?.id ??
|
|
80
|
+
"";
|
|
81
|
+
const eligible = projects.filter(
|
|
82
|
+
(p) =>
|
|
83
|
+
!p.is_uncategorized &&
|
|
84
|
+
p.workspace_id === workspace &&
|
|
85
|
+
(!p.host_id || p.host_id === "local")
|
|
86
|
+
);
|
|
87
|
+
const project =
|
|
88
|
+
eligible.find((p) => p.id === projectId)?.id ?? eligible[0]?.id ?? "";
|
|
89
|
+
const problem = timerProblem(timer);
|
|
90
|
+
const ready = !!name.trim() && !!brief.trim() && !!project && !problem;
|
|
91
|
+
|
|
92
|
+
const save = () => {
|
|
93
|
+
const cron = timerCron(timer);
|
|
94
|
+
const timerInput: RecipeFormInput["timer"] = cron
|
|
95
|
+
? { cron, timezone: timer.timezone }
|
|
96
|
+
: null;
|
|
97
|
+
if (existing)
|
|
98
|
+
update.mutate(
|
|
99
|
+
{
|
|
100
|
+
id: existing.id,
|
|
101
|
+
name: name.trim(),
|
|
102
|
+
kind,
|
|
103
|
+
brief: brief.trim(),
|
|
104
|
+
projectId: project,
|
|
105
|
+
timer: timerInput,
|
|
106
|
+
},
|
|
107
|
+
{ onSuccess: () => onDone(existing.id) }
|
|
108
|
+
);
|
|
109
|
+
else
|
|
110
|
+
create.mutate(
|
|
111
|
+
{
|
|
112
|
+
workspaceId: workspace,
|
|
113
|
+
projectId: project,
|
|
114
|
+
name: name.trim(),
|
|
115
|
+
kind,
|
|
116
|
+
brief: brief.trim(),
|
|
117
|
+
sourceSessionId: sourceSessionId ?? null,
|
|
118
|
+
timer: timerInput,
|
|
119
|
+
},
|
|
120
|
+
{ onSuccess: ({ recipe }) => onDone(recipe.id) }
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="space-y-4">
|
|
126
|
+
<div className="space-y-2">
|
|
127
|
+
<label className="text-sm font-medium" htmlFor="recipe-name">
|
|
128
|
+
Name
|
|
129
|
+
</label>
|
|
130
|
+
<Input
|
|
131
|
+
id="recipe-name"
|
|
132
|
+
value={name}
|
|
133
|
+
maxLength={MAX_NAME}
|
|
134
|
+
placeholder="TestFlight build"
|
|
135
|
+
onChange={(e) => setName(e.target.value)}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
<div className="space-y-2">
|
|
139
|
+
<label className="text-sm font-medium" htmlFor="recipe-project">
|
|
140
|
+
Project
|
|
141
|
+
</label>
|
|
142
|
+
<select
|
|
143
|
+
id="recipe-project"
|
|
144
|
+
className="border-input bg-background min-h-11 w-full rounded-md border px-2 text-sm md:min-h-9"
|
|
145
|
+
value={project}
|
|
146
|
+
onChange={(e) => setProjectId(e.target.value)}
|
|
147
|
+
>
|
|
148
|
+
{eligible.map((p) => (
|
|
149
|
+
<option key={p.id} value={p.id}>
|
|
150
|
+
{p.name}
|
|
151
|
+
</option>
|
|
152
|
+
))}
|
|
153
|
+
</select>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="space-y-2">
|
|
156
|
+
<p className="text-sm font-medium">Each run is a</p>
|
|
157
|
+
<div
|
|
158
|
+
role="radiogroup"
|
|
159
|
+
aria-label="Kind"
|
|
160
|
+
className="bg-foreground/[0.04] grid grid-cols-2 gap-1 rounded-lg p-1"
|
|
161
|
+
>
|
|
162
|
+
{KINDS.map((k) => (
|
|
163
|
+
<button
|
|
164
|
+
key={k.value}
|
|
165
|
+
type="button"
|
|
166
|
+
role="radio"
|
|
167
|
+
aria-checked={kind === k.value}
|
|
168
|
+
onClick={() => setKind(k.value)}
|
|
169
|
+
className={cn(
|
|
170
|
+
"min-h-11 rounded-md px-2 text-sm font-medium md:min-h-8",
|
|
171
|
+
kind === k.value
|
|
172
|
+
? "bg-background shadow-sm"
|
|
173
|
+
: "text-muted-foreground"
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
{k.label}
|
|
177
|
+
</button>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
<p className="text-muted-foreground text-xs">
|
|
181
|
+
{KINDS.find((k) => k.value === kind)?.hint}
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="space-y-2">
|
|
185
|
+
<label className="text-sm font-medium" htmlFor="recipe-brief">
|
|
186
|
+
Brief
|
|
187
|
+
</label>
|
|
188
|
+
{suggested && (
|
|
189
|
+
<div className="border-primary/30 bg-primary/5 space-y-2 rounded-lg border p-3">
|
|
190
|
+
<p className="text-xs font-medium">Suggested brief</p>
|
|
191
|
+
<p className="text-sm whitespace-pre-wrap">{suggested.brief}</p>
|
|
192
|
+
<div className="flex gap-2">
|
|
193
|
+
<Button
|
|
194
|
+
size="sm"
|
|
195
|
+
className="h-11 md:h-8"
|
|
196
|
+
onClick={() => {
|
|
197
|
+
setBrief(suggested.brief);
|
|
198
|
+
setSuggested(null);
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
Use this
|
|
202
|
+
</Button>
|
|
203
|
+
<Button
|
|
204
|
+
size="sm"
|
|
205
|
+
variant="ghost"
|
|
206
|
+
className="h-11 md:h-8"
|
|
207
|
+
onClick={() => setSuggested(null)}
|
|
208
|
+
>
|
|
209
|
+
Keep original
|
|
210
|
+
</Button>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
<Textarea
|
|
215
|
+
id="recipe-brief"
|
|
216
|
+
rows={8}
|
|
217
|
+
maxLength={MAX_BRIEF}
|
|
218
|
+
value={brief}
|
|
219
|
+
onChange={(e) => setBrief(e.target.value)}
|
|
220
|
+
/>
|
|
221
|
+
<p className="text-muted-foreground text-xs">
|
|
222
|
+
Keep it short: what to run and how to judge it. The steps belong in a
|
|
223
|
+
script in the repo.
|
|
224
|
+
</p>
|
|
225
|
+
</div>
|
|
226
|
+
<div className="space-y-2">
|
|
227
|
+
<p className="text-sm font-medium">Timer</p>
|
|
228
|
+
<TimerFields value={timer} onChange={setTimer} />
|
|
229
|
+
</div>
|
|
230
|
+
{mutation.error && (
|
|
231
|
+
<p className="text-destructive text-sm">{mutation.error.message}</p>
|
|
232
|
+
)}
|
|
233
|
+
<div className="flex gap-2">
|
|
234
|
+
<Button
|
|
235
|
+
className="h-11 md:h-9"
|
|
236
|
+
disabled={!ready || mutation.isPending}
|
|
237
|
+
onClick={save}
|
|
238
|
+
>
|
|
239
|
+
{mutation.isPending
|
|
240
|
+
? "Saving..."
|
|
241
|
+
: existing
|
|
242
|
+
? "Save"
|
|
243
|
+
: "Create recipe"}
|
|
244
|
+
</Button>
|
|
245
|
+
<Button variant="ghost" className="h-11 md:h-9" onClick={onCancel}>
|
|
246
|
+
Cancel
|
|
247
|
+
</Button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Pencil, Play, Trash2 } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
useArchiveRecipe,
|
|
8
|
+
useClearCarry,
|
|
9
|
+
useRecipeQuery,
|
|
10
|
+
} from "@/data/recipes";
|
|
11
|
+
import { formatRunTime } from "@/lib/schedules/cron";
|
|
12
|
+
import { RunChip } from "./RunChip";
|
|
13
|
+
import { RunHistory, Totals } from "./RunHistory";
|
|
14
|
+
import { RunSheet } from "./RunSheet";
|
|
15
|
+
|
|
16
|
+
export function RecipePage({
|
|
17
|
+
id,
|
|
18
|
+
openRun,
|
|
19
|
+
onEdit,
|
|
20
|
+
onGone,
|
|
21
|
+
}: {
|
|
22
|
+
id: string;
|
|
23
|
+
openRun: boolean;
|
|
24
|
+
onEdit: () => void;
|
|
25
|
+
onGone: () => void;
|
|
26
|
+
}) {
|
|
27
|
+
const { data, isPending, error } = useRecipeQuery(id);
|
|
28
|
+
const archive = useArchiveRecipe();
|
|
29
|
+
const clearCarry = useClearCarry();
|
|
30
|
+
const [running, setRunning] = useState(openRun);
|
|
31
|
+
const [confirm, setConfirm] = useState(false);
|
|
32
|
+
if (isPending)
|
|
33
|
+
return <div className="bg-muted/40 h-32 animate-pulse rounded-xl" />;
|
|
34
|
+
if (error || !data)
|
|
35
|
+
return (
|
|
36
|
+
<p className="text-destructive text-sm">
|
|
37
|
+
{error?.message ?? "Not found"}
|
|
38
|
+
</p>
|
|
39
|
+
);
|
|
40
|
+
const r = data.recipe;
|
|
41
|
+
return (
|
|
42
|
+
<div className="space-y-4">
|
|
43
|
+
<div className="space-y-1">
|
|
44
|
+
<div className="flex items-center gap-2">
|
|
45
|
+
<h3 className="min-w-0 flex-1 truncate font-semibold">{r.name}</h3>
|
|
46
|
+
{r.liveRun && <RunChip status={r.liveRun.status} />}
|
|
47
|
+
</div>
|
|
48
|
+
<p className="text-muted-foreground text-sm">
|
|
49
|
+
{r.kind === "task" ? "Task" : "Session"} ·{" "}
|
|
50
|
+
{r.projectName ?? "project gone"}
|
|
51
|
+
{r.timer ? ` · ${r.timer.description}` : " · no timer"}
|
|
52
|
+
</p>
|
|
53
|
+
{r.timer && (
|
|
54
|
+
<p className="text-muted-foreground text-xs">
|
|
55
|
+
{r.timer.enabled && r.timer.nextRunAt
|
|
56
|
+
? `Next ${formatRunTime(r.timer.nextRunAt, r.timer.timezone)}`
|
|
57
|
+
: "Timer off"}{" "}
|
|
58
|
+
· {r.timer.timezone}
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
<Totals totals={r.totals} />
|
|
62
|
+
</div>
|
|
63
|
+
<p className="bg-foreground/[0.03] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap">
|
|
64
|
+
{r.brief}
|
|
65
|
+
</p>
|
|
66
|
+
{running ? (
|
|
67
|
+
<RunSheet recipe={r} onClose={() => setRunning(false)} />
|
|
68
|
+
) : (
|
|
69
|
+
<div className="flex flex-wrap gap-2">
|
|
70
|
+
<Button
|
|
71
|
+
size="sm"
|
|
72
|
+
className="h-11 md:h-8"
|
|
73
|
+
disabled={!!r.liveRun}
|
|
74
|
+
onClick={() => setRunning(true)}
|
|
75
|
+
>
|
|
76
|
+
<Play className="h-3.5 w-3.5" />
|
|
77
|
+
{r.liveRun ? "Running" : "Run"}
|
|
78
|
+
</Button>
|
|
79
|
+
<Button
|
|
80
|
+
size="sm"
|
|
81
|
+
variant="outline"
|
|
82
|
+
className="h-11 md:h-8"
|
|
83
|
+
onClick={onEdit}
|
|
84
|
+
>
|
|
85
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
86
|
+
Edit
|
|
87
|
+
</Button>
|
|
88
|
+
{confirm ? (
|
|
89
|
+
<Button
|
|
90
|
+
size="sm"
|
|
91
|
+
variant="destructive"
|
|
92
|
+
className="h-11 md:h-8"
|
|
93
|
+
disabled={archive.isPending}
|
|
94
|
+
onClick={() => archive.mutate(r.id, { onSuccess: onGone })}
|
|
95
|
+
>
|
|
96
|
+
Remove (keeps history)
|
|
97
|
+
</Button>
|
|
98
|
+
) : (
|
|
99
|
+
<Button
|
|
100
|
+
size="sm"
|
|
101
|
+
variant="ghost"
|
|
102
|
+
className="text-muted-foreground h-11 md:h-8"
|
|
103
|
+
onClick={() => setConfirm(true)}
|
|
104
|
+
>
|
|
105
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
106
|
+
Remove
|
|
107
|
+
</Button>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
{r.lastRun?.summary && !r.carry_cleared_at && (
|
|
112
|
+
<p className="text-muted-foreground text-xs">
|
|
113
|
+
The next run is told what the last one reported.{" "}
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
className="text-primary font-medium underline-offset-2 hover:underline"
|
|
117
|
+
disabled={clearCarry.isPending}
|
|
118
|
+
onClick={() => clearCarry.mutate(r.id)}
|
|
119
|
+
>
|
|
120
|
+
Start the next run fresh
|
|
121
|
+
</button>
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
<RunHistory runs={data.runs} />
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useSnapshot } from "valtio";
|
|
5
|
+
import { ArrowLeft, ChefHat, Plus } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
useRecipeDraft,
|
|
9
|
+
useRecipeQuery,
|
|
10
|
+
useRecipesQuery,
|
|
11
|
+
} from "@/data/recipes";
|
|
12
|
+
import { useWorkspacesQuery } from "@/data/workspaces";
|
|
13
|
+
import { formatRunTime } from "@/lib/schedules/cron";
|
|
14
|
+
import type { RecipeView } from "@/lib/recipes/types";
|
|
15
|
+
import { recipesUi, recipesUiActions } from "@/stores/recipesUi";
|
|
16
|
+
import { RecipeForm } from "./RecipeForm";
|
|
17
|
+
import { RecipePage } from "./RecipePage";
|
|
18
|
+
import { RunChip } from "./RunChip";
|
|
19
|
+
import { money, sqlMs } from "./format";
|
|
20
|
+
import { useEffect } from "react";
|
|
21
|
+
|
|
22
|
+
type View =
|
|
23
|
+
| { mode: "list" }
|
|
24
|
+
| { mode: "detail"; id: string }
|
|
25
|
+
| { mode: "form"; id?: string };
|
|
26
|
+
|
|
27
|
+
function Row({ r, onOpen }: { r: RecipeView; onOpen: () => void }) {
|
|
28
|
+
const last = r.liveRun ?? r.lastRun;
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={onOpen}
|
|
33
|
+
className="border-border/60 hover:bg-foreground/[0.03] focus-visible:ring-ring flex min-h-14 w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left outline-none focus-visible:ring-2"
|
|
34
|
+
>
|
|
35
|
+
<div className="min-w-0 flex-1 space-y-0.5">
|
|
36
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
37
|
+
<span className="truncate text-sm font-medium">{r.name}</span>
|
|
38
|
+
<span className="text-muted-foreground shrink-0 text-[11px]">
|
|
39
|
+
{r.projectName}
|
|
40
|
+
</span>
|
|
41
|
+
</div>
|
|
42
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
43
|
+
{r.timer
|
|
44
|
+
? `${r.timer.description}${r.timer.enabled && r.timer.nextRunAt ? ` · next ${formatRunTime(r.timer.nextRunAt, r.timer.timezone)}` : ""}`
|
|
45
|
+
: "No timer"}
|
|
46
|
+
{last && ` · ${formatRunTime(sqlMs(last.created_at))}`}
|
|
47
|
+
{r.totals.costUsd > 0 && ` · ${money(r.totals.costUsd)} total`}
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
{last && <RunChip status={last.status} />}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Loads the finished session's draft, then shows the form filled in.
|
|
56
|
+
function FromSession({
|
|
57
|
+
sessionId,
|
|
58
|
+
onCancel,
|
|
59
|
+
onDone,
|
|
60
|
+
}: {
|
|
61
|
+
sessionId: string;
|
|
62
|
+
onCancel: () => void;
|
|
63
|
+
onDone: (id: string) => void;
|
|
64
|
+
}) {
|
|
65
|
+
const draft = useRecipeDraft();
|
|
66
|
+
const { mutate } = draft;
|
|
67
|
+
useEffect(() => mutate(sessionId), [mutate, sessionId]);
|
|
68
|
+
if (draft.error)
|
|
69
|
+
return <p className="text-destructive text-sm">{draft.error.message}</p>;
|
|
70
|
+
if (!draft.data)
|
|
71
|
+
return <div className="bg-muted/40 h-32 animate-pulse rounded-xl" />;
|
|
72
|
+
return (
|
|
73
|
+
<RecipeForm
|
|
74
|
+
workspaceId={recipesUi.workspaceId}
|
|
75
|
+
draft={draft.data}
|
|
76
|
+
sourceSessionId={sessionId}
|
|
77
|
+
onDone={onDone}
|
|
78
|
+
onCancel={onCancel}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function Edit({
|
|
84
|
+
id,
|
|
85
|
+
onDone,
|
|
86
|
+
onCancel,
|
|
87
|
+
}: {
|
|
88
|
+
id: string;
|
|
89
|
+
onDone: (id: string) => void;
|
|
90
|
+
onCancel: () => void;
|
|
91
|
+
}) {
|
|
92
|
+
const { data } = useRecipeQuery(id);
|
|
93
|
+
return data ? (
|
|
94
|
+
<RecipeForm
|
|
95
|
+
workspaceId={null}
|
|
96
|
+
existing={data.recipe}
|
|
97
|
+
onDone={onDone}
|
|
98
|
+
onCancel={onCancel}
|
|
99
|
+
/>
|
|
100
|
+
) : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Settings > Recipes: saved jobs, one workspace's or every workspace's.
|
|
104
|
+
export function RecipesPanel() {
|
|
105
|
+
const snap = useSnapshot(recipesUi);
|
|
106
|
+
const [chosen, setView] = useState<View>({ mode: "list" });
|
|
107
|
+
const view: View =
|
|
108
|
+
chosen.mode !== "list"
|
|
109
|
+
? chosen
|
|
110
|
+
: snap.fromSessionId
|
|
111
|
+
? { mode: "form" }
|
|
112
|
+
: snap.recipeId
|
|
113
|
+
? { mode: "detail", id: snap.recipeId }
|
|
114
|
+
: chosen;
|
|
115
|
+
const { data: recipes = [], isPending } = useRecipesQuery(
|
|
116
|
+
snap.workspaceId,
|
|
117
|
+
true
|
|
118
|
+
);
|
|
119
|
+
const { data: workspaces = [] } = useWorkspacesQuery();
|
|
120
|
+
const wsName = workspaces.find((w) => w.id === snap.workspaceId)?.name;
|
|
121
|
+
|
|
122
|
+
const toList = () => {
|
|
123
|
+
recipesUiActions.clear();
|
|
124
|
+
setView({ mode: "list" });
|
|
125
|
+
};
|
|
126
|
+
const toDetail = (id: string) => {
|
|
127
|
+
recipesUiActions.clear();
|
|
128
|
+
setView({ mode: "detail", id });
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="space-y-4 [&>*]:min-w-0">
|
|
133
|
+
<div className="flex items-center gap-2">
|
|
134
|
+
{view.mode !== "list" && (
|
|
135
|
+
<Button
|
|
136
|
+
variant="ghost"
|
|
137
|
+
size="icon-sm"
|
|
138
|
+
aria-label="Back"
|
|
139
|
+
className="-ml-2 h-11 w-11 md:h-8 md:w-8"
|
|
140
|
+
onClick={() =>
|
|
141
|
+
view.mode === "form" && view.id ? toDetail(view.id) : toList()
|
|
142
|
+
}
|
|
143
|
+
>
|
|
144
|
+
<ArrowLeft className="h-4 w-4" />
|
|
145
|
+
</Button>
|
|
146
|
+
)}
|
|
147
|
+
<h3 className="min-w-0 flex-1 truncate text-sm font-medium">
|
|
148
|
+
{view.mode === "form"
|
|
149
|
+
? view.id
|
|
150
|
+
? "Edit recipe"
|
|
151
|
+
: snap.fromSessionId
|
|
152
|
+
? "Save as recipe"
|
|
153
|
+
: "New recipe"
|
|
154
|
+
: wsName
|
|
155
|
+
? `In ${wsName}`
|
|
156
|
+
: "In every workspace"}
|
|
157
|
+
</h3>
|
|
158
|
+
{view.mode === "list" && (
|
|
159
|
+
<Button
|
|
160
|
+
size="sm"
|
|
161
|
+
className="h-11 md:h-8"
|
|
162
|
+
onClick={() => setView({ mode: "form" })}
|
|
163
|
+
>
|
|
164
|
+
<Plus className="h-4 w-4" />
|
|
165
|
+
New
|
|
166
|
+
</Button>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
{view.mode === "list" && (
|
|
170
|
+
<div className="space-y-2">
|
|
171
|
+
{isPending &&
|
|
172
|
+
[0, 1].map((i) => (
|
|
173
|
+
<div
|
|
174
|
+
key={i}
|
|
175
|
+
className="bg-muted/40 h-14 animate-pulse rounded-xl"
|
|
176
|
+
/>
|
|
177
|
+
))}
|
|
178
|
+
{!isPending && recipes.length === 0 && (
|
|
179
|
+
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
|
180
|
+
<ChefHat className="text-primary h-6 w-6" />
|
|
181
|
+
<p className="text-muted-foreground max-w-xs text-sm">
|
|
182
|
+
No recipes yet. A recipe saves a job that comes up again, like a
|
|
183
|
+
build or an audit. Each run is a fresh task or session that
|
|
184
|
+
knows what the last one reported.
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
{recipes.map((r) => (
|
|
189
|
+
<Row key={r.id} r={r} onOpen={() => toDetail(r.id)} />
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
{view.mode === "detail" && (
|
|
194
|
+
<RecipePage
|
|
195
|
+
key={view.id}
|
|
196
|
+
id={view.id}
|
|
197
|
+
openRun={snap.runRecipeId === view.id}
|
|
198
|
+
onEdit={() => setView({ mode: "form", id: view.id })}
|
|
199
|
+
onGone={toList}
|
|
200
|
+
/>
|
|
201
|
+
)}
|
|
202
|
+
{view.mode === "form" && view.id && (
|
|
203
|
+
<Edit
|
|
204
|
+
id={view.id}
|
|
205
|
+
onDone={toDetail}
|
|
206
|
+
onCancel={() => toDetail(view.id!)}
|
|
207
|
+
/>
|
|
208
|
+
)}
|
|
209
|
+
{view.mode === "form" && !view.id && snap.fromSessionId && (
|
|
210
|
+
<FromSession
|
|
211
|
+
sessionId={snap.fromSessionId}
|
|
212
|
+
onDone={toDetail}
|
|
213
|
+
onCancel={toList}
|
|
214
|
+
/>
|
|
215
|
+
)}
|
|
216
|
+
{view.mode === "form" && !view.id && !snap.fromSessionId && (
|
|
217
|
+
<RecipeForm
|
|
218
|
+
workspaceId={snap.workspaceId}
|
|
219
|
+
onDone={toDetail}
|
|
220
|
+
onCancel={toList}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RunStatus } from "@/lib/recipes/types";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { STATUS_LABEL, STATUS_TONE } from "./format";
|
|
4
|
+
|
|
5
|
+
export function RunChip({ status }: { status: RunStatus }) {
|
|
6
|
+
return (
|
|
7
|
+
<span
|
|
8
|
+
className={cn(
|
|
9
|
+
"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
|
10
|
+
STATUS_TONE[status]
|
|
11
|
+
)}
|
|
12
|
+
>
|
|
13
|
+
{STATUS_LABEL[status]}
|
|
14
|
+
</span>
|
|
15
|
+
);
|
|
16
|
+
}
|