@saadnvd1/agent-os 0.3.533 → 0.3.535

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 (86) hide show
  1. package/README.md +74 -4
  2. package/app/api/features/pieces/[id]/release/route.ts +16 -0
  3. package/app/api/features/route.ts +8 -0
  4. package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
  5. package/app/api/recipes/[id]/preview/route.ts +19 -0
  6. package/app/api/recipes/[id]/route.ts +51 -0
  7. package/app/api/recipes/[id]/run/route.ts +35 -0
  8. package/app/api/recipes/[id]/runs/route.ts +23 -0
  9. package/app/api/recipes/draft/route.ts +14 -0
  10. package/app/api/recipes/route.ts +35 -0
  11. package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
  12. package/components/CommandPalette/useAppCommands.ts +20 -0
  13. package/components/Features/FeaturesSection.tsx +100 -0
  14. package/components/Features/index.ts +1 -0
  15. package/components/Recipes/RecipeForm.tsx +251 -0
  16. package/components/Recipes/RecipePage.tsx +127 -0
  17. package/components/Recipes/RecipesPanel.tsx +225 -0
  18. package/components/Recipes/RunChip.tsx +16 -0
  19. package/components/Recipes/RunHistory.tsx +88 -0
  20. package/components/Recipes/RunSheet.tsx +84 -0
  21. package/components/Recipes/TimerFields.tsx +193 -0
  22. package/components/Recipes/format.ts +51 -0
  23. package/components/Recipes/index.ts +1 -0
  24. package/components/Schedules/ScheduleForm.tsx +20 -2
  25. package/components/Schedules/SchedulesPanel.tsx +1 -0
  26. package/components/SessionList/RowSubtitle.tsx +16 -0
  27. package/components/SessionList/SessionRowMenu.tsx +29 -0
  28. package/components/SessionList/SidebarHeader.tsx +5 -0
  29. package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
  30. package/components/Settings/SettingsDialog.tsx +9 -0
  31. package/components/Tasks/PlanReview.tsx +253 -0
  32. package/components/Tasks/TaskRow.tsx +19 -44
  33. package/components/Tasks/TasksDialog.tsx +2 -0
  34. package/data/features/index.ts +40 -0
  35. package/data/push/topics.ts +8 -0
  36. package/data/recipes/index.ts +129 -0
  37. package/data/tasks/index.ts +14 -0
  38. package/lib/away/run.ts +4 -1
  39. package/lib/db/changes.ts +8 -0
  40. package/lib/db/migrations.ts +143 -0
  41. package/lib/db/queries.ts +4 -1
  42. package/lib/db/schedules-move.ts +220 -0
  43. package/lib/db/types.ts +6 -0
  44. package/lib/models/plan-step.ts +67 -6
  45. package/lib/notify/copy.ts +28 -10
  46. package/lib/notify/push/events.ts +36 -2
  47. package/lib/notify/push/policy.ts +2 -0
  48. package/lib/notify/push-types.ts +7 -3
  49. package/lib/orchestrator/brief.ts +13 -0
  50. package/lib/orchestrator/conditions.ts +3 -1
  51. package/lib/orchestrator/deliver.ts +2 -0
  52. package/lib/orchestrator/digest.ts +27 -1
  53. package/lib/orchestrator/plan-pieces.ts +41 -0
  54. package/lib/orchestrator/recipe-tools.ts +159 -0
  55. package/lib/orchestrator/review.ts +2 -1
  56. package/lib/orchestrator/serve.ts +25 -0
  57. package/lib/orchestrator/tool-names.ts +5 -0
  58. package/lib/orchestrator/tool-schemas.ts +54 -0
  59. package/lib/orchestrator/tools.ts +10 -0
  60. package/lib/plans/features.ts +200 -0
  61. package/lib/plans/pieces.ts +366 -0
  62. package/lib/plans/release.ts +323 -0
  63. package/lib/plans/view.ts +85 -0
  64. package/lib/recipes/api.ts +19 -0
  65. package/lib/recipes/chip.ts +40 -0
  66. package/lib/recipes/compose.ts +78 -0
  67. package/lib/recipes/draft.ts +93 -0
  68. package/lib/recipes/index.ts +54 -0
  69. package/lib/recipes/result.ts +59 -0
  70. package/lib/recipes/run.ts +202 -0
  71. package/lib/recipes/settle.ts +465 -0
  72. package/lib/recipes/store.ts +378 -0
  73. package/lib/recipes/test-fixtures.ts +49 -0
  74. package/lib/recipes/timer.ts +107 -0
  75. package/lib/recipes/types.ts +116 -0
  76. package/lib/schedules/index.ts +4 -1
  77. package/lib/schedules/start.ts +33 -9
  78. package/lib/schedules/store.ts +6 -2
  79. package/lib/security/demo.ts +1 -0
  80. package/lib/settings-url.ts +1 -0
  81. package/lib/tasks/loop-types.ts +23 -0
  82. package/lib/tasks/plan-view.ts +38 -1
  83. package/package.json +1 -1
  84. package/scripts/revert-schedules-move.ts +24 -0
  85. package/server.ts +2 -0
  86. package/stores/recipesUi.ts +53 -0
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import { ArrowUpRight } from "lucide-react";
4
+ import type { RecipeRun, RecipeTotals } from "@/lib/recipes/types";
5
+ import { formatRunTime } from "@/lib/schedules/cron";
6
+ import { settingsUiActions } from "@/stores/settingsUi";
7
+ import { sessionOpenActions } from "@/stores/sessionOpen";
8
+ import { RunChip } from "./RunChip";
9
+ import { duration, money, sqlMs, triggerLabel } from "./format";
10
+
11
+ export function Totals({ totals }: { totals: RecipeTotals }) {
12
+ if (!totals.runs) return null;
13
+ const bits = [
14
+ `${totals.runs} ${totals.runs === 1 ? "run" : "runs"}`,
15
+ totals.failed ? `${totals.failed} failed` : null,
16
+ totals.costUsd ? money(totals.costUsd) : null,
17
+ totals.avgMs ? `avg ${duration(totals.avgMs)}` : null,
18
+ ].filter(Boolean);
19
+ return <p className="text-muted-foreground text-xs">{bits.join(" · ")}</p>;
20
+ }
21
+
22
+ function Facts({ run }: { run: RecipeRun }) {
23
+ const outputs = run.outputs
24
+ ? Object.entries(run.outputs)
25
+ .map(([k, v]) => `${k} ${v}`)
26
+ .join(" · ")
27
+ : "";
28
+ const text = [outputs, run.summary ?? run.error].filter(Boolean).join(". ");
29
+ return text ? (
30
+ <p className="text-muted-foreground line-clamp-2 text-xs">{text}</p>
31
+ ) : null;
32
+ }
33
+
34
+ export function RunHistory({ runs }: { runs: RecipeRun[] }) {
35
+ return (
36
+ <div className="space-y-2">
37
+ <p className="label-mono text-muted-foreground">History</p>
38
+ {runs.length === 0 && (
39
+ <p className="text-muted-foreground text-sm">No runs yet.</p>
40
+ )}
41
+ <ul className="divide-border/60 divide-y">
42
+ {runs.map((r) => {
43
+ const stats = [duration(r.duration_ms), money(r.cost_usd)]
44
+ .filter(Boolean)
45
+ .join(" · ");
46
+ return (
47
+ <li key={r.id} className="flex items-start gap-3 py-2.5">
48
+ <RunChip status={r.status} />
49
+ <div className="min-w-0 flex-1 space-y-0.5">
50
+ <p className="text-sm tabular-nums">
51
+ #{r.seq} · {formatRunTime(sqlMs(r.created_at))}
52
+ </p>
53
+ <p className="text-muted-foreground truncate text-xs">
54
+ {[
55
+ triggerLabel(r),
56
+ r.pr_number ? `PR #${r.pr_number}` : null,
57
+ stats,
58
+ ]
59
+ .filter(Boolean)
60
+ .join(" · ")}
61
+ </p>
62
+ <Facts run={r} />
63
+ {r.note && (
64
+ <p className="text-muted-foreground truncate text-xs italic">
65
+ Note: {r.note}
66
+ </p>
67
+ )}
68
+ </div>
69
+ {r.session_id && (
70
+ <button
71
+ type="button"
72
+ onClick={() => {
73
+ sessionOpenActions.request(r.session_id!);
74
+ settingsUiActions.close();
75
+ }}
76
+ className="text-primary -mr-2 flex min-h-11 shrink-0 items-center gap-1 px-2 text-xs font-medium"
77
+ >
78
+ Open
79
+ <ArrowUpRight className="h-3.5 w-3.5" />
80
+ </button>
81
+ )}
82
+ </li>
83
+ );
84
+ })}
85
+ </ul>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import { Play } from "lucide-react";
5
+ import { uuid } from "@/lib/uuid";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Textarea } from "@/components/ui/textarea";
8
+ import { useRunRecipe, usePreviewQuery } from "@/data/recipes";
9
+ import { MAX_NOTE, type RecipeView } from "@/lib/recipes/types";
10
+
11
+ // Run now: an optional one-off note, and what will be sent. The request id
12
+ // is made once per sheet, so a double tap starts one run.
13
+ export function RunSheet({
14
+ recipe,
15
+ onClose,
16
+ }: {
17
+ recipe: RecipeView;
18
+ onClose: () => void;
19
+ }) {
20
+ const [note, setNote] = useState("");
21
+ const [showPrompt, setShowPrompt] = useState(false);
22
+ const requestId = useRef(uuid());
23
+ const run = useRunRecipe();
24
+ const preview = usePreviewQuery(recipe.id, note.trim(), showPrompt);
25
+ const go = () =>
26
+ run.mutate(
27
+ {
28
+ id: recipe.id,
29
+ note: note.trim() || undefined,
30
+ requestId: requestId.current,
31
+ },
32
+ { onSuccess: onClose }
33
+ );
34
+ return (
35
+ <div className="border-border/60 bg-foreground/[0.03] space-y-3 rounded-xl border p-3">
36
+ <label className="text-sm font-medium" htmlFor="run-note">
37
+ Note for this run (optional)
38
+ </label>
39
+ <Textarea
40
+ id="run-note"
41
+ value={note}
42
+ maxLength={MAX_NOTE}
43
+ rows={2}
44
+ placeholder="Includes #87"
45
+ onChange={(e) => setNote(e.target.value)}
46
+ />
47
+ <button
48
+ type="button"
49
+ aria-expanded={showPrompt}
50
+ className="text-primary min-h-11 text-xs font-medium md:min-h-8"
51
+ onClick={() => setShowPrompt((v) => !v)}
52
+ >
53
+ {showPrompt ? "Hide what it sends" : "Show what it sends"}
54
+ </button>
55
+ {showPrompt && (
56
+ <pre className="bg-background max-h-64 overflow-auto rounded-lg p-3 text-xs whitespace-pre-wrap">
57
+ {preview.data?.prompt ?? (preview.error?.message || "Loading...")}
58
+ </pre>
59
+ )}
60
+ {run.error && (
61
+ <p className="text-destructive text-sm">{run.error.message}</p>
62
+ )}
63
+ <div className="flex gap-2">
64
+ <Button
65
+ size="sm"
66
+ className="h-11 md:h-8"
67
+ disabled={run.isPending || !!recipe.liveRun}
68
+ onClick={go}
69
+ >
70
+ <Play className="h-3.5 w-3.5" />
71
+ {run.isPending ? "Starting..." : "Run"}
72
+ </Button>
73
+ <Button
74
+ size="sm"
75
+ variant="ghost"
76
+ className="h-11 md:h-8"
77
+ onClick={onClose}
78
+ >
79
+ Cancel
80
+ </Button>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { Input } from "@/components/ui/input";
5
+ import { cn } from "@/lib/utils";
6
+ import {
7
+ clock12,
8
+ cronError,
9
+ cronPreset,
10
+ DEFAULT_TIMEZONE,
11
+ describeCron,
12
+ formatRunTime,
13
+ isTimezone,
14
+ MIN_GAP_MINUTES,
15
+ minGapMinutes,
16
+ nextRuns,
17
+ presetCron,
18
+ type PresetKind,
19
+ } from "@/lib/schedules/cron";
20
+ import type { RecipeTimer } from "@/lib/recipes/types";
21
+
22
+ type Mode = "off" | Exclude<PresetKind, "every"> | "custom";
23
+
24
+ const MODES: { value: Mode; label: string }[] = [
25
+ { value: "off", label: "No timer" },
26
+ { value: "hourly", label: "Hourly" },
27
+ { value: "daily", label: "Daily" },
28
+ { value: "weekdays", label: "Weekdays" },
29
+ { value: "weekly", label: "Weekly" },
30
+ { value: "custom", label: "Custom" },
31
+ ];
32
+ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
33
+ const select =
34
+ "border-input bg-background min-h-11 rounded-md border px-2 text-sm md:min-h-9";
35
+
36
+ export interface TimerValue {
37
+ mode: Mode;
38
+ hour: number;
39
+ minute: number;
40
+ weekday: number;
41
+ custom: string;
42
+ timezone: string;
43
+ }
44
+
45
+ export function timerValue(timer: RecipeTimer | null): TimerValue {
46
+ const preset = timer ? cronPreset(timer.cron) : null;
47
+ const mode: Mode = !timer
48
+ ? "off"
49
+ : preset && preset.kind !== "every"
50
+ ? preset.kind
51
+ : "custom";
52
+ return {
53
+ mode,
54
+ hour: preset?.hour ?? 9,
55
+ minute: preset?.minute ?? 0,
56
+ weekday: preset?.weekday ?? 1,
57
+ custom: timer?.cron ?? "0 9 * * 1-5",
58
+ timezone: timer?.timezone ?? DEFAULT_TIMEZONE,
59
+ };
60
+ }
61
+
62
+ export function timerCron(v: TimerValue): string | null {
63
+ if (v.mode === "off") return null;
64
+ if (v.mode === "custom") return v.custom.trim();
65
+ return presetCron({
66
+ kind: v.mode,
67
+ hour: v.hour,
68
+ minute: v.minute,
69
+ weekday: v.weekday,
70
+ });
71
+ }
72
+
73
+ // Why the timer can't be saved, or null.
74
+ export function timerProblem(v: TimerValue): string | null {
75
+ const cron = timerCron(v);
76
+ if (!cron) return null;
77
+ if (!isTimezone(v.timezone)) return "Unknown time zone";
78
+ const bad = cronError(cron);
79
+ if (bad) return bad;
80
+ return minGapMinutes(cron, v.timezone) < MIN_GAP_MINUTES
81
+ ? "A recipe runs at most once an hour"
82
+ : null;
83
+ }
84
+
85
+ export function TimerFields({
86
+ value,
87
+ onChange,
88
+ }: {
89
+ value: TimerValue;
90
+ onChange: (v: TimerValue) => void;
91
+ }) {
92
+ const set = (patch: Partial<TimerValue>) => onChange({ ...value, ...patch });
93
+ const cron = timerCron(value);
94
+ const problem = timerProblem(value);
95
+ const [openedAt] = useState(() => Date.now());
96
+ const upcoming = useMemo(
97
+ () => (cron && !problem ? nextRuns(cron, openedAt, 3, value.timezone) : []),
98
+ [cron, problem, value.timezone, openedAt]
99
+ );
100
+ return (
101
+ <div className="space-y-3">
102
+ <div
103
+ role="radiogroup"
104
+ aria-label="Timer"
105
+ className="bg-foreground/[0.04] grid grid-cols-3 gap-1 rounded-lg p-1 md:flex"
106
+ >
107
+ {MODES.map((m) => (
108
+ <button
109
+ key={m.value}
110
+ type="button"
111
+ role="radio"
112
+ aria-checked={value.mode === m.value}
113
+ onClick={() => set({ mode: m.value })}
114
+ className={cn(
115
+ "min-h-11 min-w-0 flex-1 truncate rounded-md px-1 text-xs font-medium md:min-h-8 md:px-3 md:text-sm",
116
+ value.mode === m.value
117
+ ? "bg-background text-foreground shadow-sm"
118
+ : "text-muted-foreground hover:text-foreground"
119
+ )}
120
+ >
121
+ {m.label}
122
+ </button>
123
+ ))}
124
+ </div>
125
+ {value.mode === "weekly" && (
126
+ <select
127
+ aria-label="Day"
128
+ className={select}
129
+ value={value.weekday}
130
+ onChange={(e) => set({ weekday: Number(e.target.value) })}
131
+ >
132
+ {DAYS.map((d, i) => (
133
+ <option key={d} value={i}>
134
+ {d}
135
+ </option>
136
+ ))}
137
+ </select>
138
+ )}
139
+ {(value.mode === "daily" ||
140
+ value.mode === "weekdays" ||
141
+ value.mode === "weekly") && (
142
+ <span className="ml-0 flex items-center gap-2">
143
+ <select
144
+ aria-label="Hour"
145
+ className={select}
146
+ value={value.hour}
147
+ onChange={(e) => set({ hour: Number(e.target.value) })}
148
+ >
149
+ {Array.from({ length: 24 }, (_, h) => (
150
+ <option key={h} value={h}>
151
+ {clock12(h, 0).replace(":00", "")}
152
+ </option>
153
+ ))}
154
+ </select>
155
+ :
156
+ <select
157
+ aria-label="Minute"
158
+ className={select}
159
+ value={value.minute}
160
+ onChange={(e) => set({ minute: Number(e.target.value) })}
161
+ >
162
+ {Array.from({ length: 12 }, (_, i) => i * 5).map((m) => (
163
+ <option key={m} value={m}>
164
+ {String(m).padStart(2, "0")}
165
+ </option>
166
+ ))}
167
+ </select>
168
+ </span>
169
+ )}
170
+ {value.mode === "custom" && (
171
+ <Input
172
+ aria-label="Cron expression"
173
+ className="font-mono"
174
+ value={value.custom}
175
+ onChange={(e) => set({ custom: e.target.value })}
176
+ />
177
+ )}
178
+ {cron && (
179
+ <p
180
+ className={cn(
181
+ "text-xs",
182
+ problem ? "text-destructive" : "text-muted-foreground"
183
+ )}
184
+ >
185
+ {problem ??
186
+ `${describeCron(cron)} · next ${upcoming
187
+ .map((t) => formatRunTime(t, value.timezone))
188
+ .join(", ")}`}
189
+ </p>
190
+ )}
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,51 @@
1
+ import type { RecipeRun, RunStatus } from "@/lib/recipes/types";
2
+
3
+ export const STATUS_LABEL: Record<RunStatus, string> = {
4
+ queued: "Queued",
5
+ starting: "Starting",
6
+ running: "Running",
7
+ succeeded: "Succeeded",
8
+ failed: "Failed",
9
+ blocked: "Needs you",
10
+ timed_out: "Timed out",
11
+ cancelled: "Cancelled",
12
+ skipped: "Skipped",
13
+ };
14
+
15
+ export const STATUS_TONE: Record<RunStatus, string> = {
16
+ queued: "bg-foreground/10 text-muted-foreground",
17
+ starting: "bg-primary/15 text-primary",
18
+ running: "bg-primary/15 text-primary",
19
+ succeeded: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
20
+ failed: "bg-destructive/15 text-destructive",
21
+ blocked: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
22
+ timed_out: "bg-destructive/15 text-destructive",
23
+ cancelled: "bg-foreground/10 text-muted-foreground",
24
+ skipped: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
25
+ };
26
+
27
+ // SQLite's datetime('now') is UTC without a zone marker.
28
+ export const sqlMs = (t: string | null) =>
29
+ t ? Date.parse(`${t.replace(" ", "T")}Z`) : 0;
30
+
31
+ export function duration(ms: number | null): string {
32
+ if (ms == null) return "";
33
+ const s = Math.round(ms / 1000);
34
+ if (s < 60) return `${s}s`;
35
+ const m = Math.round(s / 60);
36
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
37
+ }
38
+
39
+ export const money = (n: number | null) =>
40
+ n == null ? "" : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`;
41
+
42
+ const TRIGGER_LABEL = {
43
+ manual: "by hand",
44
+ tool: "by the orchestrator",
45
+ cli: "by an agent",
46
+ timer: "on its timer",
47
+ "catch-up": "on its timer, late",
48
+ } as const;
49
+
50
+ export const triggerLabel = (r: Pick<RecipeRun, "trigger">) =>
51
+ TRIGGER_LABEL[r.trigger];
@@ -0,0 +1 @@
1
+ export { RecipesPanel } from "./RecipesPanel";
@@ -34,6 +34,7 @@ import {
34
34
  wall,
35
35
  type PresetKind,
36
36
  } from "@/lib/schedules/cron";
37
+ import { recipesUiActions } from "@/stores/recipesUi";
37
38
  import { parseWhen, relativeTime } from "@/lib/schedules/when";
38
39
 
39
40
  const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
@@ -59,6 +60,10 @@ const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
59
60
  },
60
61
  ];
61
62
 
63
+ // New jobs of these shapes are recipes; the kinds stay only to edit a
64
+ // schedule that still has one (a one-off, say).
65
+ const LEGACY_KINDS: ScheduleKind[] = ["task", "session"];
66
+
62
67
  type Preset = PresetKind | "custom" | "once";
63
68
 
64
69
  const PRESETS: { value: Preset; label: string }[] = [
@@ -171,7 +176,7 @@ export function ScheduleForm({
171
176
  );
172
177
  const [name, setName] = useState(existing?.name ?? draft?.name ?? "");
173
178
  const [kind, setKind] = useState<ScheduleKind>(
174
- existing?.kind ?? draft?.kind ?? "task"
179
+ existing?.kind ?? draft?.kind ?? "orchestrator"
175
180
  );
176
181
  const [targetId, setTargetId] = useState(
177
182
  existing?.target_session_id ?? draft?.targetSessionId ?? ""
@@ -329,7 +334,9 @@ export function ScheduleForm({
329
334
  <Segmented
330
335
  label="What it starts"
331
336
  value={kind}
332
- options={KINDS}
337
+ options={KINDS.filter(
338
+ (k) => !LEGACY_KINDS.includes(k.value) || existing?.kind === k.value
339
+ )}
333
340
  onChange={(k) => {
334
341
  setKind(k);
335
342
  if (preset === "every" && !frequent(k)) setPreset("daily");
@@ -338,6 +345,17 @@ export function ScheduleForm({
338
345
  <p className="text-muted-foreground text-xs">
339
346
  {KINDS.find((k) => k.value === kind)?.hint}
340
347
  </p>
348
+ <p className="text-muted-foreground text-xs">
349
+ To start a task or session on a timer, use a{" "}
350
+ <button
351
+ type="button"
352
+ className="text-foreground underline underline-offset-2"
353
+ onClick={() => recipesUiActions.open(workspaceId ?? null)}
354
+ >
355
+ recipe
356
+ </button>
357
+ .
358
+ </p>
341
359
  </Field>
342
360
 
343
361
  {!workspaceId && (
@@ -40,6 +40,7 @@ const KIND_LABEL = {
40
40
  session: "Session",
41
41
  orchestrator: "Orchestrator",
42
42
  message: "Message",
43
+ recipe: "Recipe",
43
44
  } as const;
44
45
 
45
46
  // "Task · api", "Message → orchestrator".
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
+ import { Repeat, Layers } from "lucide-react";
3
4
  import { HostBadge } from "@/components/Hosts";
5
+ import { rowGroup } from "@/lib/recipes/chip";
4
6
  import type { Session } from "@/lib/db";
5
7
  import { LiveLine, type LiveInput } from "./LiveLine";
6
8
  import { headerParts } from "@/lib/orchestrator/header-line";
@@ -53,9 +55,23 @@ export function RowSubtitle({
53
55
  );
54
56
  }
55
57
  const project = ctx.projectNames.get(session.project_id ?? "");
58
+ const group = rowGroup(session);
56
59
  return (
57
60
  <span className="flex min-w-0 items-center gap-1.5">
58
61
  <LiveLine {...live} />
62
+ {group && (
63
+ <span
64
+ className="text-muted-foreground/70 flex min-w-0 shrink-0 items-center gap-1 truncate text-xs"
65
+ title={group.kind === "recipe" ? "A recipe run" : "A piece of a plan"}
66
+ >
67
+ {group.kind === "recipe" ? (
68
+ <Repeat aria-hidden className="h-3 w-3 shrink-0" />
69
+ ) : (
70
+ <Layers aria-hidden className="h-3 w-3 shrink-0" />
71
+ )}
72
+ <span className="truncate">{group.text}</span>
73
+ </span>
74
+ )}
59
75
  {(project || detail || usage) && (
60
76
  <span className="text-muted-foreground/70 truncate text-xs">
61
77
  {[usage && usageLabel(usage), detail?.slice(0, 200), project]
@@ -14,6 +14,9 @@ import {
14
14
  Loader2,
15
15
  Pencil,
16
16
  Pin,
17
+ Play,
18
+ Repeat,
19
+ ChefHat,
17
20
  PinOff,
18
21
  RotateCcw,
19
22
  Sparkles,
@@ -24,6 +27,8 @@ import * as CM from "@/components/ui/context-menu";
24
27
  import type { Session } from "@/lib/db";
25
28
  import { selectionActions } from "@/stores/sessionSelection";
26
29
  import { schedulesUiActions } from "@/stores/schedulesUi";
30
+ import { recipesUiActions } from "@/stores/recipesUi";
31
+ import { rowGroup } from "@/lib/recipes/chip";
27
32
  import { useDoneAction } from "./useDoneAction";
28
33
  import { useRowContext } from "./RowContext";
29
34
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
@@ -95,6 +100,8 @@ export function SessionRowMenu({
95
100
  const others = ctx.projects.filter(
96
101
  (p) => p.id !== session.project_id && !p.is_uncategorized
97
102
  );
103
+ const group = rowGroup(session);
104
+ const recipeRun = group?.kind === "recipe" ? { id: group.id } : null;
98
105
  const workspaceId =
99
106
  session.workspace_id ??
100
107
  ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
@@ -251,6 +258,28 @@ export function SessionRowMenu({
251
258
  Schedule check-ins
252
259
  </Item>
253
260
  )}
261
+ {recipeRun && (
262
+ <>
263
+ <Item onClick={() => recipesUiActions.openRecipe(recipeRun.id)}>
264
+ <Repeat className={icon} />
265
+ Open recipe
266
+ </Item>
267
+ <Item onClick={() => recipesUiActions.openRecipe(recipeRun.id, true)}>
268
+ <Play className={icon} />
269
+ Run again
270
+ </Item>
271
+ </>
272
+ )}
273
+ {!recipeRun && !session.role && !session.quick_of && (
274
+ <Item
275
+ onClick={() =>
276
+ recipesUiActions.saveSessionAs(session.id, workspaceId)
277
+ }
278
+ >
279
+ <ChefHat className={icon} />
280
+ Save as recipe
281
+ </Item>
282
+ )}
254
283
  <Item
255
284
  onClick={() =>
256
285
  selectionActions.toggle(session.id, false, ctx.orderedIds())
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  Archive,
5
5
  BookOpen,
6
+ ChefHat,
6
7
  Clock,
7
8
  FolderGit2,
8
9
  FolderPlus,
@@ -31,6 +32,7 @@ import { archivedUiActions } from "@/stores/archivedUi";
31
32
  import { tasksUiActions } from "@/stores/tasksUi";
32
33
  import { newDraft } from "@/stores/drafts";
33
34
  import { schedulesUiActions } from "@/stores/schedulesUi";
35
+ import { recipesUiActions } from "@/stores/recipesUi";
34
36
  import { busUiActions } from "@/stores/busUi";
35
37
  import { settingsUiActions } from "@/stores/settingsUi";
36
38
  import { usageUiActions } from "@/stores/usageUi";
@@ -106,6 +108,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
106
108
  </span>
107
109
  ),
108
110
  }),
111
+ menuItem("Recipes", () => recipesUiActions.open(workspaceId), {
112
+ icon: ChefHat,
113
+ }),
109
114
  menuItem("Messages", busUiActions.open, { icon: MessagesSquare }),
110
115
  ...(props.onOpenDocs
111
116
  ? [menuItem("Docs", props.onOpenDocs, { icon: BookOpen })]
@@ -4,6 +4,7 @@ import { useState } from "react";
4
4
  import {
5
5
  Archive,
6
6
  Check,
7
+ ChefHat,
7
8
  ChevronDown,
8
9
  CircleCheck,
9
10
  Clock,
@@ -30,6 +31,7 @@ import { archivedUiActions, cleanupUiActions } from "@/stores/archivedUi";
30
31
  import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
31
32
  import { sidebarUiActions } from "@/stores/sidebarUi";
32
33
  import { schedulesUiActions } from "@/stores/schedulesUi";
34
+ import { recipesUiActions } from "@/stores/recipesUi";
33
35
  import { useAddExistingProject } from "@/components/Projects/AddProject/useAddExistingProject";
34
36
 
35
37
  const icon = "mr-2 h-3.5 w-3.5";
@@ -89,10 +91,18 @@ export function WorkspaceSwitcher({
89
91
  New workspace
90
92
  </DM.DropdownMenuItem>
91
93
  {!current && (
92
- <DM.DropdownMenuItem onClick={() => schedulesUiActions.open(null)}>
93
- <Clock className={icon} />
94
- Schedules
95
- </DM.DropdownMenuItem>
94
+ <>
95
+ <DM.DropdownMenuItem onClick={() => recipesUiActions.open(null)}>
96
+ <ChefHat className={icon} />
97
+ Recipes
98
+ </DM.DropdownMenuItem>
99
+ <DM.DropdownMenuItem
100
+ onClick={() => schedulesUiActions.open(null)}
101
+ >
102
+ <Clock className={icon} />
103
+ Schedules
104
+ </DM.DropdownMenuItem>
105
+ </>
96
106
  )}
97
107
  {current && (
98
108
  <>
@@ -110,6 +120,12 @@ export function WorkspaceSwitcher({
110
120
  <Workflow className={icon} />
111
121
  Orchestrator
112
122
  </DM.DropdownMenuItem>
123
+ <DM.DropdownMenuItem
124
+ onClick={() => recipesUiActions.open(current.id)}
125
+ >
126
+ <ChefHat className={icon} />
127
+ Recipes
128
+ </DM.DropdownMenuItem>
113
129
  <DM.DropdownMenuItem
114
130
  onClick={() => schedulesUiActions.open(current.id)}
115
131
  >
@@ -6,6 +6,7 @@ import {
6
6
  ArrowLeft,
7
7
  Bell,
8
8
  ChevronRight,
9
+ ChefHat,
9
10
  ClipboardList,
10
11
  Clock,
11
12
  Cpu,
@@ -34,6 +35,7 @@ import { useSettingsUrl } from "@/hooks/useSettingsUrl";
34
35
  import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
35
36
  import { cn } from "@/lib/utils";
36
37
  import { schedulesUiActions } from "@/stores/schedulesUi";
38
+ import { RecipesPanel } from "@/components/Recipes";
37
39
  import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
38
40
  import { SetupSection } from "./SetupSection";
39
41
  import {
@@ -70,6 +72,11 @@ export const SECTION_INFO: Record<
70
72
  hint: "Sound, browser alerts, your phone",
71
73
  icon: Bell,
72
74
  },
75
+ recipes: {
76
+ label: "Recipes",
77
+ hint: "Saved jobs you run again, by hand or on a timer",
78
+ icon: ChefHat,
79
+ },
73
80
  schedules: {
74
81
  label: "Schedules",
75
82
  hint: "Work that starts at set times",
@@ -105,6 +112,8 @@ function SectionBody({
105
112
  return <DevicesPanel />;
106
113
  case "notifications":
107
114
  return <NotificationsSection browser={browser} />;
115
+ case "recipes":
116
+ return <RecipesPanel />;
108
117
  case "schedules":
109
118
  return <SchedulesPanel />;
110
119
  case "audits":