@saadnvd1/agent-os 0.3.557 → 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.
package/README.md CHANGED
@@ -27,7 +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. [More](docs/decisions/2026-10-13-experiments.md)
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)
31
31
  - **Also:** a ⌘K palette, code search, a file picker, a git panel, dev servers, up to four panes, and dictation.
32
32
 
33
33
  ## Documentation
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { toast } from "sonner";
3
+ import type { Card } from "@/lib/learning-cards/types";
4
+ import { useExperiment } from "../experiments";
5
+
6
+ export type { Card };
7
+
8
+ export interface CardEdits {
9
+ lesson?: string;
10
+ brief?: string;
11
+ taskPrompt?: string;
12
+ }
13
+
14
+ export const learningCardsKeys = {
15
+ session: (sessionId: string) => ["learning-cards", sessionId] as const,
16
+ };
17
+
18
+ async function json<T>(res: Response): Promise<T> {
19
+ const data = await res.json();
20
+ if (!res.ok) throw new Error(data.error || "Request failed");
21
+ return data;
22
+ }
23
+
24
+ // Pending cards of a session; nothing is asked while the experiment is off.
25
+ export function useLearningCards(sessionId: string) {
26
+ const enabled = useExperiment("learning-cards");
27
+ return useQuery({
28
+ queryKey: learningCardsKeys.session(sessionId),
29
+ queryFn: async () =>
30
+ (
31
+ await json<{ cards: Card[] }>(
32
+ await fetch(
33
+ `/api/sessions/${encodeURIComponent(sessionId)}/learning-cards`
34
+ )
35
+ )
36
+ ).cards,
37
+ enabled,
38
+ staleTime: 10_000,
39
+ refetchInterval: 30_000,
40
+ });
41
+ }
42
+
43
+ function useDecide(sessionId: string) {
44
+ const queryClient = useQueryClient();
45
+ return useMutation({
46
+ mutationFn: async (
47
+ v: { id: number; action: "apply" | "dismiss" } & CardEdits
48
+ ) =>
49
+ json<{ card: Card }>(
50
+ await fetch(`/api/learning-cards/${encodeURIComponent(String(v.id))}`, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify(v),
54
+ })
55
+ ),
56
+ onSuccess: (_d, v) => {
57
+ queryClient.invalidateQueries({
58
+ queryKey: learningCardsKeys.session(sessionId),
59
+ });
60
+ // A saved recipe or a new rule shows up in their own lists.
61
+ if (v.action === "apply") {
62
+ queryClient.invalidateQueries({ queryKey: ["recipes"] });
63
+ queryClient.invalidateQueries({ queryKey: ["orchestrator"] });
64
+ }
65
+ },
66
+ onError: (error) =>
67
+ toast.error(error instanceof Error ? error.message : "Couldn't do that"),
68
+ });
69
+ }
70
+
71
+ export const useApplyCard = (sessionId: string) => {
72
+ const m = useDecide(sessionId);
73
+ return {
74
+ ...m,
75
+ apply: (id: number, edits: CardEdits = {}) =>
76
+ m.mutate({ id, action: "apply", ...edits }),
77
+ dismiss: (id: number) => m.mutate({ id, action: "dismiss" }),
78
+ };
79
+ };
package/lib/chat/store.ts CHANGED
@@ -180,6 +180,22 @@ export function itemsOfKind<K extends ChatItem["kind"]>(
180
180
  ).map((r) => JSON.parse(r.data));
181
181
  }
182
182
 
183
+ // Several kinds in the order they happened.
184
+ export function itemsOfKinds<K extends ChatItem["kind"]>(
185
+ sessionId: string,
186
+ kinds: K[]
187
+ ): Extract<ChatItem, { kind: K }>[] {
188
+ flushItems();
189
+ return (
190
+ db
191
+ .prepare(
192
+ `SELECT data FROM chat_items WHERE session_id = ?
193
+ AND kind IN (${kinds.map(() => "?").join(",")}) ORDER BY seq`
194
+ )
195
+ .all(sessionId, ...kinds) as { data: string }[]
196
+ ).map((r) => JSON.parse(r.data));
197
+ }
198
+
183
199
  // A chat's background tasks still running, or ended after `since` (ms): what
184
200
  // the composer lists, read without parsing the rest of a long conversation.
185
201
  export function recentTasks(