@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.
Files changed (83) hide show
  1. package/README.md +68 -2
  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/SchedulesPanel.tsx +1 -0
  25. package/components/SessionList/RowSubtitle.tsx +16 -0
  26. package/components/SessionList/SessionRowMenu.tsx +29 -0
  27. package/components/SessionList/SidebarHeader.tsx +5 -0
  28. package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
  29. package/components/Settings/SettingsDialog.tsx +9 -0
  30. package/components/Tasks/PlanReview.tsx +253 -0
  31. package/components/Tasks/TaskRow.tsx +19 -44
  32. package/components/Tasks/TasksDialog.tsx +2 -0
  33. package/data/features/index.ts +40 -0
  34. package/data/push/topics.ts +8 -0
  35. package/data/recipes/index.ts +129 -0
  36. package/data/tasks/index.ts +14 -0
  37. package/lib/away/run.ts +4 -1
  38. package/lib/db/changes.ts +8 -0
  39. package/lib/db/migrations.ts +127 -0
  40. package/lib/db/queries.ts +4 -1
  41. package/lib/db/types.ts +6 -0
  42. package/lib/models/plan-step.ts +67 -6
  43. package/lib/notify/copy.ts +28 -10
  44. package/lib/notify/push/events.ts +36 -2
  45. package/lib/notify/push/policy.ts +2 -0
  46. package/lib/notify/push-types.ts +7 -3
  47. package/lib/orchestrator/brief.ts +13 -0
  48. package/lib/orchestrator/conditions.ts +3 -1
  49. package/lib/orchestrator/deliver.ts +2 -0
  50. package/lib/orchestrator/digest.ts +27 -1
  51. package/lib/orchestrator/plan-pieces.ts +41 -0
  52. package/lib/orchestrator/recipe-tools.ts +159 -0
  53. package/lib/orchestrator/review.ts +2 -1
  54. package/lib/orchestrator/serve.ts +25 -0
  55. package/lib/orchestrator/tool-names.ts +5 -0
  56. package/lib/orchestrator/tool-schemas.ts +54 -0
  57. package/lib/orchestrator/tools.ts +10 -0
  58. package/lib/plans/features.ts +200 -0
  59. package/lib/plans/pieces.ts +366 -0
  60. package/lib/plans/release.ts +323 -0
  61. package/lib/plans/view.ts +85 -0
  62. package/lib/recipes/api.ts +19 -0
  63. package/lib/recipes/chip.ts +40 -0
  64. package/lib/recipes/compose.ts +78 -0
  65. package/lib/recipes/draft.ts +93 -0
  66. package/lib/recipes/index.ts +54 -0
  67. package/lib/recipes/result.ts +59 -0
  68. package/lib/recipes/run.ts +202 -0
  69. package/lib/recipes/settle.ts +465 -0
  70. package/lib/recipes/store.ts +378 -0
  71. package/lib/recipes/test-fixtures.ts +49 -0
  72. package/lib/recipes/timer.ts +107 -0
  73. package/lib/recipes/types.ts +116 -0
  74. package/lib/schedules/index.ts +4 -1
  75. package/lib/schedules/start.ts +33 -9
  76. package/lib/schedules/store.ts +6 -2
  77. package/lib/security/demo.ts +1 -0
  78. package/lib/settings-url.ts +1 -0
  79. package/lib/tasks/loop-types.ts +23 -0
  80. package/lib/tasks/plan-view.ts +38 -1
  81. package/package.json +1 -1
  82. package/server.ts +2 -0
  83. package/stores/recipesUi.ts +53 -0
package/README.md CHANGED
@@ -99,6 +99,11 @@ conversation in **Messages** and can join in. [More](#agent-network)
99
99
  section in the agent's brief. [More](#project-config-agentosjson)
100
100
  - **Schedules.** Start tasks, chats or orchestrator prompts on a timer, or
101
101
  check in on a session, with run history. [More](#schedules)
102
+ - **Recipes.** Save a job that comes up again (a build, an audit, a report);
103
+ each run is a fresh task or session that is told what the last run
104
+ reported. [More](#recipes)
105
+ - **Plans in pieces.** A plan splits into several PRs only for a real reason,
106
+ checked by AgentOS; later pieces start by themselves. [More](#plans-in-pieces)
102
107
  - **Stacks.** Run a board's cards as stacked PRs that merge bottom-up.
103
108
  [More](#stacks)
104
109
  - **Machine load.** A gauge in the sidebar, each session's share of CPU and
@@ -896,6 +901,66 @@ for a running turn, and refuses, naming the card, if it doesn't end), and
896
901
  what it's sent during the land waits in its queue. A conflict stops on
897
902
  that card with the exact command to fix it.
898
903
 
904
+ ### Recipes
905
+
906
+ A recipe is a saved brief: a project, a kind (a **task**, which ends in a PR
907
+ and goes through the merge gates, or a **session**, which has no PR), and a
908
+ short brief that says what to run and how to judge it. The steps belong in a
909
+ script in the repo; the recipe says "run this, judge the result". Every run
910
+ is a fresh task or session, so nothing piles up in one long conversation.
911
+
912
+ Open **Settings → Recipes** (the sidebar's **⋯** menu, the workspace menu, or
913
+ ⌘K → Recipes or "Run recipe: …"). **Run** takes an optional one-off note and
914
+ shows exactly what will be sent. **Save as recipe** on a session's menu or a
915
+ finished task prefills the form from what it did, with a suggested brief to
916
+ use or ignore.
917
+
918
+ - **Carried between runs.** A run ends with one line,
919
+ `RESULT: ok|fail key=value … | one sentence`. The next run's brief gets a
920
+ short block (at most 600 characters) with the last run's outcome and
921
+ those values, such as a build number; after a failure it also carries the
922
+ last good run. With no line, a cheap model summarises the run, and with
923
+ that unavailable the state alone is recorded. **Start the next run fresh**
924
+ drops the block.
925
+ - **One run at a time.** A run while another is going is refused, and a
926
+ double tap starts one run.
927
+ - **Timers.** A recipe can run hourly, daily, on weekdays, weekly or on a
928
+ cron expression, never more than once an hour; a timer that fires while a
929
+ run is going is skipped. Only you set timers: the orchestrator can't.
930
+ - **Orchestrator.** `recipes`, `run_recipe`, `recipe_save` and `recipe_update`
931
+ tools, and the workspace's recipes in its brief and digest. A run goes
932
+ through the same brakes, Pause and task queue as a task it started
933
+ itself, and a recipe is never an approval: it can't pass a gate. The
934
+ orchestrator gets an event when a run it started finishes, and for any
935
+ failure.
936
+ - **History.** Each recipe lists its runs: when, how triggered and by whom,
937
+ outcome, the reported values, cost, duration, and links to the session and
938
+ PR, with totals.
939
+
940
+ ### Plans in pieces
941
+
942
+ A plan ends with a `## Pieces` block, one piece by default. AgentOS accepts
943
+ more only for one of four reasons, written on the piece: (a) it spans
944
+ repositories, (b) one part must be live (merged and deployed) before the
945
+ other can merge, (c) one part needs you mid-way, (d) one part must be
946
+ revertible on its own (a migration that rewrites rows, or an auth change
947
+ mixed with other work). Diff size is never a reason. A piece without a
948
+ standing reason is folded into the one before it, and a repository with no
949
+ deploy check can't claim (b); this is a deterministic check, not the model's
950
+ word.
951
+
952
+ A one-piece plan behaves as always. With several, piece 1 continues in the
953
+ planning task with only its slice, and the rest wait as rows (no worktree,
954
+ port or queue slot) until what they wait on has merged or deployed, then start
955
+ as tasks through the usual brakes and queue. A plan waiting for approval
956
+ shows its pieces: **Merge into ①** or **Drop** a piece (undoable until you
957
+ approve), **Approve with note…**, or **Reject**. Each piece's review judges
958
+ its slice; the last also checks the whole plan was built. **Tasks** shows
959
+ the feature's pieces and what each waits on, with **Release anyway** for one
960
+ held by a dropped or failed predecessor. A plan-only task that reaches
961
+ approval sends a "Plan ready: name, N pieces" push, and a finished feature
962
+ sends one "Feature merged" push naming it.
963
+
899
964
  ### Schedules
900
965
 
901
966
  A schedule starts agent work at set times without you opening AgentOS:
@@ -1017,7 +1082,8 @@ line at the bottom of Schedules), one of:
1017
1082
 
1018
1083
  A machine linked to Connect sends to the paired phones that have registered
1019
1084
  for push, instead of Telegram. A push is generic: the title "AgentOS", a
1020
- category word and a link to open, no session or task text (the payload is a
1085
+ category word and a link to open, with no session or task text (a plan's or
1086
+ feature's name is the one exception, scrubbed to a single line) (the payload is a
1021
1087
  versioned envelope, so an encrypted body can replace it later). It goes
1022
1088
  through the Connect relay, signed with the machine key; AgentOS keeps a
1023
1089
  `push_outbox` that retries with backoff, forgets tokens the relay reports as
@@ -1025,7 +1091,7 @@ dead, and caps alerting pushes at 10 an hour per device (one summary after).
1025
1091
 
1026
1092
  - **What pushes**: an ask raised, a session waiting on you for a minute,
1027
1093
  a task ready for review, CI or a review failing, a plan ready to approve,
1028
- and the Away digest. Merged is off by default. Each device picks its
1094
+ a feature merged, and the Away digest. Merged is off by default. Each device picks its
1029
1095
  categories (`PATCH /api/devices/me/push`). A task mirrored from another
1030
1096
  machine never pushes from here.
1031
1097
  - **Quiet rules**: waiting, review, CI and merged don't push while you're at
@@ -0,0 +1,16 @@
1
+ import { NextResponse } from "next/server";
2
+ import { releaseAnyway } from "@/lib/plans/release";
3
+
4
+ // A blocked piece stops waiting on what held it and starts on the next sweep.
5
+ export async function POST(
6
+ _request: Request,
7
+ { params }: { params: Promise<{ id: string }> }
8
+ ) {
9
+ const id = Number((await params).id);
10
+ if (!Number.isInteger(id) || !releaseAnyway(id))
11
+ return NextResponse.json(
12
+ { error: "That piece isn't blocked" },
13
+ { status: 409 }
14
+ );
15
+ return NextResponse.json({ success: true });
16
+ }
@@ -0,0 +1,8 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { featureViews } from "@/lib/plans/view";
3
+
4
+ export async function GET(request: NextRequest) {
5
+ return NextResponse.json({
6
+ features: featureViews(request.nextUrl.searchParams.get("workspace")),
7
+ });
8
+ }
@@ -0,0 +1,20 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { clearCarry } from "@/lib/recipes";
3
+ import { failed, resolveRecipe } from "@/lib/recipes/api";
4
+
5
+ // The next run starts without the last run's summary.
6
+ export async function POST(
7
+ request: NextRequest,
8
+ { params }: { params: Promise<{ id: string }> }
9
+ ) {
10
+ try {
11
+ const recipe = resolveRecipe(
12
+ (await params).id,
13
+ request.nextUrl.searchParams.get("workspace")
14
+ );
15
+ clearCarry(recipe.id);
16
+ return NextResponse.json({ ok: true });
17
+ } catch (error) {
18
+ return failed(error);
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { previewPrompt } from "@/lib/recipes";
3
+ import { failed, resolveRecipe } from "@/lib/recipes/api";
4
+
5
+ // What a run started now would send, for the Run sheet.
6
+ export async function GET(
7
+ request: NextRequest,
8
+ { params }: { params: Promise<{ id: string }> }
9
+ ) {
10
+ try {
11
+ const q = request.nextUrl.searchParams;
12
+ const recipe = resolveRecipe((await params).id, q.get("workspace"));
13
+ return NextResponse.json({
14
+ prompt: previewPrompt(recipe.id, q.get("note")),
15
+ });
16
+ } catch (error) {
17
+ return failed(error, 404);
18
+ }
19
+ }
@@ -0,0 +1,51 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ archiveRecipe,
4
+ listRuns,
5
+ recipeView,
6
+ setTimer,
7
+ updateRecipe,
8
+ } from "@/lib/recipes";
9
+ import { failed, resolveRecipe } from "@/lib/recipes/api";
10
+
11
+ type Params = { params: Promise<{ id: string }> };
12
+
13
+ const workspaceOf = (request: NextRequest) =>
14
+ request.nextUrl.searchParams.get("workspace");
15
+
16
+ export async function GET(request: NextRequest, { params }: Params) {
17
+ try {
18
+ const recipe = resolveRecipe((await params).id, workspaceOf(request));
19
+ return NextResponse.json({
20
+ recipe: recipeView(recipe),
21
+ runs: listRuns(recipe.id),
22
+ });
23
+ } catch (error) {
24
+ return failed(error, 404);
25
+ }
26
+ }
27
+
28
+ // The timer is Saad's: it can ride along with the settings here.
29
+ export async function PATCH(request: NextRequest, { params }: Params) {
30
+ try {
31
+ const recipe = resolveRecipe((await params).id, workspaceOf(request));
32
+ const body = await request.json();
33
+ const { timer, ...patch } = body;
34
+ const updated = updateRecipe(recipe.id, patch);
35
+ if (timer !== undefined) setTimer(updated, timer);
36
+ return NextResponse.json({ recipe: recipeView(updated) });
37
+ } catch (error) {
38
+ return failed(error);
39
+ }
40
+ }
41
+
42
+ // Archives it: off the list and its timer off, its runs kept.
43
+ export async function DELETE(request: NextRequest, { params }: Params) {
44
+ try {
45
+ const recipe = resolveRecipe((await params).id, workspaceOf(request));
46
+ archiveRecipe(recipe.id);
47
+ return NextResponse.json({ ok: true });
48
+ } catch (error) {
49
+ return failed(error);
50
+ }
51
+ }
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { AlreadyRunning, startRun } from "@/lib/recipes";
3
+ import { failed, resolveRecipe } from "@/lib/recipes/api";
4
+ import { BrakeRefused } from "@/lib/orchestrator/brakes";
5
+
6
+ type Params = { params: Promise<{ id: string }> };
7
+
8
+ // Run now. From the UI it is Saad's (manual); `aos recipe run` sends `from`,
9
+ // the session asking, and goes through the brakes like any agent's start.
10
+ export async function POST(request: NextRequest, { params }: Params) {
11
+ try {
12
+ const body = await request.json().catch(() => ({}));
13
+ const recipe = resolveRecipe(
14
+ (await params).id,
15
+ request.nextUrl.searchParams.get("workspace")
16
+ );
17
+ const from = typeof body.from === "string" ? body.from : null;
18
+ const { run, queued } = await startRun(recipe.id, {
19
+ trigger: from ? "cli" : "manual",
20
+ actorSessionId: from,
21
+ note: typeof body.note === "string" ? body.note : null,
22
+ requestId: typeof body.requestId === "string" ? body.requestId : null,
23
+ });
24
+ return NextResponse.json({ run, queued }, { status: 201 });
25
+ } catch (error) {
26
+ if (error instanceof AlreadyRunning)
27
+ return NextResponse.json(
28
+ { error: "It's already running", liveRun: error.run },
29
+ { status: 409 }
30
+ );
31
+ if (error instanceof BrakeRefused)
32
+ return NextResponse.json({ error: error.message }, { status: 429 });
33
+ return failed(error);
34
+ }
35
+ }
@@ -0,0 +1,23 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { listRuns } from "@/lib/recipes";
3
+ import { failed, resolveRecipe } from "@/lib/recipes/api";
4
+
5
+ export async function GET(
6
+ request: NextRequest,
7
+ { params }: { params: Promise<{ id: string }> }
8
+ ) {
9
+ try {
10
+ const recipe = resolveRecipe(
11
+ (await params).id,
12
+ request.nextUrl.searchParams.get("workspace")
13
+ );
14
+ const q = request.nextUrl.searchParams;
15
+ const runs = listRuns(recipe.id, {
16
+ before: q.get("before") ? Number(q.get("before")) : undefined,
17
+ limit: q.get("limit") ? Number(q.get("limit")) : undefined,
18
+ });
19
+ return NextResponse.json({ runs });
20
+ } catch (error) {
21
+ return failed(error, 404);
22
+ }
23
+ }
@@ -0,0 +1,14 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { recipeDraft } from "@/lib/recipes/draft";
3
+ import { failed } from "@/lib/recipes/api";
4
+
5
+ // A finished session as a recipe form: prefilled, with a drafted brief when
6
+ // the model could write one. Saves nothing.
7
+ export async function POST(request: NextRequest) {
8
+ try {
9
+ const { sessionId } = await request.json();
10
+ return NextResponse.json(await recipeDraft(String(sessionId ?? "")));
11
+ } catch (error) {
12
+ return failed(error);
13
+ }
14
+ }
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ createRecipe,
4
+ listRecipeViews,
5
+ recipeView,
6
+ setTimer,
7
+ } from "@/lib/recipes";
8
+ import { failed } from "@/lib/recipes/api";
9
+
10
+ export async function GET(request: NextRequest) {
11
+ const workspace = request.nextUrl.searchParams.get("workspace");
12
+ return NextResponse.json({ recipes: listRecipeViews(workspace) });
13
+ }
14
+
15
+ export async function POST(request: NextRequest) {
16
+ try {
17
+ const body = await request.json();
18
+ const recipe = createRecipe({
19
+ workspaceId: body.workspaceId,
20
+ projectId: body.projectId,
21
+ name: body.name,
22
+ kind: body.kind,
23
+ brief: body.brief,
24
+ model: body.model,
25
+ view: body.view,
26
+ autoDone: body.autoDone,
27
+ maxMinutes: body.maxMinutes,
28
+ sourceSessionId: body.sourceSessionId,
29
+ });
30
+ if (body.timer) setTimer(recipe, body.timer);
31
+ return NextResponse.json({ recipe: recipeView(recipe) }, { status: 201 });
32
+ } catch (error) {
33
+ return failed(error);
34
+ }
35
+ }
@@ -0,0 +1,34 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db, type Session } from "@/lib/db";
3
+ import { notifyStatusChanged } from "@/lib/status/hub";
4
+
5
+ // Saad's edits to a waiting plan's pieces: which to fold into the one before
6
+ // and which to leave out. Kept on the task and applied when the plan is
7
+ // approved; piece 1 can't be edited.
8
+ export async function PUT(
9
+ request: NextRequest,
10
+ { params }: { params: Promise<{ id: string }> }
11
+ ) {
12
+ const { id } = await params;
13
+ const body = await request.json().catch(() => ({}));
14
+ const ints = (v: unknown) =>
15
+ Array.isArray(v)
16
+ ? [...new Set(v.filter((n) => Number.isInteger(n) && n >= 2 && n <= 6))]
17
+ : [];
18
+ const merge = ints(body?.merge);
19
+ const drop = ints(body?.drop).filter((n) => !merge.includes(n));
20
+ const session = db
21
+ .prepare(`SELECT plan_phase FROM sessions WHERE id = ?`)
22
+ .get(id) as Pick<Session, "plan_phase"> | undefined;
23
+ if (session?.plan_phase !== "awaiting")
24
+ return NextResponse.json(
25
+ { error: "That task isn't waiting for plan approval" },
26
+ { status: 409 }
27
+ );
28
+ db.prepare(`UPDATE sessions SET plan_edits = ? WHERE id = ?`).run(
29
+ JSON.stringify({ merge, drop }),
30
+ id
31
+ );
32
+ notifyStatusChanged();
33
+ return NextResponse.json({ success: true, merge, drop });
34
+ }
@@ -4,6 +4,7 @@ import { useSnapshot } from "valtio";
4
4
  import { useTheme } from "next-themes";
5
5
  import {
6
6
  Archive,
7
+ ChefHat,
7
8
  Code,
8
9
  FolderGit2,
9
10
  FolderPlus,
@@ -22,6 +23,8 @@ import {
22
23
  import type { Session } from "@/lib/db";
23
24
  import type { PaletteCommand } from "@/lib/palette/registry";
24
25
  import { useWorkspacesQuery } from "@/data/workspaces";
26
+ import { useRecipesQuery } from "@/data/recipes";
27
+ import { recipesUiActions } from "@/stores/recipesUi";
25
28
  import { usePaletteCommands } from "@/hooks/usePaletteCommands";
26
29
  import { archivedUiActions } from "@/stores/archivedUi";
27
30
  import { busUiActions } from "@/stores/busUi";
@@ -67,6 +70,7 @@ export function useAppCommands({
67
70
  const { workspaceId } = useSnapshot(sidebarUi);
68
71
  const current = workspaces.find((w) => w.id === workspaceId) ?? null;
69
72
  const dark = resolvedTheme?.startsWith("dark") ?? false;
73
+ const { data: recipes = [] } = useRecipesQuery(workspaceId ?? null);
70
74
  const addProject = useAddProject();
71
75
  const addExisting = useAddExistingProject();
72
76
 
@@ -134,6 +138,22 @@ export function useAppCommands({
134
138
  },
135
139
  ]
136
140
  : []),
141
+ {
142
+ id: "app.recipes",
143
+ title: "Recipes",
144
+ group: "Go to",
145
+ keywords: ["saved", "jobs", "repeat", "testflight"],
146
+ icon: ChefHat,
147
+ run: () => recipesUiActions.open(workspaceId ?? null),
148
+ },
149
+ ...recipes.map((r) => ({
150
+ id: `app.recipe.${r.id}`,
151
+ title: `Run recipe: ${r.name}`,
152
+ group: "Actions",
153
+ keywords: ["recipe", "run", r.projectName ?? ""],
154
+ icon: ChefHat,
155
+ run: () => recipesUiActions.openRecipe(r.id, true),
156
+ })),
137
157
  {
138
158
  id: "app.search-code",
139
159
  title: "Search code",
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import { ArrowUpRight, Check, Layers } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { useFeaturesQuery, useReleasePiece } from "@/data/features";
6
+ import type { FeatureView } from "@/lib/plans/view";
7
+ import { duration, money } from "@/components/Recipes/format";
8
+ import { sessionOpenActions } from "@/stores/sessionOpen";
9
+ import { tasksUiActions } from "@/stores/tasksUi";
10
+ import { cn } from "@/lib/utils";
11
+
12
+ function FeatureCard({ f }: { f: FeatureView }) {
13
+ const release = useReleasePiece();
14
+ if (f.status === "done")
15
+ return (
16
+ <p className="border-border/60 flex min-h-11 items-center gap-2 rounded-xl border px-3 text-sm">
17
+ <Check className="h-4 w-4 shrink-0 text-emerald-600" />
18
+ <span className="min-w-0 flex-1 truncate">
19
+ {f.name} · {f.total} {f.total === 1 ? "PR" : "PRs"}
20
+ {f.costUsd ? ` · ${money(f.costUsd)}` : ""}
21
+ {f.durationMs ? ` · ${duration(f.durationMs)}` : ""}
22
+ </span>
23
+ </p>
24
+ );
25
+ return (
26
+ <div
27
+ className={cn(
28
+ "border-border/60 space-y-1 rounded-xl border p-3",
29
+ f.urgent && "border-amber-500/50"
30
+ )}
31
+ >
32
+ <p className="text-sm font-medium">
33
+ {f.name}{" "}
34
+ <span className="text-muted-foreground font-normal">
35
+ {f.merged}/{f.total} merged
36
+ </span>
37
+ </p>
38
+ <ul>
39
+ {f.pieces.map((p) => (
40
+ <li
41
+ key={p.id}
42
+ className={cn(
43
+ "flex min-h-11 items-center gap-2 text-xs md:min-h-8",
44
+ p.status === "dropped" && "opacity-50"
45
+ )}
46
+ >
47
+ <span className="w-4 shrink-0 tabular-nums">{p.position}</span>
48
+ <span className="min-w-0 flex-1 truncate">
49
+ {p.name} <span className="text-muted-foreground">· {p.line}</span>
50
+ </span>
51
+ {p.status === "blocked" && (
52
+ <Button
53
+ size="sm"
54
+ variant="outline"
55
+ className="h-11 md:h-8"
56
+ disabled={release.isPending}
57
+ onClick={() => release.mutate(p.id)}
58
+ >
59
+ Release anyway
60
+ </Button>
61
+ )}
62
+ {p.sessionId && p.status !== "merged" && p.status !== "dropped" && (
63
+ <button
64
+ type="button"
65
+ aria-label={`Open ${p.name}`}
66
+ className="text-primary -mr-2 flex min-h-11 items-center px-2"
67
+ onClick={() => {
68
+ sessionOpenActions.request(p.sessionId!);
69
+ tasksUiActions.setPanelOpen(false);
70
+ }}
71
+ >
72
+ <ArrowUpRight className="h-3.5 w-3.5" />
73
+ </button>
74
+ )}
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ {release.error && (
79
+ <p className="text-destructive text-xs">{release.error.message}</p>
80
+ )}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ // Plans that split into pieces: how far along, and what each waits on.
86
+ export function FeaturesSection() {
87
+ const { data: features = [] } = useFeaturesQuery();
88
+ if (!features.length) return null;
89
+ return (
90
+ <div className="space-y-2">
91
+ <p className="label-mono text-muted-foreground flex items-center gap-1.5 pt-2">
92
+ <Layers className="h-3 w-3" />
93
+ Plans in pieces
94
+ </p>
95
+ {features.map((f) => (
96
+ <FeatureCard key={f.id} f={f} />
97
+ ))}
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1 @@
1
+ export { FeaturesSection } from "./FeaturesSection";