@saadnvd1/agent-os 0.3.556 → 0.3.558

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 (48) hide show
  1. package/README.md +1 -0
  2. package/app/api/experiments/[id]/route.ts +39 -0
  3. package/app/api/experiments/route.test.ts +51 -0
  4. package/app/api/experiments/route.ts +7 -0
  5. package/app/api/learning-cards/[id]/route.test.ts +62 -0
  6. package/app/api/learning-cards/[id]/route.ts +36 -0
  7. package/app/api/recipes/draft/route.ts +6 -1
  8. package/app/api/recipes/route.ts +3 -0
  9. package/app/api/sessions/[id]/learning-cards/route.test.ts +35 -0
  10. package/app/api/sessions/[id]/learning-cards/route.ts +10 -0
  11. package/components/Chat/ChatPanel.tsx +2 -0
  12. package/components/LearningCards/LearningCard.test.ts +156 -0
  13. package/components/LearningCards/LearningCard.tsx +210 -0
  14. package/components/Settings/ExperimentsSection.test.ts +89 -0
  15. package/components/Settings/ExperimentsSection.tsx +225 -0
  16. package/components/Settings/SettingsDialog.test.ts +3 -0
  17. package/components/Settings/SettingsDialog.tsx +9 -0
  18. package/components/Settings/commands.ts +9 -0
  19. package/components/Settings/entry-points.test.ts +1 -0
  20. package/data/experiments/index.ts +67 -0
  21. package/data/learning-cards/index.ts +79 -0
  22. package/lib/chat/store.ts +16 -0
  23. package/lib/db/migrations.ts +59 -0
  24. package/lib/done/index.ts +2 -0
  25. package/lib/experiments/hooks.ts +39 -0
  26. package/lib/experiments/index.ts +19 -0
  27. package/lib/experiments/meter.ts +327 -0
  28. package/lib/experiments/registry.ts +37 -0
  29. package/lib/experiments/view.ts +19 -0
  30. package/lib/learning-cards/__fixtures__/db.ts +52 -0
  31. package/lib/learning-cards/__fixtures__/sessions.ts +63 -0
  32. package/lib/learning-cards/analyze.ts +228 -0
  33. package/lib/learning-cards/apply.ts +196 -0
  34. package/lib/learning-cards/index.ts +29 -0
  35. package/lib/learning-cards/input.ts +116 -0
  36. package/lib/learning-cards/pairs.ts +111 -0
  37. package/lib/learning-cards/signals.ts +98 -0
  38. package/lib/learning-cards/store.ts +103 -0
  39. package/lib/learning-cards/types.ts +79 -0
  40. package/lib/orchestrator/quick.ts +14 -1
  41. package/lib/recipes/draft.ts +62 -33
  42. package/lib/recipes/settle.ts +4 -1
  43. package/lib/settings-store.ts +22 -0
  44. package/lib/settings-url.ts +1 -0
  45. package/lib/tasks/finish.ts +2 -0
  46. package/lib/tasks/session.ts +2 -0
  47. package/lib/updates/settings.ts +2 -20
  48. package/package.json +1 -1
package/README.md CHANGED
@@ -27,6 +27,7 @@ and check on all of it from your phone.
27
27
  - **Workspaces.** Group projects, finish work with Done, and get a log of every merged task. [More](docs/reference/workspaces.md)
28
28
  - **LumifyHub (optional).** Boards, cards and docs for a workspace, if you want them. [More](docs/lumifyhub.md)
29
29
  - **No approvals mode.** One switch in Settings that approves every ask, lets brakes and plan-only tasks through, and starts every agent with its permission prompts off; each approval it makes is logged. Off by default. [More](docs/reference/orchestrator.md#no-approvals-mode)
30
+ - **Experiments.** Settings → Experiments lists features still being tried, each off until you turn it on, with what it has cost and how often its suggestions were taken. The first is Learning cards: when a session ends it can offer to save the work as a recipe or keep a lesson where it belongs. [More](docs/decisions/2026-10-13-experiments.md)
30
31
  - **Also:** a ⌘K palette, code search, a file picker, a git panel, dev servers, up to four panes, and dictation.
31
32
 
32
33
  ## Documentation
@@ -0,0 +1,39 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { experimentsLocked, setExperiment } from "@/lib/experiments";
3
+ import { isExperimentId } from "@/lib/experiments/registry";
4
+ import { usageDetail } from "@/lib/experiments/meter";
5
+ import { experimentView } from "@/lib/experiments/view";
6
+
7
+ type Ctx = { params: Promise<{ id: string }> };
8
+
9
+ // GET /api/experiments/:id - the experiment with its per-day usage
10
+ export async function GET(_req: NextRequest, { params }: Ctx) {
11
+ const { id } = await params;
12
+ if (!isExperimentId(id))
13
+ return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
14
+ return NextResponse.json({
15
+ ...experimentView(id),
16
+ locked: experimentsLocked(),
17
+ detail: usageDetail(id),
18
+ });
19
+ }
20
+
21
+ // PUT /api/experiments/:id {on} - anyone using the app may switch one
22
+ export async function PUT(request: NextRequest, { params }: Ctx) {
23
+ const { id } = await params;
24
+ if (!isExperimentId(id))
25
+ return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
26
+ const body = (await request.json().catch(() => ({}))) as { on?: unknown };
27
+ if (typeof body.on !== "boolean")
28
+ return NextResponse.json(
29
+ { error: "on must be true or false" },
30
+ { status: 400 }
31
+ );
32
+ if (experimentsLocked())
33
+ return NextResponse.json(
34
+ { error: "Experiments are switched off for this server" },
35
+ { status: 409 }
36
+ );
37
+ setExperiment(id, body.on);
38
+ return NextResponse.json(experimentView(id));
39
+ }
@@ -0,0 +1,51 @@
1
+ import { NextRequest } from "next/server";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { db } from "@/lib/db";
4
+ import { experimentOn } from "@/lib/experiments";
5
+ import { GET } from "./route";
6
+ import * as one from "./[id]/route";
7
+
8
+ const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
9
+ const put = (id: string, body: unknown) =>
10
+ one.PUT(
11
+ new NextRequest(`http://localhost/api/experiments/${id}`, {
12
+ method: "PUT",
13
+ body: JSON.stringify(body),
14
+ }),
15
+ ctx(id)
16
+ );
17
+
18
+ beforeEach(() => {
19
+ db.prepare(`DELETE FROM settings WHERE key LIKE 'experiments.%'`).run();
20
+ });
21
+
22
+ describe("/api/experiments", () => {
23
+ it("lists every experiment, off, with zero usage", async () => {
24
+ const body = await (await GET()).json();
25
+ const e = body.experiments.find(
26
+ (x: { id: string }) => x.id === "learning-cards"
27
+ );
28
+ expect(e).toMatchObject({ on: false, status: "experimental" });
29
+ expect(e.usage.today.calls).toBe(0);
30
+ });
31
+
32
+ it("switches one on and off", async () => {
33
+ expect((await put("learning-cards", { on: true })).status).toBe(200);
34
+ expect(experimentOn("learning-cards")).toBe(true);
35
+ await put("learning-cards", { on: false });
36
+ expect(experimentOn("learning-cards")).toBe(false);
37
+ });
38
+
39
+ it("refuses an unknown id and a bad body", async () => {
40
+ expect((await put("nope", { on: true })).status).toBe(404);
41
+ expect((await put("learning-cards", { on: "yes" })).status).toBe(400);
42
+ });
43
+
44
+ it("gives the detail view", async () => {
45
+ const res = await one.GET(
46
+ new NextRequest("http://localhost/api/experiments/learning-cards"),
47
+ ctx("learning-cards")
48
+ );
49
+ expect((await res.json()).detail.days).toHaveLength(30);
50
+ });
51
+ });
@@ -0,0 +1,7 @@
1
+ import { NextResponse } from "next/server";
2
+ import { experimentsView } from "@/lib/experiments/view";
3
+
4
+ // GET /api/experiments - every experiment, whether it's on, and what it used
5
+ export function GET() {
6
+ return NextResponse.json(experimentsView());
7
+ }
@@ -0,0 +1,62 @@
1
+ import { NextRequest } from "next/server";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { setExperiment } from "@/lib/experiments";
4
+ import { listRules } from "@/lib/orchestrator/rules";
5
+ import { clearCards, seedSession } from "@/lib/learning-cards/__fixtures__/db";
6
+ import { insertCard } from "@/lib/learning-cards/store";
7
+ import { POST } from "./route";
8
+
9
+ const post = (id: string, body: unknown) =>
10
+ POST(
11
+ new NextRequest(`http://localhost/api/learning-cards/${id}`, {
12
+ method: "POST",
13
+ body: JSON.stringify(body),
14
+ }),
15
+ { params: Promise.resolve({ id }) }
16
+ );
17
+
18
+ function card() {
19
+ const s = seedSession();
20
+ const c = insertCard({
21
+ sessionId: s.id,
22
+ workspaceId: s.ws.id,
23
+ kind: "lesson",
24
+ destination: "rule",
25
+ payload: { lesson: "Ask before deleting", destination: "rule", pairs: 1 },
26
+ })!;
27
+ return { s, c };
28
+ }
29
+
30
+ beforeEach(() => {
31
+ clearCards();
32
+ setExperiment("learning-cards", true);
33
+ });
34
+
35
+ describe("POST /api/learning-cards/[id]", () => {
36
+ it("applies a card", async () => {
37
+ const { s, c } = card();
38
+ const res = await post(String(c.id), {
39
+ action: "apply",
40
+ lesson: "Ask first",
41
+ });
42
+ expect(res.status).toBe(200);
43
+ expect(listRules(s.ws.id)[0].text).toBe("Ask first");
44
+ });
45
+
46
+ it("dismisses a card", async () => {
47
+ const { s, c } = card();
48
+ expect((await post(String(c.id), { action: "dismiss" })).status).toBe(200);
49
+ expect(listRules(s.ws.id)).toEqual([]);
50
+ });
51
+
52
+ it("is 409 while off or when already decided, 400 for a bad id or action", async () => {
53
+ const { c } = card();
54
+ setExperiment("learning-cards", false);
55
+ expect((await post(String(c.id), { action: "apply" })).status).toBe(409);
56
+ setExperiment("learning-cards", true);
57
+ expect((await post("abc", { action: "apply" })).status).toBe(400);
58
+ expect((await post(String(c.id), { action: "nope" })).status).toBe(400);
59
+ await post(String(c.id), { action: "dismiss" });
60
+ expect((await post(String(c.id), { action: "apply" })).status).toBe(409);
61
+ });
62
+ });
@@ -0,0 +1,36 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ applyCard,
4
+ CardRefused,
5
+ dismissCard,
6
+ } from "@/lib/learning-cards/apply";
7
+
8
+ export async function POST(
9
+ request: NextRequest,
10
+ { params }: { params: Promise<{ id: string }> }
11
+ ) {
12
+ const { id } = await params;
13
+ const cardId = Number(id);
14
+ if (!Number.isInteger(cardId) || cardId < 1)
15
+ return NextResponse.json({ error: "Bad card id" }, { status: 400 });
16
+ const body = await request.json().catch(() => ({}));
17
+ const text = (v: unknown) => (typeof v === "string" ? v : undefined);
18
+ try {
19
+ if (body.action === "dismiss")
20
+ return NextResponse.json({ card: dismissCard(cardId) });
21
+ if (body.action !== "apply")
22
+ return NextResponse.json({ error: "Unknown action" }, { status: 400 });
23
+ const card = await applyCard(cardId, {
24
+ lesson: text(body.lesson),
25
+ brief: text(body.brief),
26
+ taskPrompt: text(body.taskPrompt),
27
+ });
28
+ return NextResponse.json({ card });
29
+ } catch (error) {
30
+ const message = error instanceof Error ? error.message : "Failed";
31
+ return NextResponse.json(
32
+ { error: message },
33
+ { status: error instanceof CardRefused ? 409 : 400 }
34
+ );
35
+ }
36
+ }
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { recipeDraft } from "@/lib/recipes/draft";
3
+ import { pendingRecipeDraft } from "@/lib/learning-cards";
3
4
  import { failed } from "@/lib/recipes/api";
4
5
 
5
6
  // A finished session as a recipe form: prefilled, with a drafted brief when
@@ -7,7 +8,11 @@ import { failed } from "@/lib/recipes/api";
7
8
  export async function POST(request: NextRequest) {
8
9
  try {
9
10
  const { sessionId } = await request.json();
10
- return NextResponse.json(await recipeDraft(String(sessionId ?? "")));
11
+ const id = String(sessionId ?? "");
12
+ // A learning card may have drafted it already.
13
+ return NextResponse.json(
14
+ await recipeDraft(id, undefined, pendingRecipeDraft(id))
15
+ );
11
16
  } catch (error) {
12
17
  return failed(error);
13
18
  }
@@ -5,6 +5,7 @@ import {
5
5
  recipeView,
6
6
  setTimer,
7
7
  } from "@/lib/recipes";
8
+ import { acceptRecipeCardFor } from "@/lib/learning-cards/apply";
8
9
  import { failed } from "@/lib/recipes/api";
9
10
 
10
11
  export async function GET(request: NextRequest) {
@@ -27,6 +28,8 @@ export async function POST(request: NextRequest) {
27
28
  maxMinutes: body.maxMinutes,
28
29
  sourceSessionId: body.sourceSessionId,
29
30
  });
31
+ if (typeof body.sourceSessionId === "string")
32
+ acceptRecipeCardFor(body.sourceSessionId, recipe.id);
30
33
  if (body.timer) setTimer(recipe, body.timer);
31
34
  return NextResponse.json({ recipe: recipeView(recipe) }, { status: 201 });
32
35
  } catch (error) {
@@ -0,0 +1,35 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { setExperiment } from "@/lib/experiments";
3
+ import { clearCards, seedSession } from "@/lib/learning-cards/__fixtures__/db";
4
+ import { insertCard } from "@/lib/learning-cards/store";
5
+ import { GET } from "./route";
6
+
7
+ const get = async (id: string) =>
8
+ (
9
+ await (
10
+ await GET(new Request("http://localhost/x"), {
11
+ params: Promise.resolve({ id }),
12
+ })
13
+ ).json()
14
+ ).cards;
15
+
16
+ beforeEach(() => {
17
+ clearCards();
18
+ setExperiment("learning-cards", true);
19
+ });
20
+
21
+ describe("GET /api/sessions/[id]/learning-cards", () => {
22
+ it("lists pending cards, and none while off", async () => {
23
+ const s = seedSession();
24
+ insertCard({
25
+ sessionId: s.id,
26
+ workspaceId: s.ws.id,
27
+ kind: "lesson",
28
+ destination: "rule",
29
+ payload: { lesson: "x", destination: "rule", pairs: 1 },
30
+ });
31
+ expect(await get(s.id)).toHaveLength(1);
32
+ setExperiment("learning-cards", false);
33
+ expect(await get(s.id)).toEqual([]);
34
+ });
35
+ });
@@ -0,0 +1,10 @@
1
+ import { NextResponse } from "next/server";
2
+ import { cardsFor } from "@/lib/learning-cards";
3
+
4
+ export async function GET(
5
+ _request: Request,
6
+ { params }: { params: Promise<{ id: string }> }
7
+ ) {
8
+ const { id } = await params;
9
+ return NextResponse.json({ cards: cardsFor(id) });
10
+ }
@@ -37,6 +37,7 @@ import { UndoDialog, UndoneBlock } from "./Undo";
37
37
  import { Composer } from "./Composer";
38
38
  import { Queue } from "./Queue";
39
39
  import { StaleChip } from "./StaleChip";
40
+ import { LearningCardsHost } from "@/components/LearningCards/LearningCard";
40
41
  import { composerCompacts } from "@/lib/chat/stale";
41
42
  import { useFreshStartQuery } from "@/data/fresh-start";
42
43
  import { QuoteButton } from "./QuoteButton";
@@ -734,6 +735,7 @@ export function ChatPanel({
734
735
  asIs={asIs}
735
736
  onAsIs={setAsIs}
736
737
  />
738
+ <LearningCardsHost sessionId={sessionId} />
737
739
  <Composer
738
740
  draftKey={sessionId}
739
741
  suggestion={shownSuggestion}
@@ -0,0 +1,156 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { Card } from "@/lib/learning-cards/types";
6
+
7
+ for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
8
+ Object.defineProperty(globalThis, key, {
9
+ value: (window as unknown as Record<string, unknown>)[key],
10
+ configurable: true,
11
+ writable: true,
12
+ });
13
+ (
14
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
15
+ ).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ const state = vi.hoisted(() => ({
18
+ cards: undefined as unknown,
19
+ apply: vi.fn(),
20
+ dismiss: vi.fn(),
21
+ saveSessionAs: vi.fn(),
22
+ }));
23
+ vi.mock("@/data/learning-cards", () => ({
24
+ useLearningCards: () => ({ data: state.cards }),
25
+ useApplyCard: () => ({
26
+ apply: state.apply,
27
+ dismiss: state.dismiss,
28
+ isPending: false,
29
+ }),
30
+ }));
31
+ vi.mock("@/stores/recipesUi", () => ({
32
+ recipesUiActions: { saveSessionAs: state.saveSessionAs },
33
+ }));
34
+
35
+ const { LearningCards, LearningCardsHost, destinationLabel } =
36
+ await import("./LearningCard");
37
+
38
+ const base = {
39
+ session_id: "s1",
40
+ workspace_id: "w1",
41
+ status: "pending",
42
+ applied_ref: null,
43
+ shown_at: null,
44
+ decided_at: null,
45
+ created_at: 1,
46
+ } as const;
47
+ const cards = [
48
+ {
49
+ ...base,
50
+ id: 1,
51
+ kind: "recipe",
52
+ destination: null,
53
+ payload: { signals: [], brief: "Send the build", summary: "" },
54
+ },
55
+ {
56
+ ...base,
57
+ id: 2,
58
+ kind: "lesson",
59
+ destination: "claude_md",
60
+ payload: {
61
+ lesson: "Run the linter first",
62
+ destination: "claude_md",
63
+ projectName: "Web",
64
+ pairs: 1,
65
+ },
66
+ },
67
+ ] as unknown as Card[];
68
+
69
+ let root: Root;
70
+ let host: HTMLDivElement;
71
+ beforeEach(() => {
72
+ for (const f of [state.apply, state.dismiss, state.saveSessionAs])
73
+ f.mockReset();
74
+ state.cards = cards;
75
+ host = document.createElement("div");
76
+ document.body.appendChild(host);
77
+ root = createRoot(host);
78
+ });
79
+ afterEach(() => {
80
+ act(() => root.unmount());
81
+ host.remove();
82
+ });
83
+
84
+ const click = (label: string, nth = 0) =>
85
+ act(() => {
86
+ [...host.querySelectorAll("button")]
87
+ .filter((b) => b.textContent === label)
88
+ [nth].dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
89
+ });
90
+
91
+ describe("LearningCardsHost", () => {
92
+ it("shows both rows, with the destination in plain words", () => {
93
+ act(() =>
94
+ root.render(createElement(LearningCardsHost, { sessionId: "s1" }))
95
+ );
96
+ expect(host.textContent).toContain("Save as recipe?");
97
+ expect(host.textContent).toContain("Keep this for next time?");
98
+ expect(host.textContent).toContain("CLAUDE.md in Web, through a PR");
99
+ expect(host.querySelector("input")?.value).toBe("Run the linter first");
100
+ });
101
+
102
+ it("saves the recipe, applies the lesson with its edit, and dismisses", () => {
103
+ act(() =>
104
+ root.render(createElement(LearningCardsHost, { sessionId: "s1" }))
105
+ );
106
+ click("Save");
107
+ expect(state.apply).toHaveBeenCalledWith(1, { brief: "Send the build" });
108
+ click("Apply");
109
+ expect(state.apply).toHaveBeenCalledWith(2, {
110
+ lesson: "Run the linter first",
111
+ taskPrompt: "",
112
+ });
113
+ click("Dismiss", 1);
114
+ expect(state.dismiss).toHaveBeenCalledWith(2);
115
+ });
116
+
117
+ it("Edit opens the recipe form for the session", () => {
118
+ act(() =>
119
+ root.render(createElement(LearningCardsHost, { sessionId: "s1" }))
120
+ );
121
+ click("Edit");
122
+ expect(state.saveSessionAs).toHaveBeenCalledWith("s1");
123
+ });
124
+
125
+ it("renders nothing when off (no data) or when there are no cards", () => {
126
+ state.cards = undefined;
127
+ act(() =>
128
+ root.render(createElement(LearningCardsHost, { sessionId: "s1" }))
129
+ );
130
+ expect(host.innerHTML).toBe("");
131
+ act(() =>
132
+ root.render(
133
+ createElement(LearningCards, {
134
+ cards: [],
135
+ onApply: vi.fn(),
136
+ onDismiss: vi.fn(),
137
+ onEditRecipe: vi.fn(),
138
+ })
139
+ )
140
+ );
141
+ expect(host.innerHTML).toBe("");
142
+ });
143
+ });
144
+
145
+ describe("destinationLabel", () => {
146
+ it("names each place", () => {
147
+ const p = { lesson: "x", pairs: 1 } as const;
148
+ expect(
149
+ destinationLabel({ ...p, destination: "recipe", recipeName: "Build" })
150
+ ).toBe("Recipe brief: Build");
151
+ expect(destinationLabel({ ...p, destination: "rule" })).toBe(
152
+ "Workspace rule"
153
+ );
154
+ expect(destinationLabel({ ...p, destination: "check" })).toContain("task");
155
+ });
156
+ });
@@ -0,0 +1,210 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { BookmarkPlus, ChefHat } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { recipesUiActions } from "@/stores/recipesUi";
7
+ import { useApplyCard, useLearningCards } from "@/data/learning-cards";
8
+ import type {
9
+ Card,
10
+ LessonPayload,
11
+ RecipePayload,
12
+ } from "@/lib/learning-cards/types";
13
+
14
+ export function destinationLabel(p: LessonPayload): string {
15
+ switch (p.destination) {
16
+ case "recipe":
17
+ return `Recipe brief: ${p.recipeName ?? "this recipe"}`;
18
+ case "claude_md":
19
+ return `CLAUDE.md in ${p.projectName ?? "the project"}, through a PR`;
20
+ case "check":
21
+ return "Start a task to build a check";
22
+ case "rule":
23
+ return "Workspace rule";
24
+ }
25
+ }
26
+
27
+ function RecipeRow({
28
+ card,
29
+ busy,
30
+ onSave,
31
+ onEdit,
32
+ onDismiss,
33
+ }: {
34
+ card: Card;
35
+ busy: boolean;
36
+ onSave: (brief: string) => void;
37
+ onEdit: () => void;
38
+ onDismiss: () => void;
39
+ }) {
40
+ const p = card.payload as RecipePayload;
41
+ const [open, setOpen] = useState(false);
42
+ return (
43
+ <div className="flex flex-col gap-1.5">
44
+ <div className="flex items-center gap-2 text-sm font-medium">
45
+ <ChefHat className="text-muted-foreground h-3.5 w-3.5" />
46
+ Save as recipe?
47
+ </div>
48
+ <button
49
+ type="button"
50
+ onClick={() => setOpen(!open)}
51
+ className={`text-muted-foreground text-left text-xs whitespace-pre-wrap ${open ? "" : "line-clamp-3"}`}
52
+ >
53
+ {p.brief}
54
+ </button>
55
+ <div className="flex flex-wrap gap-1">
56
+ <Button
57
+ size="sm"
58
+ className="h-11 md:h-8"
59
+ disabled={busy}
60
+ onClick={() => onSave(p.brief)}
61
+ >
62
+ Save
63
+ </Button>
64
+ <Button
65
+ size="sm"
66
+ variant="outline"
67
+ className="h-11 md:h-8"
68
+ disabled={busy}
69
+ onClick={onEdit}
70
+ >
71
+ Edit
72
+ </Button>
73
+ <Button
74
+ size="sm"
75
+ variant="ghost"
76
+ className="text-muted-foreground h-11 md:h-8"
77
+ disabled={busy}
78
+ onClick={onDismiss}
79
+ >
80
+ Dismiss
81
+ </Button>
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ function LessonRow({
88
+ card,
89
+ busy,
90
+ onApply,
91
+ onDismiss,
92
+ }: {
93
+ card: Card;
94
+ busy: boolean;
95
+ onApply: (edits: { lesson: string; taskPrompt?: string }) => void;
96
+ onDismiss: () => void;
97
+ }) {
98
+ const p = card.payload as LessonPayload;
99
+ const [lesson, setLesson] = useState(p.lesson);
100
+ const [task, setTask] = useState(p.taskPrompt ?? "");
101
+ return (
102
+ <div className="flex flex-col gap-1.5">
103
+ <div className="flex items-center gap-2 text-sm font-medium">
104
+ <BookmarkPlus className="text-muted-foreground h-3.5 w-3.5" />
105
+ Keep this for next time?
106
+ </div>
107
+ {p.destination === "check" ? (
108
+ <textarea
109
+ aria-label="Task to start"
110
+ value={task}
111
+ onChange={(e) => setTask(e.target.value)}
112
+ rows={3}
113
+ className="bg-background border-border w-full rounded-lg border p-2 text-xs"
114
+ />
115
+ ) : (
116
+ <input
117
+ aria-label="Lesson"
118
+ value={lesson}
119
+ maxLength={200}
120
+ onChange={(e) => setLesson(e.target.value)}
121
+ className="bg-background border-border h-11 w-full rounded-lg border px-2 text-xs md:h-8"
122
+ />
123
+ )}
124
+ <p className="text-muted-foreground text-xs">{destinationLabel(p)}</p>
125
+ <div className="flex flex-wrap gap-1">
126
+ <Button
127
+ size="sm"
128
+ className="h-11 md:h-8"
129
+ disabled={
130
+ busy || !(p.destination === "check" ? task.trim() : lesson.trim())
131
+ }
132
+ onClick={() => onApply({ lesson, taskPrompt: task })}
133
+ >
134
+ Apply
135
+ </Button>
136
+ <Button
137
+ size="sm"
138
+ variant="ghost"
139
+ className="text-muted-foreground h-11 md:h-8"
140
+ disabled={busy}
141
+ onClick={onDismiss}
142
+ >
143
+ Dismiss
144
+ </Button>
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ export function LearningCards({
151
+ cards,
152
+ busy = false,
153
+ onApply,
154
+ onDismiss,
155
+ onEditRecipe,
156
+ }: {
157
+ cards: Card[];
158
+ busy?: boolean;
159
+ onApply: (
160
+ id: number,
161
+ edits: { lesson?: string; brief?: string; taskPrompt?: string }
162
+ ) => void;
163
+ onDismiss: (id: number) => void;
164
+ onEditRecipe: (sessionId: string) => void;
165
+ }) {
166
+ if (!cards.length) return null;
167
+ return (
168
+ <div
169
+ role="region"
170
+ aria-label="Learning cards"
171
+ className="bg-card popover-surface mb-2 flex flex-col gap-3 rounded-2xl p-3"
172
+ >
173
+ {cards.map((c) =>
174
+ c.kind === "recipe" ? (
175
+ <RecipeRow
176
+ key={c.id}
177
+ card={c}
178
+ busy={busy}
179
+ onSave={(brief) => onApply(c.id, { brief })}
180
+ onEdit={() => onEditRecipe(c.session_id)}
181
+ onDismiss={() => onDismiss(c.id)}
182
+ />
183
+ ) : (
184
+ <LessonRow
185
+ key={c.id}
186
+ card={c}
187
+ busy={busy}
188
+ onApply={(edits) => onApply(c.id, edits)}
189
+ onDismiss={() => onDismiss(c.id)}
190
+ />
191
+ )
192
+ )}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ // On a finished session, above the composer. Nothing while the experiment is off.
198
+ export function LearningCardsHost({ sessionId }: { sessionId: string }) {
199
+ const { data } = useLearningCards(sessionId);
200
+ const { apply, dismiss, isPending } = useApplyCard(sessionId);
201
+ return (
202
+ <LearningCards
203
+ cards={data ?? []}
204
+ busy={isPending}
205
+ onApply={apply}
206
+ onDismiss={dismiss}
207
+ onEditRecipe={(id) => recipesUiActions.saveSessionAs(id)}
208
+ />
209
+ );
210
+ }