@saadnvd1/agent-os 0.3.553 → 0.3.556

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 (45) hide show
  1. package/app/api/sessions/[id]/done/route.test.ts +70 -0
  2. package/app/api/sessions/[id]/done/route.ts +32 -6
  3. package/app/api/updates/check/route.ts +17 -0
  4. package/app/api/updates/route.test.ts +205 -0
  5. package/app/api/updates/route.ts +11 -0
  6. package/app/api/updates/run/route.ts +50 -0
  7. package/app/api/updates/settings/route.ts +25 -0
  8. package/components/Chat/ChatPanel.tsx +18 -5
  9. package/components/Chat/Messages.test.ts +44 -0
  10. package/components/Chat/Messages.tsx +29 -7
  11. package/components/Chat/composer/ComposerBody.tsx +5 -2
  12. package/components/Chat/composer/hook.test.ts +24 -0
  13. package/components/Chat/composer/insert.ts +18 -0
  14. package/components/SessionList/useDoneAction.test.ts +149 -0
  15. package/components/SessionList/useDoneAction.ts +17 -12
  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/components/Updates/UpdateActions.tsx +93 -0
  21. package/components/Updates/UpdateBanner.test.ts +198 -0
  22. package/components/Updates/UpdateBanner.tsx +212 -0
  23. package/components/Updates/UpdatesSection.tsx +150 -0
  24. package/components/Updates/index.ts +2 -0
  25. package/components/views/DesktopView.tsx +2 -0
  26. package/components/views/MobileView.tsx +2 -0
  27. package/data/done/index.ts +33 -7
  28. package/data/updates/index.ts +63 -0
  29. package/hooks/useDismissedUpdate.ts +56 -0
  30. package/hooks/useReloadWhenUpdated.ts +19 -0
  31. package/lib/chat/quote.ts +5 -0
  32. package/lib/done/deadline.ts +45 -0
  33. package/lib/done/index.ts +4 -1
  34. package/lib/settings-url.ts +1 -0
  35. package/lib/updates/banner.ts +114 -0
  36. package/lib/updates/check.ts +240 -0
  37. package/lib/updates/run.ts +452 -0
  38. package/lib/updates/settings.ts +38 -0
  39. package/lib/updates/version.ts +30 -0
  40. package/lib/updates/view.ts +80 -0
  41. package/package.json +1 -1
  42. package/scripts/redeploy +4 -0
  43. package/scripts/update-run.mjs +82 -0
  44. package/scripts/update-run.test.ts +64 -0
  45. package/server.ts +16 -0
@@ -0,0 +1,70 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const doneSession = vi.hoisted(() => vi.fn());
4
+ const deadline = vi.hoisted(() => vi.fn());
5
+ vi.mock("@/lib/done", () => ({
6
+ doneSession,
7
+ getDoneTarget: (id: string) => {
8
+ if (id === "gone") throw new Error("Session not found");
9
+ return { id, name: "Fix pair page menu label" };
10
+ },
11
+ }));
12
+ vi.mock("@/lib/done/deadline", () => ({
13
+ withinDeadline: (...a: unknown[]) => {
14
+ deadline(...a);
15
+ return a[0];
16
+ },
17
+ }));
18
+
19
+ const { POST } = await import("./route");
20
+ const post = (id: string) =>
21
+ POST(new Request("http://x", { method: "POST" }), {
22
+ params: Promise.resolve({ id }),
23
+ });
24
+
25
+ const unhandled: unknown[] = [];
26
+ process.on("unhandledRejection", (e) => unhandled.push(e));
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ unhandled.length = 0;
31
+ });
32
+
33
+ describe("POST /api/sessions/:id/done", () => {
34
+ it("answers 404 for an unknown id, without starting anything or leaving a rejection", async () => {
35
+ const res = await post("gone");
36
+ expect(res.status).toBe(404);
37
+ expect(doneSession).not.toHaveBeenCalled();
38
+ await new Promise((r) => setTimeout(r, 10));
39
+ expect(unhandled).toEqual([]);
40
+ });
41
+
42
+ it("runs a quick change's done through the deadline wrapper", async () => {
43
+ doneSession.mockResolvedValue({ text: "Merged." });
44
+ const res = await post("q1");
45
+ expect(res.status).toBe(200);
46
+ expect(await res.json()).toEqual({ outcome: { text: "Merged." } });
47
+ expect(deadline).toHaveBeenCalledWith(
48
+ expect.any(Promise),
49
+ "Fix pair page menu label"
50
+ );
51
+ });
52
+
53
+ it("returns the refusal, and a second done while one runs is refused", async () => {
54
+ let finish!: (v: unknown) => void;
55
+ doneSession.mockReturnValueOnce(new Promise((r) => (finish = r)));
56
+ const first = post("q2");
57
+ await new Promise((r) => setTimeout(r, 0));
58
+ const second = await post("q2");
59
+ expect(second.status).toBe(409);
60
+ expect((await second.json()).error).toMatch(/already being finished/);
61
+ expect(doneSession).toHaveBeenCalledTimes(1);
62
+ finish({ text: "ok" });
63
+ expect((await first).status).toBe(200);
64
+
65
+ doneSession.mockRejectedValueOnce(new Error("Not done. ci failed"));
66
+ const refused = await post("q2");
67
+ expect(refused.status).toBe(409);
68
+ expect((await refused.json()).error).toMatch(/ci failed/);
69
+ });
70
+ });
@@ -1,19 +1,45 @@
1
1
  import { NextResponse } from "next/server";
2
- import { doneSession } from "@/lib/done";
2
+ import { doneSession, getDoneTarget } from "@/lib/done";
3
+ import { withinDeadline } from "@/lib/done/deadline";
4
+
5
+ // Sessions with a done running. Released when the work ends, not when the
6
+ // request's deadline does, so a retry can't start a parallel run beside one
7
+ // that is still going.
8
+ const g = globalThis as unknown as { __agentosDoneRunning?: Set<string> };
9
+ const running: Set<string> = (g.__agentosDoneRunning ??= new Set());
3
10
 
4
11
  // POST /api/sessions/:id/done - finished work: merge through the gates if
5
- // its PR is open, clean up, archive. 409 with the reason when refused.
12
+ // its PR is open, clean up, archive. 404 for an unknown session, 409 with
13
+ // the reason when refused or already running.
6
14
  export async function POST(
7
15
  _request: Request,
8
16
  { params }: { params: Promise<{ id: string }> }
9
17
  ) {
18
+ const id = (await params).id;
19
+ let name: string;
20
+ try {
21
+ name = getDoneTarget(id).name;
22
+ } catch {
23
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
24
+ }
25
+ if (running.has(id))
26
+ return NextResponse.json(
27
+ { error: `${name} is already being finished: wait for it to end.` },
28
+ { status: 409 }
29
+ );
30
+ running.add(id);
10
31
  try {
11
- const outcome = await doneSession((await params).id, {
12
- by: "direct",
13
- onPeer: true,
14
- });
32
+ const work = doneSession(id, { by: "direct", onPeer: true });
33
+ work.then(
34
+ () => running.delete(id),
35
+ () => running.delete(id)
36
+ );
37
+ const outcome = await withinDeadline(work, name);
15
38
  return NextResponse.json({ outcome });
16
39
  } catch (error) {
40
+ // A done that threw before it started has nothing left to hold.
41
+ if (!(error instanceof Error) || !/taking longer/.test(error.message))
42
+ running.delete(id);
17
43
  const message = error instanceof Error ? error.message : String(error);
18
44
  return NextResponse.json({ error: message }, { status: 409 });
19
45
  }
@@ -0,0 +1,17 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { approverOf } from "@/lib/security/approver";
3
+ import { checkForUpdate } from "@/lib/updates/check";
4
+ import { updateView } from "@/lib/updates/view";
5
+
6
+ // POST /api/updates/check - look now, at most once a minute.
7
+ export async function POST(request: NextRequest) {
8
+ const { throttled } = await checkForUpdate({ force: true });
9
+ if (throttled)
10
+ return NextResponse.json(
11
+ { error: "Checked a moment ago.", retryAfter: throttled },
12
+ { status: 429, headers: { "Retry-After": String(throttled) } }
13
+ );
14
+ return NextResponse.json(
15
+ await updateView({ mayUpdate: !!approverOf(request.headers) })
16
+ );
17
+ }
@@ -0,0 +1,205 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { NextRequest } from "next/server";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { db } from "@/lib/db";
6
+ import { DEVICE_HEADER, TRUST_HEADER } from "@/lib/security/auth";
7
+ import { mintDevice, setDeviceCanApprove } from "@/lib/security/devices";
8
+ import type { CheckResult } from "@/lib/updates/check";
9
+
10
+ let check: CheckResult | null;
11
+ let throttled: number | undefined;
12
+ let refreshed: CheckResult | null = null;
13
+ vi.mock("@/lib/updates/check", async (orig) => ({
14
+ ...(await orig<typeof import("@/lib/updates/check")>()),
15
+ cachedCheck: () => check,
16
+ checkForUpdate: async () => ({ result: refreshed ?? check, throttled }),
17
+ }));
18
+ vi.mock("@/lib/busy", () => ({ inFlight: () => ({ reviews: 0, setups: 0 }) }));
19
+ vi.mock("@/lib/status/collect", () => ({
20
+ collectStatuses: async () => ({
21
+ statuses: {
22
+ a: { status: "running" },
23
+ b: { status: "running" },
24
+ c: { status: "idle" },
25
+ },
26
+ }),
27
+ }));
28
+
29
+ const root = await import("./route");
30
+ const runRoute = await import("./run/route");
31
+ const checkRoute = await import("./check/route");
32
+ const settingsRoute = await import("./settings/route");
33
+
34
+ const req = (
35
+ path: string,
36
+ method: string,
37
+ headers: Record<string, string>,
38
+ body?: unknown
39
+ ) =>
40
+ new NextRequest(`http://localhost/api/updates${path}`, {
41
+ method,
42
+ headers: {
43
+ ...headers,
44
+ ...(body !== undefined && { "content-type": "application/json" }),
45
+ },
46
+ ...(body !== undefined && { body: JSON.stringify(body) }),
47
+ });
48
+
49
+ const loopback = { [TRUST_HEADER]: "loopback" };
50
+ const available = (): CheckResult => ({
51
+ status: "ok",
52
+ current: { version: "0.3.1", sha: "a" },
53
+ latest: { version: "0.3.5", sha: "b", notes: ["x"], url: null },
54
+ behind: 4,
55
+ available: true,
56
+ branch: "main",
57
+ checkedAt: 1,
58
+ error: null,
59
+ });
60
+
61
+ beforeEach(() => {
62
+ check = available();
63
+ throttled = undefined;
64
+ refreshed = null;
65
+ delete process.env.AGENTOS_UPDATE_BANNER;
66
+ process.env.AGENTOS_DEV = "1";
67
+ db.prepare(`DELETE FROM settings WHERE key LIKE 'updates.%'`).run();
68
+ });
69
+
70
+ describe("GET /api/updates", () => {
71
+ it("describes the update, and says who may start it", async () => {
72
+ const view = await (await root.GET(req("", "GET", loopback))).json();
73
+ expect(view).toMatchObject({
74
+ available: true,
75
+ current: { version: "0.3.1" },
76
+ latest: { version: "0.3.5" },
77
+ bannerEnabled: true,
78
+ working: { sessions: 2 },
79
+ });
80
+ // A dev server refuses, with the reason.
81
+ expect(view.canUpdate).toMatchObject({ ok: false, code: "dev" });
82
+ });
83
+
84
+ it("a device without Can approve can read it but not update", async () => {
85
+ const { device } = mintDevice("phone");
86
+ setDeviceCanApprove(device.id, false);
87
+ const headers = { [TRUST_HEADER]: "device", [DEVICE_HEADER]: device.id };
88
+ const view = await (await root.GET(req("", "GET", headers))).json();
89
+ expect(view.canUpdate).toMatchObject({ ok: false, code: "forbidden" });
90
+ const res = await runRoute.POST(
91
+ req("/run", "POST", headers, { when: "now" })
92
+ );
93
+ expect(res.status).toBe(403);
94
+ });
95
+ });
96
+
97
+ describe("after an update", () => {
98
+ it("asks again instead of offering the version that was just installed", async () => {
99
+ const file = path.join(
100
+ path.dirname(process.env.DB_PATH!),
101
+ "agent-os-update.json"
102
+ );
103
+ fs.writeFileSync(
104
+ file,
105
+ JSON.stringify({
106
+ id: "r",
107
+ phase: "done",
108
+ from: "a",
109
+ fromVersion: "0.3.1",
110
+ toVersion: "0.3.5",
111
+ startedAt: 5,
112
+ finishedAt: 10,
113
+ })
114
+ );
115
+ refreshed = { ...available(), available: false, behind: 0, checkedAt: 20 };
116
+ try {
117
+ const view = await (await root.GET(req("", "GET", loopback))).json();
118
+ expect(view.available).toBe(false);
119
+ expect(view.run.phase).toBe("done");
120
+ } finally {
121
+ fs.rmSync(file, { force: true });
122
+ }
123
+ });
124
+ });
125
+
126
+ describe("POST /api/updates/run", () => {
127
+ it("rejects a body that isn't now or idle", async () => {
128
+ for (const body of [{}, { when: "later" }, { when: 1 }])
129
+ expect(
130
+ (await runRoute.POST(req("/run", "POST", loopback, body))).status
131
+ ).toBe(400);
132
+ });
133
+
134
+ it("answers 409 with the code when the update can't run", async () => {
135
+ const res = await runRoute.POST(
136
+ req("/run", "POST", loopback, { when: "now" })
137
+ );
138
+ expect(res.status).toBe(409);
139
+ expect(await res.json()).toMatchObject({ code: "dev" });
140
+ expect(
141
+ db.prepare(`SELECT 1 FROM settings WHERE key = 'updates.pending'`).get()
142
+ ).toBeUndefined();
143
+ });
144
+
145
+ it("refuses an anonymous caller", async () => {
146
+ expect(
147
+ (await runRoute.POST(req("/run", "POST", {}, { when: "now" }))).status
148
+ ).toBe(403);
149
+ expect((await runRoute.DELETE(req("/run", "DELETE", {}))).status).toBe(403);
150
+ });
151
+
152
+ it("cancels a requested update", async () => {
153
+ db.prepare(
154
+ `INSERT INTO settings (key, value) VALUES ('updates.pending', ?)`
155
+ ).run(JSON.stringify({ requestedAt: Date.now() }));
156
+ const view = await (
157
+ await runRoute.DELETE(req("/run", "DELETE", loopback))
158
+ ).json();
159
+ expect(view.pending).toBeNull();
160
+ });
161
+ });
162
+
163
+ describe("POST /api/updates/check", () => {
164
+ it("answers 429 inside the floor", async () => {
165
+ throttled = 40;
166
+ const res = await checkRoute.POST(req("/check", "POST", loopback));
167
+ expect(res.status).toBe(429);
168
+ expect(res.headers.get("Retry-After")).toBe("40");
169
+ });
170
+ });
171
+
172
+ describe("PUT /api/updates/settings", () => {
173
+ it("turns the banner off and back on", async () => {
174
+ let view = await (
175
+ await settingsRoute.PUT(
176
+ req("/settings", "PUT", loopback, { banner: false })
177
+ )
178
+ ).json();
179
+ expect(view.bannerEnabled).toBe(false);
180
+ view = await (
181
+ await settingsRoute.PUT(
182
+ req("/settings", "PUT", loopback, { banner: true })
183
+ )
184
+ ).json();
185
+ expect(view.bannerEnabled).toBe(true);
186
+ });
187
+
188
+ it("is refused when pinned by the environment, and for a bad body", async () => {
189
+ expect(
190
+ (
191
+ await settingsRoute.PUT(
192
+ req("/settings", "PUT", loopback, { banner: "no" })
193
+ )
194
+ ).status
195
+ ).toBe(400);
196
+ process.env.AGENTOS_UPDATE_BANNER = "off";
197
+ expect(
198
+ (
199
+ await settingsRoute.PUT(
200
+ req("/settings", "PUT", loopback, { banner: true })
201
+ )
202
+ ).status
203
+ ).toBe(409);
204
+ });
205
+ });
@@ -0,0 +1,11 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { approverOf } from "@/lib/security/approver";
3
+ import { updateView } from "@/lib/updates/view";
4
+
5
+ // GET /api/updates - the installed version, the latest one, the update run
6
+ // in progress and whether the caller may start one.
7
+ export async function GET(request: NextRequest) {
8
+ return NextResponse.json(
9
+ await updateView({ mayUpdate: !!approverOf(request.headers) })
10
+ );
11
+ }
@@ -0,0 +1,50 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import {
4
+ cancelPending,
5
+ readRun,
6
+ realRunDeps,
7
+ startUpdate,
8
+ } from "@/lib/updates/run";
9
+ import { isActivePhase } from "@/lib/updates/banner";
10
+ import { updateView } from "@/lib/updates/view";
11
+
12
+ // POST /api/updates/run {when: "now" | "idle"} - start an update, or ask for
13
+ // one once nothing is working. Nothing updates unless this is called.
14
+ export async function POST(request: NextRequest) {
15
+ const allowed = requireApprover(request);
16
+ if (!allowed.ok) return allowed.response;
17
+ const body = (await request.json().catch(() => null)) as {
18
+ when?: unknown;
19
+ } | null;
20
+ if (body?.when !== "now" && body?.when !== "idle")
21
+ return NextResponse.json(
22
+ { error: 'when must be "now" or "idle".' },
23
+ { status: 400 }
24
+ );
25
+ const started = await startUpdate({ when: body.when });
26
+ if (!started.ok)
27
+ return NextResponse.json(
28
+ { code: started.code, error: started.error },
29
+ { status: 409 }
30
+ );
31
+ return NextResponse.json(await updateView({ mayUpdate: true }), {
32
+ status: 202,
33
+ });
34
+ }
35
+
36
+ // DELETE /api/updates/run - cancel a requested "update when idle". A started
37
+ // update can't be stopped.
38
+ export async function DELETE(request: NextRequest) {
39
+ const allowed = requireApprover(request);
40
+ if (!allowed.ok) return allowed.response;
41
+ const deps = realRunDeps();
42
+ const run = readRun(deps);
43
+ if (run && isActivePhase(run.phase))
44
+ return NextResponse.json(
45
+ { error: "The update has started and can't be stopped." },
46
+ { status: 409 }
47
+ );
48
+ cancelPending(deps);
49
+ return NextResponse.json(await updateView({ mayUpdate: true }));
50
+ }
@@ -0,0 +1,25 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { bannerLocked, setBannerEnabled } from "@/lib/updates/settings";
4
+ import { updateView } from "@/lib/updates/view";
5
+
6
+ // PUT /api/updates/settings {banner: boolean} - show the update banner or not.
7
+ export async function PUT(request: NextRequest) {
8
+ const allowed = requireApprover(request);
9
+ if (!allowed.ok) return allowed.response;
10
+ const body = (await request.json().catch(() => null)) as {
11
+ banner?: unknown;
12
+ } | null;
13
+ if (typeof body?.banner !== "boolean")
14
+ return NextResponse.json(
15
+ { error: "banner must be true or false." },
16
+ { status: 400 }
17
+ );
18
+ if (bannerLocked())
19
+ return NextResponse.json(
20
+ { error: "The banner is pinned off by AGENTOS_UPDATE_BANNER." },
21
+ { status: 409 }
22
+ );
23
+ setBannerEnabled(body.banner);
24
+ return NextResponse.json(await updateView({ mayUpdate: true }));
25
+ }
@@ -27,7 +27,7 @@ import {
27
27
  OVERLAY_SELECTOR,
28
28
  } from "@/lib/chat/escape";
29
29
  import { sentPrompts } from "@/lib/chat/history";
30
- import { quoteMarkdown } from "@/lib/chat/quote";
30
+ import { quoteInsert, quoteMarkdown } from "@/lib/chat/quote";
31
31
  import { OrchestratorBar } from "@/components/Orchestrator/OrchestratorBar";
32
32
  import { ActivityLine } from "./Activity";
33
33
  import { Approval } from "./Approval";
@@ -82,6 +82,7 @@ interface ItemActions {
82
82
  onKeepPlanning?: () => void;
83
83
  // Absent while the agent is working, and inside undone messages.
84
84
  onUndo?: (id: string) => void;
85
+ onQuote?: (text: string) => void;
85
86
  }
86
87
 
87
88
  function Item({ item, actions }: { item: ChatItem; actions: ItemActions }) {
@@ -98,7 +99,7 @@ function Item({ item, actions }: { item: ChatItem; actions: ItemActions }) {
98
99
  />
99
100
  );
100
101
  case "assistant":
101
- return <AssistantMessage item={item} />;
102
+ return <AssistantMessage item={item} onQuote={actions.onQuote} />;
102
103
  case "reasoning":
103
104
  return <Reasoning item={item} />;
104
105
  case "todos":
@@ -171,7 +172,10 @@ const Block = memo(
171
172
  actions: ItemActions;
172
173
  }) {
173
174
  const respond = actions.respond;
174
- const undoneActions = useMemo(() => ({ respond }), [respond]);
175
+ const undoneActions = useMemo(
176
+ () => ({ respond, onQuote: actions.onQuote }),
177
+ [respond, actions.onQuote]
178
+ );
175
179
  return b.type === "tools" ? (
176
180
  <ToolGroup tools={b.tools} />
177
181
  ) : b.type === "events" ? (
@@ -239,7 +243,11 @@ export function ChatPanel({
239
243
  // The tab on show in the focused pane: opening it takes the caret.
240
244
  active: boolean;
241
245
  }) {
242
- const [prefill, setPrefill] = useState<{ text: string; at: number }>();
246
+ const [prefill, setPrefill] = useState<{
247
+ text: string;
248
+ at: number;
249
+ append?: boolean;
250
+ }>();
243
251
  const {
244
252
  items,
245
253
  hasMore,
@@ -305,7 +313,11 @@ export function ChatPanel({
305
313
  const shownSuggestion = suggestion === dismissed ? null : suggestion;
306
314
  const dismissSuggestion = useCallback((s: string) => setDismissed(s), []);
307
315
  const history = useMemo(() => sentPrompts(items), [items]);
308
- const [insert, setInsert] = useState<{ text: string; at: number }>();
316
+ const [insert, setInsert] = useState<{
317
+ text: string;
318
+ at: number;
319
+ append?: boolean;
320
+ }>();
309
321
 
310
322
  // Esc sets the composer's ghost text aside, or else stops the turn
311
323
  // (lib/chat/escape), from anywhere in this panel.
@@ -555,6 +567,7 @@ export function ChatPanel({
555
567
  onUndo: idle ? (id) => undo(id, true) : undefined,
556
568
  onCarryPlan: idle ? carryPlan : undefined,
557
569
  onKeepPlanning: focusComposer,
570
+ onQuote: (text) => setInsert(quoteInsert(text)),
558
571
  }),
559
572
  [respond, idle, undo, carryPlan, focusComposer]
560
573
  );
@@ -0,0 +1,44 @@
1
+ import { createElement } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { AssistantMessage } from "./Messages";
5
+
6
+ const item = {
7
+ id: "a1",
8
+ kind: "assistant",
9
+ text: "Hello\n\n```ts\nx\n```",
10
+ streaming: false,
11
+ } as never;
12
+
13
+ describe("AssistantMessage quote action", () => {
14
+ it("shows a labelled Quote button next to Copy", () => {
15
+ const html = renderToStaticMarkup(
16
+ createElement(AssistantMessage, { item, onQuote: vi.fn() })
17
+ );
18
+ expect(html).toContain('aria-label="Copy message"');
19
+ expect(html).toContain('aria-label="Quote message"');
20
+ expect(html).toContain('title="Quote"');
21
+ });
22
+
23
+ it("hands the markdown source to onQuote on click", () => {
24
+ const onQuote = vi.fn();
25
+ const el = AssistantMessage({ item, onQuote }) as never as {
26
+ props: { children: unknown[] };
27
+ };
28
+ const group = (
29
+ el.props.children as { props: { children: unknown[] } }[]
30
+ )[1];
31
+ const button = group.props.children[1] as {
32
+ props: { onClick: () => void };
33
+ };
34
+ button.props.onClick();
35
+ expect(onQuote).toHaveBeenCalledWith("Hello\n\n```ts\nx\n```");
36
+ });
37
+
38
+ it("has no Quote button without a handler", () => {
39
+ const html = renderToStaticMarkup(
40
+ createElement(AssistantMessage, { item })
41
+ );
42
+ expect(html).not.toContain("Quote message");
43
+ });
44
+ });
@@ -1,6 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { Check, ChevronRight, Circle, CircleDot, Sparkles } from "lucide-react";
3
+ import {
4
+ Check,
5
+ ChevronRight,
6
+ Circle,
7
+ CircleDot,
8
+ Quote,
9
+ Sparkles,
10
+ } from "lucide-react";
4
11
  import type { ChatItem } from "@/lib/chat/events";
5
12
  import { cn } from "@/lib/utils";
6
13
  import { leadingCommand } from "@/lib/chat/commands";
@@ -91,18 +98,33 @@ export function UserMessage({
91
98
  );
92
99
  }
93
100
 
94
- export function AssistantMessage({ item }: { item: Of<"assistant"> }) {
101
+ export function AssistantMessage({
102
+ item,
103
+ onQuote,
104
+ }: {
105
+ item: Of<"assistant">;
106
+ onQuote?: (text: string) => void;
107
+ }) {
95
108
  return (
96
109
  <div className="group">
97
110
  <div data-quotable className={cn(item.streaming && "streaming-cursor")}>
98
111
  <Markdown text={item.text} streaming={item.streaming} />
99
112
  </div>
100
113
  {!item.streaming && (
101
- <CopyButton
102
- text={item.text}
103
- label="Copy message"
104
- className="-ml-1.5 opacity-40 group-hover:opacity-100 md:opacity-0"
105
- />
114
+ <div className="-ml-1.5 flex items-center opacity-40 group-hover:opacity-100 md:opacity-0">
115
+ <CopyButton text={item.text} label="Copy message" />
116
+ {onQuote && (
117
+ <button
118
+ type="button"
119
+ aria-label="Quote message"
120
+ title="Quote"
121
+ onClick={() => onQuote(item.text)}
122
+ className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] flex h-9 w-9 items-center justify-center rounded-md md:h-7 md:w-7"
123
+ >
124
+ <Quote className="h-3.5 w-3.5" />
125
+ </button>
126
+ )}
127
+ </div>
106
128
  )}
107
129
  </div>
108
130
  );
@@ -22,6 +22,7 @@ import { ComposerMenu, type MenuOption } from "../ComposerMenu";
22
22
  import { offeredSuggestion } from "@/lib/chat/suggestion";
23
23
  import { historyStep } from "@/lib/chat/history";
24
24
  import { markdownParagraphs } from "@/lib/chat/quote";
25
+ import { insertMarkdown } from "./insert";
25
26
  import { useCoarsePointer } from "./useCoarsePointer";
26
27
  import { useMentions } from "./useMentions";
27
28
  import { Attachments } from "./Attachments";
@@ -68,7 +69,9 @@ export interface ComposerProps {
68
69
  // Files and folders for an @mention.
69
70
  requestFiles?: (query: string) => Promise<FileSuggestion[]>;
70
71
  // Markdown to put in at the caret (a quote), once per `at`.
71
- insert?: { text: string; at: number };
72
+ // `append` puts it after what's already written, on its own lines, rather
73
+ // than at the caret.
74
+ insert?: { text: string; at: number; append?: boolean };
72
75
  // Takes the caret once it's ready, and again each time this changes to a
73
76
  // new key (the conversation shown), unless the user is busy elsewhere.
74
77
  focusKey?: string | null;
@@ -155,7 +158,7 @@ export function ComposerBody({
155
158
  inserted.current = insert.at;
156
159
  const content = markdownParagraphs(insert.text);
157
160
  if (!content.length) return;
158
- editor.chain().focus().insertContent(content).run();
161
+ insertMarkdown(editor, content, insert.append);
159
162
  }, [insert, editor]);
160
163
 
161
164
  const query = slashQuery(text);
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import type { Editor } from "@tiptap/core";
7
7
  import * as extensions from "./extensions";
8
8
  import * as serialize from "@/lib/chat/markdown/serialize";
9
+ import { insertMarkdown } from "./insert";
10
+ import { quoteInsert, markdownParagraphs } from "@/lib/chat/quote";
9
11
  import { useComposerEditor, type ComposerHandlers } from "./useComposerEditor";
10
12
 
11
13
  vi.mock("./extensions", async (original) => {
@@ -95,4 +97,26 @@ describe("useComposerEditor", () => {
95
97
  });
96
98
  expect(last.text).toBe("hi");
97
99
  });
100
+
101
+ it("quotes under an existing draft, caret after the quote", async () => {
102
+ await render();
103
+ await act(async () => last.setText("my draft"));
104
+ const q = quoteInsert("one\ntwo");
105
+ await act(async () =>
106
+ insertMarkdown(editor(), markdownParagraphs(q.text), q.append)
107
+ );
108
+ expect(last.text).toBe("my draft\n> one\n> two\n\n");
109
+ expect(editor().state.selection.from).toBe(
110
+ editor().state.doc.content.size - 1
111
+ );
112
+ });
113
+
114
+ it("quotes into an empty composer", async () => {
115
+ await render();
116
+ const q = quoteInsert("hi");
117
+ await act(async () =>
118
+ insertMarkdown(editor(), markdownParagraphs(q.text), q.append)
119
+ );
120
+ expect(last.text.startsWith("> hi")).toBe(true);
121
+ });
98
122
  });