@saadnvd1/agent-os 0.3.532 → 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.
Files changed (93) hide show
  1. package/README.md +71 -3
  2. package/app/api/devices/connect/route.ts +79 -0
  3. package/app/api/features/pieces/[id]/release/route.ts +16 -0
  4. package/app/api/features/route.ts +8 -0
  5. package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
  6. package/app/api/recipes/[id]/preview/route.ts +19 -0
  7. package/app/api/recipes/[id]/route.ts +51 -0
  8. package/app/api/recipes/[id]/run/route.ts +35 -0
  9. package/app/api/recipes/[id]/runs/route.ts +23 -0
  10. package/app/api/recipes/draft/route.ts +14 -0
  11. package/app/api/recipes/route.ts +35 -0
  12. package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
  13. package/components/CommandPalette/useAppCommands.ts +20 -0
  14. package/components/Devices/ConnectCard.tsx +2 -15
  15. package/components/Features/FeaturesSection.tsx +100 -0
  16. package/components/Features/index.ts +1 -0
  17. package/components/Recipes/RecipeForm.tsx +251 -0
  18. package/components/Recipes/RecipePage.tsx +127 -0
  19. package/components/Recipes/RecipesPanel.tsx +225 -0
  20. package/components/Recipes/RunChip.tsx +16 -0
  21. package/components/Recipes/RunHistory.tsx +88 -0
  22. package/components/Recipes/RunSheet.tsx +84 -0
  23. package/components/Recipes/TimerFields.tsx +193 -0
  24. package/components/Recipes/format.ts +51 -0
  25. package/components/Recipes/index.ts +1 -0
  26. package/components/Schedules/SchedulesPanel.tsx +1 -0
  27. package/components/SessionList/RowSubtitle.tsx +16 -0
  28. package/components/SessionList/SessionRowMenu.tsx +29 -0
  29. package/components/SessionList/SidebarHeader.tsx +5 -0
  30. package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
  31. package/components/Settings/SettingsDialog.tsx +9 -0
  32. package/components/Tasks/PlanReview.tsx +253 -0
  33. package/components/Tasks/TaskRow.tsx +19 -44
  34. package/components/Tasks/TasksDialog.tsx +2 -0
  35. package/components/Welcome/Block.tsx +26 -0
  36. package/components/Welcome/ConnectBlock.tsx +169 -0
  37. package/components/Welcome/DevicesStep.tsx +4 -26
  38. package/data/devices/queries.ts +21 -1
  39. package/data/features/index.ts +40 -0
  40. package/data/push/topics.ts +8 -0
  41. package/data/recipes/index.ts +129 -0
  42. package/data/tasks/index.ts +14 -0
  43. package/lib/away/run.ts +4 -1
  44. package/lib/connect/cli.ts +6 -18
  45. package/lib/connect/link.ts +35 -0
  46. package/lib/connect/serve.ts +12 -1
  47. package/lib/connect/view.ts +28 -0
  48. package/lib/db/changes.ts +8 -0
  49. package/lib/db/migrations.ts +127 -0
  50. package/lib/db/queries.ts +4 -1
  51. package/lib/db/types.ts +6 -0
  52. package/lib/models/plan-step.ts +67 -6
  53. package/lib/notify/copy.ts +28 -10
  54. package/lib/notify/push/events.ts +36 -2
  55. package/lib/notify/push/policy.ts +2 -0
  56. package/lib/notify/push-types.ts +7 -3
  57. package/lib/orchestrator/brief.ts +13 -0
  58. package/lib/orchestrator/conditions.ts +3 -1
  59. package/lib/orchestrator/deliver.ts +2 -0
  60. package/lib/orchestrator/digest.ts +27 -1
  61. package/lib/orchestrator/plan-pieces.ts +41 -0
  62. package/lib/orchestrator/recipe-tools.ts +159 -0
  63. package/lib/orchestrator/review.ts +2 -1
  64. package/lib/orchestrator/serve.ts +25 -0
  65. package/lib/orchestrator/tool-names.ts +5 -0
  66. package/lib/orchestrator/tool-schemas.ts +54 -0
  67. package/lib/orchestrator/tools.ts +10 -0
  68. package/lib/plans/features.ts +200 -0
  69. package/lib/plans/pieces.ts +366 -0
  70. package/lib/plans/release.ts +323 -0
  71. package/lib/plans/view.ts +85 -0
  72. package/lib/recipes/api.ts +19 -0
  73. package/lib/recipes/chip.ts +40 -0
  74. package/lib/recipes/compose.ts +78 -0
  75. package/lib/recipes/draft.ts +93 -0
  76. package/lib/recipes/index.ts +54 -0
  77. package/lib/recipes/result.ts +59 -0
  78. package/lib/recipes/run.ts +202 -0
  79. package/lib/recipes/settle.ts +465 -0
  80. package/lib/recipes/store.ts +378 -0
  81. package/lib/recipes/test-fixtures.ts +49 -0
  82. package/lib/recipes/timer.ts +107 -0
  83. package/lib/recipes/types.ts +116 -0
  84. package/lib/schedules/index.ts +4 -1
  85. package/lib/schedules/start.ts +33 -9
  86. package/lib/schedules/store.ts +6 -2
  87. package/lib/security/demo.ts +2 -0
  88. package/lib/settings-url.ts +1 -0
  89. package/lib/tasks/loop-types.ts +23 -0
  90. package/lib/tasks/plan-view.ts +38 -1
  91. package/package.json +1 -1
  92. package/server.ts +2 -0
  93. 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
+ }