@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 +1 -1
- package/app/api/learning-cards/[id]/route.test.ts +62 -0
- package/app/api/learning-cards/[id]/route.ts +36 -0
- package/app/api/recipes/draft/route.ts +6 -1
- package/app/api/recipes/route.ts +3 -0
- package/app/api/sessions/[id]/learning-cards/route.test.ts +35 -0
- package/app/api/sessions/[id]/learning-cards/route.ts +10 -0
- package/components/Chat/ChatPanel.tsx +2 -0
- package/components/LearningCards/LearningCard.test.ts +156 -0
- package/components/LearningCards/LearningCard.tsx +210 -0
- package/data/learning-cards/index.ts +79 -0
- package/lib/chat/store.ts +16 -0
- package/lib/db/migrations.ts +26 -0
- package/lib/experiments/hooks.ts +12 -4
- package/lib/learning-cards/__fixtures__/db.ts +52 -0
- package/lib/learning-cards/__fixtures__/sessions.ts +63 -0
- package/lib/learning-cards/analyze.ts +228 -0
- package/lib/learning-cards/apply.ts +196 -0
- package/lib/learning-cards/index.ts +29 -0
- package/lib/learning-cards/input.ts +116 -0
- package/lib/learning-cards/pairs.ts +111 -0
- package/lib/learning-cards/signals.ts +98 -0
- package/lib/learning-cards/store.ts +103 -0
- package/lib/learning-cards/types.ts +79 -0
- package/lib/orchestrator/quick.ts +14 -1
- package/lib/recipes/draft.ts +62 -33
- package/package.json +1 -1
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
|
-
|
|
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
|
}
|
package/app/api/recipes/route.ts
CHANGED
|
@@ -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(
|