@saadnvd1/agent-os 0.3.553 → 0.3.555

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 (38) hide show
  1. package/app/api/updates/check/route.ts +17 -0
  2. package/app/api/updates/route.test.ts +205 -0
  3. package/app/api/updates/route.ts +11 -0
  4. package/app/api/updates/run/route.ts +50 -0
  5. package/app/api/updates/settings/route.ts +25 -0
  6. package/components/Chat/ChatPanel.tsx +18 -5
  7. package/components/Chat/Messages.test.ts +44 -0
  8. package/components/Chat/Messages.tsx +29 -7
  9. package/components/Chat/composer/ComposerBody.tsx +5 -2
  10. package/components/Chat/composer/hook.test.ts +24 -0
  11. package/components/Chat/composer/insert.ts +18 -0
  12. package/components/Settings/SettingsDialog.test.ts +3 -0
  13. package/components/Settings/SettingsDialog.tsx +9 -0
  14. package/components/Settings/commands.ts +9 -0
  15. package/components/Settings/entry-points.test.ts +1 -0
  16. package/components/Updates/UpdateActions.tsx +93 -0
  17. package/components/Updates/UpdateBanner.test.ts +198 -0
  18. package/components/Updates/UpdateBanner.tsx +212 -0
  19. package/components/Updates/UpdatesSection.tsx +150 -0
  20. package/components/Updates/index.ts +2 -0
  21. package/components/views/DesktopView.tsx +2 -0
  22. package/components/views/MobileView.tsx +2 -0
  23. package/data/updates/index.ts +63 -0
  24. package/hooks/useDismissedUpdate.ts +56 -0
  25. package/hooks/useReloadWhenUpdated.ts +19 -0
  26. package/lib/chat/quote.ts +5 -0
  27. package/lib/settings-url.ts +1 -0
  28. package/lib/updates/banner.ts +114 -0
  29. package/lib/updates/check.ts +240 -0
  30. package/lib/updates/run.ts +452 -0
  31. package/lib/updates/settings.ts +38 -0
  32. package/lib/updates/version.ts +30 -0
  33. package/lib/updates/view.ts +80 -0
  34. package/package.json +1 -1
  35. package/scripts/redeploy +4 -0
  36. package/scripts/update-run.mjs +82 -0
  37. package/scripts/update-run.test.ts +64 -0
  38. package/server.ts +16 -0
@@ -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
  });
@@ -0,0 +1,18 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { markdownParagraphs } from "@/lib/chat/quote";
3
+
4
+ // Puts paragraphs in at the caret, or with `append` after everything already
5
+ // written (on new lines), leaving the caret after them.
6
+ export function insertMarkdown(
7
+ editor: Editor,
8
+ content: ReturnType<typeof markdownParagraphs>,
9
+ append = false
10
+ ) {
11
+ if (append && !editor.isEmpty) {
12
+ editor
13
+ .chain()
14
+ .insertContentAt(editor.state.doc.content.size, content)
15
+ .focus("end")
16
+ .run();
17
+ } else editor.chain().focus().insertContent(content).run();
18
+ }
@@ -44,6 +44,9 @@ vi.mock("@/components/Schedules", () => ({
44
44
  vi.mock("@/components/Audits/AuditsPanel", () => ({
45
45
  AuditsPanel: stub("audits"),
46
46
  }));
47
+ vi.mock("@/components/Updates/UpdatesSection", () => ({
48
+ UpdatesSection: stub("updates"),
49
+ }));
47
50
  vi.mock("./NotificationsSection", () => ({
48
51
  NotificationsSection: stub("notifications"),
49
52
  }));
@@ -14,6 +14,7 @@ import {
14
14
  LayoutGrid,
15
15
  ListChecks,
16
16
  ShieldOff,
17
+ RefreshCw,
17
18
  Smartphone,
18
19
  X,
19
20
  type LucideIcon,
@@ -31,6 +32,7 @@ import { DevicesPanel } from "@/components/Devices";
31
32
  import { GlobalMergeSection } from "@/components/Merge/GlobalMergeSection";
32
33
  import { ModelsSection } from "@/components/Models/ModelsSection";
33
34
  import { SchedulesPanel } from "@/components/Schedules";
35
+ import { UpdatesSection } from "@/components/Updates/UpdatesSection";
34
36
  import { WorkspaceSettings } from "@/components/Workspaces";
35
37
  import { useSettingsUrl } from "@/hooks/useSettingsUrl";
36
38
  import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
@@ -99,6 +101,11 @@ export const SECTION_INFO: Record<
99
101
  hint: "What this machine has, agents, first-run setup",
100
102
  icon: ListChecks,
101
103
  },
104
+ updates: {
105
+ label: "Updates",
106
+ hint: "Version, check for updates, the update banner",
107
+ icon: RefreshCw,
108
+ },
102
109
  };
103
110
 
104
111
  function SectionBody({
@@ -129,6 +136,8 @@ function SectionBody({
129
136
  return <AuditsPanel />;
130
137
  case "setup":
131
138
  return <SetupSection />;
139
+ case "updates":
140
+ return <UpdatesSection />;
132
141
  }
133
142
  }
134
143
 
@@ -5,6 +5,7 @@ import {
5
5
  Cpu,
6
6
  GitMerge,
7
7
  LayoutGrid,
8
+ RefreshCw,
8
9
  Settings,
9
10
  Smartphone,
10
11
  } from "lucide-react";
@@ -102,5 +103,13 @@ export function settingsCommands(workspaceId: string | null): PaletteCommand[] {
102
103
  icon: ClipboardList,
103
104
  run: () => settingsUiActions.open("audits"),
104
105
  },
106
+ {
107
+ id: "app.updates",
108
+ title: "Check for updates",
109
+ group,
110
+ keywords: ["update", "upgrade", "version", "new version", "banner"],
111
+ icon: RefreshCw,
112
+ run: () => settingsUiActions.open("updates"),
113
+ },
105
114
  ];
106
115
  }
@@ -27,6 +27,7 @@ describe("⌘K entry points", () => {
27
27
  ["app.phone-notifications", "notifications"],
28
28
  ["app.schedules", "schedules"],
29
29
  ["app.audits", "audits"],
30
+ ["app.updates", "updates"],
30
31
  ] as const)("%s opens Settings at %s", (id, section) => {
31
32
  run(id);
32
33
  expect(settingsUi.open).toBe(true);