@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.
- package/app/api/updates/check/route.ts +17 -0
- package/app/api/updates/route.test.ts +205 -0
- package/app/api/updates/route.ts +11 -0
- package/app/api/updates/run/route.ts +50 -0
- package/app/api/updates/settings/route.ts +25 -0
- package/components/Chat/ChatPanel.tsx +18 -5
- package/components/Chat/Messages.test.ts +44 -0
- package/components/Chat/Messages.tsx +29 -7
- package/components/Chat/composer/ComposerBody.tsx +5 -2
- package/components/Chat/composer/hook.test.ts +24 -0
- package/components/Chat/composer/insert.ts +18 -0
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +9 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Updates/UpdateActions.tsx +93 -0
- package/components/Updates/UpdateBanner.test.ts +198 -0
- package/components/Updates/UpdateBanner.tsx +212 -0
- package/components/Updates/UpdatesSection.tsx +150 -0
- package/components/Updates/index.ts +2 -0
- package/components/views/DesktopView.tsx +2 -0
- package/components/views/MobileView.tsx +2 -0
- package/data/updates/index.ts +63 -0
- package/hooks/useDismissedUpdate.ts +56 -0
- package/hooks/useReloadWhenUpdated.ts +19 -0
- package/lib/chat/quote.ts +5 -0
- package/lib/settings-url.ts +1 -0
- package/lib/updates/banner.ts +114 -0
- package/lib/updates/check.ts +240 -0
- package/lib/updates/run.ts +452 -0
- package/lib/updates/settings.ts +38 -0
- package/lib/updates/version.ts +30 -0
- package/lib/updates/view.ts +80 -0
- package/package.json +1 -1
- package/scripts/redeploy +4 -0
- package/scripts/update-run.mjs +82 -0
- package/scripts/update-run.test.ts +64 -0
- 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(
|
|
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<{
|
|
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<{
|
|
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 {
|
|
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({
|
|
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
|
-
<
|
|
102
|
-
text={item.text}
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
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);
|