@saadnvd1/agent-os 0.3.794 → 0.3.807
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/busy/route.ts +2 -2
- package/app/api/chat-views/[sessionId]/route.ts +19 -0
- package/app/api/chat-views/route.test.ts +79 -0
- package/app/api/chat-views/route.ts +8 -0
- package/app/api/tips/route.test.ts +56 -0
- package/app/api/tips/route.ts +45 -0
- package/app/api/updates/health/route.ts +22 -0
- package/app/api/updates/route.test.ts +53 -0
- package/app/api/updates/settings/route.ts +37 -10
- package/app/page.tsx +17 -14
- package/components/Chat/ChatPanel.tsx +74 -59
- package/components/Chat/NewDivider.tsx +15 -0
- package/components/Chat/needs-more-history.test.ts +28 -0
- package/components/Chat/needs-more-history.ts +14 -0
- package/components/Chat/session-link.ts +7 -4
- package/components/Chat/useFind.ts +50 -1
- package/components/Chat/usePosition.ts +363 -0
- package/components/CodeSearchDialog.tsx +90 -0
- package/components/CommandPalette/CommandsBody.tsx +12 -4
- package/components/CommandPalette/useAppCommands.ts +29 -11
- package/components/DevServers/ServerLogsModal.tsx +24 -21
- package/components/Nav/HistoryButtons.tsx +128 -0
- package/components/Pane/MobileTabBar.tsx +3 -0
- package/components/Pane/index.tsx +2 -2
- package/components/Recipes/TimerFields.tsx +2 -1
- package/components/Schedules/ScheduleForm.tsx +1 -1
- package/components/SessionList/useSessionSwitching.ts +3 -2
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/components/Shortcuts/PendingKeys.tsx +19 -0
- package/components/Shortcuts/ShortcutsButton.tsx +22 -0
- package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
- package/components/SidebarFooter.tsx +4 -0
- package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
- package/components/Tips/TipButton.tsx +39 -0
- package/components/Tips/TipDialog.tsx +148 -0
- package/components/Tips/TipHost.tsx +14 -0
- package/components/Tips/TipMedia.tsx +71 -0
- package/components/Tips/index.ts +2 -0
- package/components/Updates/AutoUpdates.tsx +141 -0
- package/components/Updates/UpdateBanner.test.ts +39 -1
- package/components/Updates/UpdateBanner.tsx +31 -5
- package/components/Updates/UpdatesSection.tsx +24 -3
- package/components/views/DesktopView.tsx +7 -9
- package/components/views/MobileView.tsx +2 -8
- package/components/views/lazy.ts +2 -2
- package/content/tips.json +541 -0
- package/content/whats-new.json +56 -0
- package/data/chat-views/index.ts +2 -0
- package/data/chat-views/keys.ts +3 -0
- package/data/chat-views/queries.ts +34 -0
- package/data/sessions/pin.ts +0 -1
- package/data/tips/index.ts +49 -0
- package/data/updates/index.ts +5 -0
- package/hooks/useChatViewsSync.ts +12 -0
- package/hooks/useNavActions.ts +104 -0
- package/hooks/useNavKeys.ts +105 -0
- package/hooks/useReloadWhenUpdated.ts +5 -1
- package/hooks/useSessionUrl.ts +23 -5
- package/lib/bus/index.ts +4 -2
- package/lib/bus/role-alias.ts +4 -2
- package/lib/bus/targets.ts +2 -1
- package/lib/busy.ts +23 -1
- package/lib/chat/escape.ts +8 -4
- package/lib/chat-views/body.ts +41 -0
- package/lib/chat-views/store.ts +67 -0
- package/lib/chat-views/types.ts +20 -0
- package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
- package/lib/db/migrations.ts +21 -0
- package/lib/keybindings/focus.ts +17 -0
- package/lib/keybindings/keymap.ts +120 -0
- package/lib/keybindings/sequence.ts +64 -0
- package/lib/live/service.ts +1 -1
- package/lib/nav/history-mirror.ts +59 -0
- package/lib/nav/history-writer.ts +109 -0
- package/lib/nav/position.ts +67 -0
- package/lib/nav/recent.ts +34 -0
- package/lib/orchestrator/armed.ts +5 -0
- package/lib/palette/sections.ts +12 -6
- package/lib/recipes/timer.ts +3 -1
- package/lib/schedules/cron.ts +14 -5
- package/lib/schedules/store.ts +3 -2
- package/lib/security/demo.ts +2 -0
- package/lib/session-url.ts +11 -5
- package/lib/tasks/finish.ts +5 -5
- package/lib/tips/conditions.ts +38 -0
- package/lib/tips/index.ts +2 -0
- package/lib/tips/load.ts +26 -0
- package/lib/tips/media.ts +101 -0
- package/lib/tips/pick.ts +35 -0
- package/lib/tips/schema.ts +82 -0
- package/lib/tips/state.ts +26 -0
- package/lib/tips/view.ts +60 -0
- package/lib/updates/auto.ts +149 -0
- package/lib/updates/banner.ts +78 -3
- package/lib/updates/check.ts +64 -8
- package/lib/updates/history.ts +99 -0
- package/lib/updates/run.ts +334 -23
- package/lib/updates/settings.ts +66 -0
- package/lib/updates/view.ts +14 -3
- package/package.json +2 -2
- package/public/tips/.gitkeep +0 -0
- package/public/tips/chat-access-picker-dark.webp +0 -0
- package/public/tips/chat-access-picker.webp +0 -0
- package/public/tips/chat-attach-files-poster.webp +0 -0
- package/public/tips/chat-attach-files.mp4 +0 -0
- package/public/tips/chat-bookmark-dark.webp +0 -0
- package/public/tips/chat-bookmark.webp +0 -0
- package/public/tips/chat-plan-mode-dark.webp +0 -0
- package/public/tips/chat-plan-mode.webp +0 -0
- package/public/tips/chat-search-poster.webp +0 -0
- package/public/tips/chat-search.mp4 +0 -0
- package/public/tips/chat-slash-menu-poster.webp +0 -0
- package/public/tips/chat-slash-menu.mp4 +0 -0
- package/public/tips/chat-tab-suggestion-poster.webp +0 -0
- package/public/tips/chat-tab-suggestion.mp4 +0 -0
- package/public/tips/chat-terminal-handoff-poster.webp +0 -0
- package/public/tips/chat-terminal-handoff.mp4 +0 -0
- package/public/tips/chat-undo-dark.webp +0 -0
- package/public/tips/chat-undo.webp +0 -0
- package/public/tips/export-ai-dark.webp +0 -0
- package/public/tips/export-ai.webp +0 -0
- package/public/tips/net-messages-dark.webp +0 -0
- package/public/tips/net-messages.webp +0 -0
- package/public/tips/recipe-save-dark.webp +0 -0
- package/public/tips/recipe-save.webp +0 -0
- package/public/tips/schedule-first-dark.webp +0 -0
- package/public/tips/schedule-first.webp +0 -0
- package/public/tips/share-check-dark.webp +0 -0
- package/public/tips/share-check.webp +0 -0
- package/public/tips/sidebar-draft-poster.webp +0 -0
- package/public/tips/sidebar-draft.mp4 +0 -0
- package/public/tips/sidebar-groups-dark.webp +0 -0
- package/public/tips/sidebar-groups.webp +0 -0
- package/public/tips/sidebar-load-dark.webp +0 -0
- package/public/tips/sidebar-load.webp +0 -0
- package/public/tips/sidebar-snooze-dark.webp +0 -0
- package/public/tips/sidebar-snooze.webp +0 -0
- package/public/tips/tasks-plan-pieces-dark.webp +0 -0
- package/public/tips/tasks-plan-pieces.webp +0 -0
- package/public/tips/tasks-sign-off-dark.webp +0 -0
- package/public/tips/tasks-sign-off.webp +0 -0
- package/public/tips/tasks-stacks-dark.webp +0 -0
- package/public/tips/tasks-stacks.webp +0 -0
- package/scripts/autodeploy +4 -0
- package/scripts/autodeploy.test.ts +12 -0
- package/scripts/check +2 -2
- package/scripts/docs-links.test.ts +1 -25
- package/scripts/lib/commands.sh +32 -1
- package/scripts/lib/deps-swap.sh +8 -6
- package/scripts/lib/heading-slugs.ts +26 -0
- package/scripts/lib/update-verify.mjs +90 -0
- package/scripts/redeploy +31 -2
- package/scripts/redeploy.test.ts +94 -3
- package/scripts/tips-lint.test.ts +77 -0
- package/scripts/tips-media.ts +31 -0
- package/scripts/update-e2e.test.ts +215 -0
- package/scripts/update-recover.mjs +130 -0
- package/scripts/update-recover.test.ts +144 -0
- package/scripts/update-run.mjs +182 -40
- package/scripts/update-run.test.ts +208 -2
- package/server.ts +3 -2
- package/stores/chatJump.ts +2 -6
- package/stores/chatPositions.ts +65 -0
- package/stores/navHistory.ts +98 -0
- package/stores/shortcutsUi.ts +20 -0
- package/stores/tips.ts +14 -0
- package/components/QuickSwitcher.tsx +0 -286
package/app/api/busy/route.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import { inFlight } from "@/lib/busy";
|
|
2
|
+
import { inFlight, inFlightTotal } from "@/lib/busy";
|
|
3
3
|
import { requireLocalTrust } from "@/lib/security/route-guard";
|
|
4
4
|
|
|
5
5
|
// GET /api/busy - work a restart would cut off; scripts/redeploy waits
|
|
@@ -8,6 +8,6 @@ export async function GET(request: NextRequest) {
|
|
|
8
8
|
const refused = requireLocalTrust(request);
|
|
9
9
|
if (refused) return refused;
|
|
10
10
|
const work = inFlight();
|
|
11
|
-
const busy = work
|
|
11
|
+
const busy = inFlightTotal(work) > 0;
|
|
12
12
|
return NextResponse.json({ ...work, busy });
|
|
13
13
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { readViewBody } from "@/lib/chat-views/body";
|
|
3
|
+
import { saveChatView } from "@/lib/chat-views/store";
|
|
4
|
+
|
|
5
|
+
type Ctx = { params: Promise<{ sessionId: string }> };
|
|
6
|
+
|
|
7
|
+
// Ids are uuids today; anything id-shaped fits other machines' sessions too.
|
|
8
|
+
const ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
9
|
+
|
|
10
|
+
// PUT /api/chat-views/:sessionId { itemId, offsetPx, atEnd } - the reader is
|
|
11
|
+
// here in this chat; or { viewed: true } - they just opened it.
|
|
12
|
+
export async function PUT(request: Request, { params }: Ctx) {
|
|
13
|
+
const { sessionId } = await params;
|
|
14
|
+
if (!ID_SHAPE.test(sessionId))
|
|
15
|
+
return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
|
16
|
+
const body = await readViewBody(request);
|
|
17
|
+
if (body instanceof NextResponse) return body;
|
|
18
|
+
return NextResponse.json({ view: saveChatView(sessionId, body.input) });
|
|
19
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { getDb } from "@/lib/db";
|
|
4
|
+
import * as one from "./[sessionId]/route";
|
|
5
|
+
import * as list from "./route";
|
|
6
|
+
|
|
7
|
+
const put = (sessionId: string, body: unknown, raw = false) =>
|
|
8
|
+
one.PUT(
|
|
9
|
+
new Request("http://x/api/chat-views/" + sessionId, {
|
|
10
|
+
method: "PUT",
|
|
11
|
+
body: raw ? (body as string) : JSON.stringify(body),
|
|
12
|
+
}),
|
|
13
|
+
{ params: Promise.resolve({ sessionId }) }
|
|
14
|
+
);
|
|
15
|
+
const views = async () =>
|
|
16
|
+
(await (await list.GET()).json()).views as {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
itemId: string | null;
|
|
19
|
+
offsetPx: number;
|
|
20
|
+
atEnd: boolean;
|
|
21
|
+
viewedAt: number;
|
|
22
|
+
}[];
|
|
23
|
+
|
|
24
|
+
describe("/api/chat-views", () => {
|
|
25
|
+
it("saves a position, keeps it when a chat is only opened, and lists newest first", async () => {
|
|
26
|
+
const a = randomUUID();
|
|
27
|
+
const b = randomUUID();
|
|
28
|
+
const res = await put(a, { itemId: "i1", offsetPx: 40, atEnd: false });
|
|
29
|
+
expect(res.status).toBe(200);
|
|
30
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
31
|
+
await put(b, { viewed: true });
|
|
32
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
33
|
+
await put(a, { viewed: true });
|
|
34
|
+
const mine = (await views()).filter(
|
|
35
|
+
(v) => v.sessionId === a || v.sessionId === b
|
|
36
|
+
);
|
|
37
|
+
expect(mine.map((v) => v.sessionId)).toEqual([a, b]);
|
|
38
|
+
expect(mine[0]).toMatchObject({ itemId: "i1", offsetPx: 40, atEnd: false });
|
|
39
|
+
expect(mine[1]).toMatchObject({ itemId: null, atEnd: true });
|
|
40
|
+
expect(mine[0].viewedAt).toBeGreaterThan(mine[1].viewedAt);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps viewed_at when the reader left with things unseen below", async () => {
|
|
44
|
+
const id = randomUUID();
|
|
45
|
+
await put(id, { viewed: true });
|
|
46
|
+
const first = (await views()).find((v) => v.sessionId === id)!.viewedAt;
|
|
47
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
48
|
+
await put(id, { itemId: "i", offsetPx: 3, atEnd: false, advance: false });
|
|
49
|
+
const kept = (await views()).find((v) => v.sessionId === id)!;
|
|
50
|
+
expect(kept).toMatchObject({ itemId: "i", offsetPx: 3, atEnd: false });
|
|
51
|
+
expect(kept.viewedAt).toBe(first);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("refuses a bad body and a bad id", async () => {
|
|
55
|
+
const id = randomUUID();
|
|
56
|
+
expect((await put(id, { offsetPx: "x" })).status).toBe(400);
|
|
57
|
+
expect((await put(id, "not json", true)).status).toBe(400);
|
|
58
|
+
expect(
|
|
59
|
+
(await put(id, { itemId: "", offsetPx: 0, atEnd: true })).status
|
|
60
|
+
).toBe(400);
|
|
61
|
+
expect((await put("bad id!", { viewed: true })).status).toBe(400);
|
|
62
|
+
expect((await put(id, { viewed: true, x: "y".repeat(5000) })).status).toBe(
|
|
63
|
+
413
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is dropped with its chat", async () => {
|
|
68
|
+
const id = randomUUID();
|
|
69
|
+
getDb()
|
|
70
|
+
.prepare(
|
|
71
|
+
`INSERT INTO sessions (id, name, tmux_name, working_directory) VALUES (?, 'v', ?, '/tmp')`
|
|
72
|
+
)
|
|
73
|
+
.run(id, `claude-${id}`);
|
|
74
|
+
await put(id, { viewed: true });
|
|
75
|
+
expect((await views()).some((v) => v.sessionId === id)).toBe(true);
|
|
76
|
+
getDb().prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
|
|
77
|
+
expect((await views()).some((v) => v.sessionId === id)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { listChatViews } from "@/lib/chat-views/store";
|
|
3
|
+
|
|
4
|
+
// GET /api/chat-views - where each chat was last being read and when, newest
|
|
5
|
+
// first: the switcher's recent order and the place a chat reopens at.
|
|
6
|
+
export async function GET() {
|
|
7
|
+
return NextResponse.json({ views: listChatViews() });
|
|
8
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { putSetting } from "@/lib/settings-store";
|
|
4
|
+
import { GET, POST } from "./route";
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
for (const k of ["tips.off", "tips.seen", "tips.today"]) putSetting(k, null);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const get = (q = "?day=2026-11-01", headers: Record<string, string> = {}) =>
|
|
11
|
+
GET(new NextRequest(`http://localhost/api/tips${q}`, { headers }));
|
|
12
|
+
const post = (body: unknown) =>
|
|
13
|
+
POST(
|
|
14
|
+
new NextRequest("http://localhost/api/tips", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
body: JSON.stringify(body),
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe("/api/tips", () => {
|
|
21
|
+
it("returns today's tip and the deck, and 304s on the same ETag", async () => {
|
|
22
|
+
const res = await get();
|
|
23
|
+
const body = await res.json();
|
|
24
|
+
expect(body.today.id).toEqual(expect.any(String));
|
|
25
|
+
expect(body.deck.length).toBeGreaterThan(10);
|
|
26
|
+
const again = await get("?day=2026-11-01", {
|
|
27
|
+
"if-none-match": res.headers.get("etag")!,
|
|
28
|
+
});
|
|
29
|
+
expect(again.status).toBe(304);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects a bad day and a bad body", async () => {
|
|
33
|
+
expect((await get("?day=tomorrow")).status).toBe(400);
|
|
34
|
+
expect((await post({ action: "dance" })).status).toBe(400);
|
|
35
|
+
expect((await post({ action: "seen", ids: ["a"], day: "x" })).status).toBe(
|
|
36
|
+
400
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("remembers a tip as read", async () => {
|
|
41
|
+
const { today } = await (await get()).json();
|
|
42
|
+
const after = await (
|
|
43
|
+
await post({ action: "seen", ids: [today.id], day: "2026-11-01" })
|
|
44
|
+
).json();
|
|
45
|
+
expect(after.today).toEqual({ id: today.id, read: true });
|
|
46
|
+
expect((await (await get()).json()).today.read).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("turns tips off and on", async () => {
|
|
50
|
+
const off = await (await post({ action: "off", off: true })).json();
|
|
51
|
+
expect(off).toMatchObject({ off: true, today: null, deck: [] });
|
|
52
|
+
expect((await (await get()).json()).off).toBe(true);
|
|
53
|
+
const on = await (await post({ action: "off", off: false })).json();
|
|
54
|
+
expect(on.off).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { markSeen, setTipsOff, tipsView } from "@/lib/tips";
|
|
5
|
+
|
|
6
|
+
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
7
|
+
|
|
8
|
+
const post = z.discriminatedUnion("action", [
|
|
9
|
+
z.object({
|
|
10
|
+
action: z.literal("seen"),
|
|
11
|
+
ids: z.array(z.string().max(80)).max(100),
|
|
12
|
+
day,
|
|
13
|
+
}),
|
|
14
|
+
z.object({ action: z.literal("off"), off: z.boolean() }),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// GET /api/tips?day=YYYY-MM-DD - today's tip and the deck that applies now.
|
|
18
|
+
// `day` is the caller's local date, so the tip changes at their midnight.
|
|
19
|
+
export async function GET(request: NextRequest) {
|
|
20
|
+
const parsed = day
|
|
21
|
+
.optional()
|
|
22
|
+
.safeParse(request.nextUrl.searchParams.get("day") ?? undefined);
|
|
23
|
+
if (!parsed.success)
|
|
24
|
+
return NextResponse.json({ error: "Bad day" }, { status: 400 });
|
|
25
|
+
const text = JSON.stringify(tipsView(parsed.data));
|
|
26
|
+
const etag = `"${createHash("sha1").update(text).digest("hex").slice(0, 16)}"`;
|
|
27
|
+
const headers = { ETag: etag, "Cache-Control": "private, no-cache" };
|
|
28
|
+
if (request.headers.get("if-none-match") === etag)
|
|
29
|
+
return new NextResponse(null, { status: 304, headers });
|
|
30
|
+
return new NextResponse(text, {
|
|
31
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// POST /api/tips - { action: "seen", ids, day } or { action: "off", off }.
|
|
36
|
+
export async function POST(request: NextRequest) {
|
|
37
|
+
const body = post.safeParse(await request.json().catch(() => null));
|
|
38
|
+
if (!body.success)
|
|
39
|
+
return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
|
40
|
+
if (body.data.action === "seen") markSeen(body.data.ids, body.data.day);
|
|
41
|
+
else setTipsOff(body.data.off);
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
tipsView(body.data.action === "seen" ? body.data.day : undefined)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { buildId } from "@/lib/build";
|
|
3
|
+
import { db } from "@/lib/db";
|
|
4
|
+
import { migrationStatus } from "@/lib/db/migrations";
|
|
5
|
+
|
|
6
|
+
const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
7
|
+
|
|
8
|
+
// GET /api/updates/health - what scripts/update-run.mjs checks after an
|
|
9
|
+
// update's restart: the build this server runs, whether every migration it
|
|
10
|
+
// knows applied, and when it started (a change means it crashed and came back).
|
|
11
|
+
export function GET() {
|
|
12
|
+
let migrations: { known: number; applied: number } | null = null;
|
|
13
|
+
try {
|
|
14
|
+
migrations = migrationStatus(db);
|
|
15
|
+
} catch {}
|
|
16
|
+
return NextResponse.json({
|
|
17
|
+
ok: !!migrations,
|
|
18
|
+
build: buildId(),
|
|
19
|
+
startedAt,
|
|
20
|
+
migrations,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -32,6 +32,7 @@ const root = await import("./route");
|
|
|
32
32
|
const runRoute = await import("./run/route");
|
|
33
33
|
const checkRoute = await import("./check/route");
|
|
34
34
|
const settingsRoute = await import("./settings/route");
|
|
35
|
+
const healthRoute = await import("./health/route");
|
|
35
36
|
|
|
36
37
|
const req = (
|
|
37
38
|
path: string,
|
|
@@ -65,6 +66,7 @@ beforeEach(() => {
|
|
|
65
66
|
throttled = undefined;
|
|
66
67
|
refreshed = null;
|
|
67
68
|
delete process.env.AGENTOS_UPDATE_BANNER;
|
|
69
|
+
delete process.env.AGENTOS_AUTO_UPDATE;
|
|
68
70
|
process.env.AGENTOS_DEV = "1";
|
|
69
71
|
db.prepare(`DELETE FROM settings WHERE key LIKE 'updates.%'`).run();
|
|
70
72
|
});
|
|
@@ -205,3 +207,54 @@ describe("PUT /api/updates/settings", () => {
|
|
|
205
207
|
).toBe(409);
|
|
206
208
|
});
|
|
207
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("automatic updates settings", () => {
|
|
212
|
+
it("are on by default, and turn off and back on, with an idle time", async () => {
|
|
213
|
+
const first = await (await root.GET(req("", "GET", loopback))).json();
|
|
214
|
+
expect(first.auto).toMatchObject({
|
|
215
|
+
enabled: true,
|
|
216
|
+
locked: false,
|
|
217
|
+
idleMinutes: 10,
|
|
218
|
+
});
|
|
219
|
+
let view = await (
|
|
220
|
+
await settingsRoute.PUT(
|
|
221
|
+
req("/settings", "PUT", loopback, { auto: false, idleMinutes: 30 })
|
|
222
|
+
)
|
|
223
|
+
).json();
|
|
224
|
+
expect(view.auto).toMatchObject({ enabled: false, idleMinutes: 30 });
|
|
225
|
+
view = await (
|
|
226
|
+
await settingsRoute.PUT(req("/settings", "PUT", loopback, { auto: true }))
|
|
227
|
+
).json();
|
|
228
|
+
expect(view.auto.enabled).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("refuses an idle time it doesn't offer, and a pinned switch", async () => {
|
|
232
|
+
expect(
|
|
233
|
+
(
|
|
234
|
+
await settingsRoute.PUT(
|
|
235
|
+
req("/settings", "PUT", loopback, { idleMinutes: 7 })
|
|
236
|
+
)
|
|
237
|
+
).status
|
|
238
|
+
).toBe(400);
|
|
239
|
+
process.env.AGENTOS_AUTO_UPDATE = "off";
|
|
240
|
+
expect(
|
|
241
|
+
(
|
|
242
|
+
await settingsRoute.PUT(
|
|
243
|
+
req("/settings", "PUT", loopback, { auto: true })
|
|
244
|
+
)
|
|
245
|
+
).status
|
|
246
|
+
).toBe(409);
|
|
247
|
+
const view = await (await root.GET(req("", "GET", loopback))).json();
|
|
248
|
+
expect(view.auto).toMatchObject({ enabled: false, locked: true });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("GET /api/updates/health", () => {
|
|
253
|
+
it("says which build runs and that every migration applied", async () => {
|
|
254
|
+
const body = await healthRoute.GET().json();
|
|
255
|
+
expect(body.ok).toBe(true);
|
|
256
|
+
expect(body.build).toBeTruthy();
|
|
257
|
+
expect(body.migrations.known).toBeGreaterThan(0);
|
|
258
|
+
expect(body.migrations.applied).toBe(body.migrations.known);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -1,25 +1,52 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
IDLE_MINUTES,
|
|
5
|
+
autoLocked,
|
|
6
|
+
bannerLocked,
|
|
7
|
+
idleMinutesLocked,
|
|
8
|
+
setAutoEnabled,
|
|
9
|
+
setBannerEnabled,
|
|
10
|
+
setIdleMinutes,
|
|
11
|
+
} from "@/lib/updates/settings";
|
|
4
12
|
import { updateView } from "@/lib/updates/view";
|
|
5
13
|
|
|
6
|
-
|
|
14
|
+
const conflict = (error: string) =>
|
|
15
|
+
NextResponse.json({ error }, { status: 409 });
|
|
16
|
+
|
|
17
|
+
// PUT /api/updates/settings {banner?, auto?, idleMinutes?} - show the update
|
|
18
|
+
// banner, install updates automatically, and how long everything must be
|
|
19
|
+
// idle first.
|
|
7
20
|
export async function PUT(request: NextRequest) {
|
|
8
21
|
const allowed = requireApprover(request);
|
|
9
22
|
if (!allowed.ok) return allowed.response;
|
|
10
23
|
const body = (await request.json().catch(() => null)) as {
|
|
11
24
|
banner?: unknown;
|
|
25
|
+
auto?: unknown;
|
|
26
|
+
idleMinutes?: unknown;
|
|
12
27
|
} | null;
|
|
13
|
-
|
|
28
|
+
const { banner, auto, idleMinutes } = body ?? {};
|
|
29
|
+
if (
|
|
30
|
+
(banner === undefined && auto === undefined && idleMinutes === undefined) ||
|
|
31
|
+
(banner !== undefined && typeof banner !== "boolean") ||
|
|
32
|
+
(auto !== undefined && typeof auto !== "boolean") ||
|
|
33
|
+
(idleMinutes !== undefined &&
|
|
34
|
+
!(IDLE_MINUTES as readonly unknown[]).includes(idleMinutes))
|
|
35
|
+
)
|
|
14
36
|
return NextResponse.json(
|
|
15
|
-
{
|
|
37
|
+
{
|
|
38
|
+
error: `Send banner or auto (true or false), or idleMinutes (${IDLE_MINUTES.join(", ")}).`,
|
|
39
|
+
},
|
|
16
40
|
{ status: 400 }
|
|
17
41
|
);
|
|
18
|
-
if (bannerLocked())
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
42
|
+
if (banner !== undefined && bannerLocked())
|
|
43
|
+
return conflict("The banner is pinned off by AGENTOS_UPDATE_BANNER.");
|
|
44
|
+
if (auto !== undefined && autoLocked())
|
|
45
|
+
return conflict("Automatic updates are pinned off by AGENTOS_AUTO_UPDATE.");
|
|
46
|
+
if (idleMinutes !== undefined && idleMinutesLocked())
|
|
47
|
+
return conflict("The idle time is pinned by AGENTOS_AUTO_UPDATE_IDLE_MIN.");
|
|
48
|
+
if (typeof banner === "boolean") setBannerEnabled(banner);
|
|
49
|
+
if (typeof auto === "boolean") setAutoEnabled(auto);
|
|
50
|
+
if (typeof idleMinutes === "number") setIdleMinutes(idleMinutes);
|
|
24
51
|
return NextResponse.json(await updateView({ mayUpdate: true }));
|
|
25
52
|
}
|
package/app/page.tsx
CHANGED
|
@@ -7,6 +7,9 @@ import { subscribe } from "valtio";
|
|
|
7
7
|
import { viewSwitchStore, viewSwitchActions } from "@/stores/viewSwitch";
|
|
8
8
|
import { useOpenSession } from "@/hooks/useOpenSession";
|
|
9
9
|
import { useSessionUrl } from "@/hooks/useSessionUrl";
|
|
10
|
+
import { useNavActions } from "@/hooks/useNavActions";
|
|
11
|
+
import { useChatViewsSync } from "@/hooks/useChatViewsSync";
|
|
12
|
+
import { chatPositionsActions } from "@/stores/chatPositions";
|
|
10
13
|
import { tmuxAttachStore, tmuxAttachActions } from "@/stores/tmuxAttach";
|
|
11
14
|
import type { AttachSpec } from "@/lib/hosts/attach";
|
|
12
15
|
import { memo, useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
@@ -55,7 +58,8 @@ import { useDraftKeys } from "@/hooks/useDraftKeys";
|
|
|
55
58
|
import { useDraftRequests } from "@/hooks/useDraftRequests";
|
|
56
59
|
import { newDraft } from "@/stores/drafts";
|
|
57
60
|
import { useOpenOrchestrator } from "@/hooks/useOpenOrchestrator";
|
|
58
|
-
import {
|
|
61
|
+
import { ShortcutsDialog } from "@/components/Shortcuts/ShortcutsDialog";
|
|
62
|
+
import { PendingKeys } from "@/components/Shortcuts/PendingKeys";
|
|
59
63
|
import { useAppCommands } from "@/components/CommandPalette/useAppCommands";
|
|
60
64
|
import { useMoveCommands } from "@/components/Tasks/useMoveCommands";
|
|
61
65
|
import dynamic from "next/dynamic";
|
|
@@ -187,6 +191,8 @@ const AppDialogs = memo(function AppDialogs({
|
|
|
187
191
|
<AwayReport />
|
|
188
192
|
<IncomingLinks />
|
|
189
193
|
<CommandPalette />
|
|
194
|
+
<ShortcutsDialog />
|
|
195
|
+
<PendingKeys />
|
|
190
196
|
</>
|
|
191
197
|
);
|
|
192
198
|
});
|
|
@@ -530,9 +536,12 @@ function HomeContent() {
|
|
|
530
536
|
isMobile,
|
|
531
537
|
});
|
|
532
538
|
|
|
539
|
+
useChatViewsSync();
|
|
540
|
+
|
|
533
541
|
// Notification click handler
|
|
534
542
|
const handleNotificationClick = useCallback(
|
|
535
543
|
(sessionId: string) => {
|
|
544
|
+
chatPositionsActions.wantLatest(sessionId);
|
|
536
545
|
const session = sessions.find((s) => s.id === sessionId);
|
|
537
546
|
if (session) {
|
|
538
547
|
attachToSession(session);
|
|
@@ -571,19 +580,6 @@ function HomeContent() {
|
|
|
571
580
|
if (isHydrated && !isMobile) setSidebarOpen(true);
|
|
572
581
|
}, [isMobile, isHydrated]);
|
|
573
582
|
|
|
574
|
-
// Cmd+K: the command palette, which searches sessions too.
|
|
575
|
-
useEffect(() => {
|
|
576
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
577
|
-
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
578
|
-
e.preventDefault();
|
|
579
|
-
if (paletteUi.open) paletteActions.setOpen(false);
|
|
580
|
-
else paletteActions.open();
|
|
581
|
-
}
|
|
582
|
-
};
|
|
583
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
584
|
-
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
585
|
-
}, []);
|
|
586
|
-
|
|
587
583
|
// Picking a session with a tap, ⌘K or the keyboard: the composer is focused
|
|
588
584
|
// inside the gesture, which iOS requires. Other ways in (URL, notification,
|
|
589
585
|
// a session just created) use attachToSession's later focus.
|
|
@@ -674,6 +670,7 @@ function HomeContent() {
|
|
|
674
670
|
// A workspace's orchestrator row opens its chat, made on first open.
|
|
675
671
|
const handleOrchestratorOpened = useCallback(
|
|
676
672
|
(sessionId: string) => {
|
|
673
|
+
chatPositionsActions.wantLatest(sessionId);
|
|
677
674
|
if (isMobile) setSidebarOpen(false);
|
|
678
675
|
void handleSessionCreated(sessionId);
|
|
679
676
|
},
|
|
@@ -724,8 +721,14 @@ function HomeContent() {
|
|
|
724
721
|
chatMetaActions.setActive(focusedActiveTab?.sessionId ?? null);
|
|
725
722
|
}, [focusedActiveTab?.sessionId]);
|
|
726
723
|
|
|
724
|
+
useNavActions({
|
|
725
|
+
sessions,
|
|
726
|
+
activeSessionId: focusedActiveTab?.sessionId,
|
|
727
|
+
select: selectSession,
|
|
728
|
+
});
|
|
727
729
|
useAppCommands({
|
|
728
730
|
sessions,
|
|
731
|
+
activeSessionId: focusedActiveTab?.sessionId,
|
|
729
732
|
statuses: sessionStatuses,
|
|
730
733
|
onSelectSession: selectSession,
|
|
731
734
|
onSearchCode: () => setShowQuickSwitcher(true),
|