@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.
- package/app/api/sessions/[id]/done/route.test.ts +70 -0
- package/app/api/sessions/[id]/done/route.ts +32 -6
- 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/SessionList/useDoneAction.test.ts +149 -0
- package/components/SessionList/useDoneAction.ts +17 -12
- 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/done/index.ts +33 -7
- 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/done/deadline.ts +45 -0
- package/lib/done/index.ts +4 -1
- 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,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
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import "@/components/Chat/composer/test-dom";
|
|
2
|
+
import { act, createElement, useEffect } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { Session } from "@/lib/db/types";
|
|
7
|
+
|
|
8
|
+
const toast = vi.hoisted(() =>
|
|
9
|
+
Object.assign(vi.fn(), {
|
|
10
|
+
loading: vi.fn(() => "t1"),
|
|
11
|
+
success: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
})
|
|
14
|
+
);
|
|
15
|
+
vi.mock("sonner", () => ({ toast }));
|
|
16
|
+
|
|
17
|
+
const { useDoneAction } = await import("./useDoneAction");
|
|
18
|
+
|
|
19
|
+
(
|
|
20
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
21
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
22
|
+
|
|
23
|
+
let root: Root;
|
|
24
|
+
let host: HTMLElement;
|
|
25
|
+
const hook: { current: (s: Session) => void } = { current: () => {} };
|
|
26
|
+
const confirmSpy = vi.fn(() => false);
|
|
27
|
+
const fetchSpy = vi.fn();
|
|
28
|
+
|
|
29
|
+
function Probe() {
|
|
30
|
+
const markDone = useDoneAction();
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
hook.current = markDone;
|
|
33
|
+
});
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
vi.stubGlobal("confirm", confirmSpy);
|
|
40
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
41
|
+
host = document.createElement("div");
|
|
42
|
+
document.body.append(host);
|
|
43
|
+
root = createRoot(host);
|
|
44
|
+
act(() =>
|
|
45
|
+
root.render(
|
|
46
|
+
createElement(
|
|
47
|
+
QueryClientProvider,
|
|
48
|
+
{ client: new QueryClient() },
|
|
49
|
+
createElement(Probe)
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
act(() => root.unmount());
|
|
56
|
+
host.remove();
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const quick = {
|
|
61
|
+
id: "q1",
|
|
62
|
+
name: "Fix pair page menu label",
|
|
63
|
+
quick_of: "orch",
|
|
64
|
+
task_status: "running",
|
|
65
|
+
pr_status: "open",
|
|
66
|
+
pr_number: 281,
|
|
67
|
+
} as Session;
|
|
68
|
+
|
|
69
|
+
const respond = (status: number, body: object) =>
|
|
70
|
+
fetchSpy.mockResolvedValue({
|
|
71
|
+
ok: status < 400,
|
|
72
|
+
status,
|
|
73
|
+
json: async () => body,
|
|
74
|
+
});
|
|
75
|
+
// Done on an open PR asks first, in a toast; Merge is its action.
|
|
76
|
+
const confirmMerge = () => {
|
|
77
|
+
const [, opts] = toast.mock.calls.at(-1) as unknown as [
|
|
78
|
+
string,
|
|
79
|
+
{ action: { onClick: () => void } },
|
|
80
|
+
];
|
|
81
|
+
act(() => opts.action.onClick());
|
|
82
|
+
};
|
|
83
|
+
const settle = () => act(() => new Promise((r) => setTimeout(r, 0)));
|
|
84
|
+
|
|
85
|
+
describe("Done from a row's menu", () => {
|
|
86
|
+
it("asks in the app, not with confirm(), and does nothing until Merge is chosen", async () => {
|
|
87
|
+
respond(200, {
|
|
88
|
+
outcome: {
|
|
89
|
+
id: "q1",
|
|
90
|
+
name: quick.name,
|
|
91
|
+
merged: "Merged.",
|
|
92
|
+
worktree: { action: "none" },
|
|
93
|
+
text: "Merged. Agent stopped, no worktree.",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
act(() => hook.current(quick));
|
|
97
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
98
|
+
confirmMerge();
|
|
99
|
+
await settle();
|
|
100
|
+
expect(confirmSpy).not.toHaveBeenCalled();
|
|
101
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
102
|
+
"/api/sessions/q1/done",
|
|
103
|
+
expect.objectContaining({ method: "POST" })
|
|
104
|
+
);
|
|
105
|
+
expect(toast.success).toHaveBeenCalledWith(
|
|
106
|
+
"Merged. Agent stopped, no worktree.",
|
|
107
|
+
expect.objectContaining({ id: "t1" })
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("shows a refusal as a toast naming the gate, for a quick change and a task", async () => {
|
|
112
|
+
respond(409, {
|
|
113
|
+
error: "Not done, nothing merged.\n- ci failed: CI failed",
|
|
114
|
+
});
|
|
115
|
+
act(() => hook.current(quick));
|
|
116
|
+
confirmMerge();
|
|
117
|
+
await settle();
|
|
118
|
+
act(() => hook.current({ ...quick, id: "t2", quick_of: null } as Session));
|
|
119
|
+
confirmMerge();
|
|
120
|
+
await settle();
|
|
121
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
122
|
+
expect(toast.error).toHaveBeenCalledTimes(2);
|
|
123
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
124
|
+
expect.stringContaining("ci failed"),
|
|
125
|
+
expect.objectContaining({ id: "t1" })
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("ends the Merging toast with an error when the server never answers", async () => {
|
|
130
|
+
vi.useFakeTimers();
|
|
131
|
+
fetchSpy.mockImplementation(
|
|
132
|
+
(_url: string, init: { signal?: AbortSignal }) =>
|
|
133
|
+
new Promise((_, reject) =>
|
|
134
|
+
init.signal?.addEventListener("abort", () =>
|
|
135
|
+
reject(init.signal?.reason)
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
act(() => hook.current(quick));
|
|
140
|
+
confirmMerge();
|
|
141
|
+
expect(toast.error).not.toHaveBeenCalled();
|
|
142
|
+
await act(() => vi.advanceTimersByTimeAsync(181_000));
|
|
143
|
+
vi.useRealTimers();
|
|
144
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
145
|
+
expect.any(String),
|
|
146
|
+
expect.objectContaining({ id: "t1" })
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -7,21 +7,12 @@ import {
|
|
|
7
7
|
} from "@/data/done";
|
|
8
8
|
import { useSettleSession } from "@/data/sessions/shelf";
|
|
9
9
|
|
|
10
|
-
// Done from a session's menu: an open PR merges through the gates,
|
|
11
|
-
//
|
|
10
|
+
// Done from a session's menu: an open PR merges through the gates, after an
|
|
11
|
+
// in-app confirmation; a failing gate comes back as an error toast naming it.
|
|
12
12
|
export function useDoneAction() {
|
|
13
13
|
const done = useDoneSession();
|
|
14
14
|
const settle = useSettleSession();
|
|
15
|
-
|
|
16
|
-
const merges =
|
|
17
|
-
session.task_status === "running" && session.pr_status === "open";
|
|
18
|
-
if (
|
|
19
|
-
merges &&
|
|
20
|
-
!confirm(
|
|
21
|
-
`Merge ${session.name}'s PR through the gates and finish it? A failing gate refuses and nothing merges.`
|
|
22
|
-
)
|
|
23
|
-
)
|
|
24
|
-
return;
|
|
15
|
+
const run = (session: Session, merges: boolean) => {
|
|
25
16
|
const id = toast.loading(
|
|
26
17
|
merges ? `Merging ${session.name}…` : `Finishing ${session.name}…`
|
|
27
18
|
);
|
|
@@ -45,6 +36,20 @@ export function useDoneAction() {
|
|
|
45
36
|
onError: (e) => toast.error(e.message, { id, duration: 12000 }),
|
|
46
37
|
});
|
|
47
38
|
};
|
|
39
|
+
return (session: Session) => {
|
|
40
|
+
const merges =
|
|
41
|
+
session.task_status === "running" && session.pr_status === "open";
|
|
42
|
+
if (!merges) return run(session, false);
|
|
43
|
+
// Merging is the one Done that can't be undone: asked in the app, not
|
|
44
|
+
// with confirm(), which a webview drops.
|
|
45
|
+
toast(`Merge ${session.name}'s PR and finish it?`, {
|
|
46
|
+
description:
|
|
47
|
+
"It merges only through the orchestrator's gates; a failing gate refuses and nothing merges.",
|
|
48
|
+
duration: 15000,
|
|
49
|
+
action: { label: "Merge", onClick: () => run(session, true) },
|
|
50
|
+
cancel: { label: "Cancel", onClick: () => {} },
|
|
51
|
+
});
|
|
52
|
+
};
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
// Archive from a session's menu: puts it away; Unarchive brings it back.
|
|
@@ -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);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "@/components/ui/dialog";
|
|
14
|
+
import { useCancelUpdate, useRunUpdate } from "@/data/updates";
|
|
15
|
+
import { workingTotal, type UpdateView } from "@/lib/updates/banner";
|
|
16
|
+
|
|
17
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
18
|
+
|
|
19
|
+
const toastError = (e: unknown) =>
|
|
20
|
+
toast.error(e instanceof Error ? e.message : String(e));
|
|
21
|
+
|
|
22
|
+
/** What a restart cuts, in words. */
|
|
23
|
+
export function workingWarning(w: UpdateView["working"]): string {
|
|
24
|
+
const parts: string[] = [];
|
|
25
|
+
if (w.sessions) parts.push(`${plural(w.sessions, "session")} working`);
|
|
26
|
+
if (w.reviews) parts.push(`${plural(w.reviews, "review")} running`);
|
|
27
|
+
if (w.setups) parts.push(`${plural(w.setups, "task setup")} running`);
|
|
28
|
+
return parts.join(", ");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Update now, or Cancel for an update waiting for quiet. When something is
|
|
33
|
+
* working, Update now asks first and offers to wait.
|
|
34
|
+
*/
|
|
35
|
+
export function UpdateActions({ view }: { view: UpdateView }) {
|
|
36
|
+
const run = useRunUpdate();
|
|
37
|
+
const cancel = useCancelUpdate();
|
|
38
|
+
const [confirm, setConfirm] = useState(false);
|
|
39
|
+
const busy = workingTotal(view.working) > 0;
|
|
40
|
+
|
|
41
|
+
if (view.pending)
|
|
42
|
+
return (
|
|
43
|
+
<Button
|
|
44
|
+
size="sm"
|
|
45
|
+
className="min-h-11 md:min-h-0"
|
|
46
|
+
variant="outline"
|
|
47
|
+
disabled={cancel.isPending}
|
|
48
|
+
onClick={() => cancel.mutate(undefined, { onError: toastError })}
|
|
49
|
+
>
|
|
50
|
+
Cancel
|
|
51
|
+
</Button>
|
|
52
|
+
);
|
|
53
|
+
if (!view.canUpdate.ok) return null;
|
|
54
|
+
|
|
55
|
+
const start = (when: "now" | "idle") => {
|
|
56
|
+
setConfirm(false);
|
|
57
|
+
run.mutate(when, { onError: toastError });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<Button
|
|
63
|
+
size="sm"
|
|
64
|
+
className="min-h-11 md:min-h-0"
|
|
65
|
+
disabled={run.isPending}
|
|
66
|
+
onClick={() => (busy ? setConfirm(true) : start("now"))}
|
|
67
|
+
>
|
|
68
|
+
Update now
|
|
69
|
+
</Button>
|
|
70
|
+
<Dialog open={confirm} onOpenChange={setConfirm}>
|
|
71
|
+
<DialogContent className="max-w-md">
|
|
72
|
+
<DialogHeader>
|
|
73
|
+
<DialogTitle>Update while work is running?</DialogTitle>
|
|
74
|
+
<DialogDescription>
|
|
75
|
+
{workingWarning(view.working)}. Terminals and chats keep running
|
|
76
|
+
through the restart; reviews and task setups in progress start
|
|
77
|
+
over when AgentOS is back.
|
|
78
|
+
</DialogDescription>
|
|
79
|
+
</DialogHeader>
|
|
80
|
+
<DialogFooter className="gap-2 max-sm:flex-col-reverse">
|
|
81
|
+
<Button variant="ghost" onClick={() => setConfirm(false)}>
|
|
82
|
+
Cancel
|
|
83
|
+
</Button>
|
|
84
|
+
<Button variant="outline" onClick={() => start("now")}>
|
|
85
|
+
Update now
|
|
86
|
+
</Button>
|
|
87
|
+
<Button onClick={() => start("idle")}>Update when idle</Button>
|
|
88
|
+
</DialogFooter>
|
|
89
|
+
</DialogContent>
|
|
90
|
+
</Dialog>
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import "@/components/Chat/composer/test-dom";
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { UpdateView } from "@/lib/updates/banner";
|
|
6
|
+
|
|
7
|
+
// Radix dispatches its own events; they must be jsdom's, not Node's.
|
|
8
|
+
for (const key of [
|
|
9
|
+
"Event",
|
|
10
|
+
"CustomEvent",
|
|
11
|
+
"FocusEvent",
|
|
12
|
+
"PopStateEvent",
|
|
13
|
+
"NodeFilter",
|
|
14
|
+
"HTMLButtonElement",
|
|
15
|
+
])
|
|
16
|
+
Object.defineProperty(globalThis, key, {
|
|
17
|
+
value: (window as unknown as Record<string, unknown>)[key],
|
|
18
|
+
configurable: true,
|
|
19
|
+
writable: true,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let view: UpdateView | undefined;
|
|
23
|
+
const runMutate = vi.fn();
|
|
24
|
+
const cancelMutate = vi.fn();
|
|
25
|
+
vi.mock("@/data/updates", () => ({
|
|
26
|
+
useUpdates: () => ({ data: view }),
|
|
27
|
+
useRunUpdate: () => ({ mutate: runMutate, isPending: false }),
|
|
28
|
+
useCancelUpdate: () => ({ mutate: cancelMutate, isPending: false }),
|
|
29
|
+
}));
|
|
30
|
+
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
|
|
31
|
+
|
|
32
|
+
const { UpdateBanner } = await import("./UpdateBanner");
|
|
33
|
+
|
|
34
|
+
(
|
|
35
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
36
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
37
|
+
|
|
38
|
+
const make = (over: Partial<UpdateView> = {}): UpdateView => ({
|
|
39
|
+
current: { version: "0.3.1", sha: "a" },
|
|
40
|
+
latest: {
|
|
41
|
+
version: "0.3.5",
|
|
42
|
+
sha: "b",
|
|
43
|
+
notes: ["Faster boot (#9)"],
|
|
44
|
+
url: "https://example.com/compare",
|
|
45
|
+
},
|
|
46
|
+
available: true,
|
|
47
|
+
behind: 4,
|
|
48
|
+
status: "ok",
|
|
49
|
+
checkedAt: 1,
|
|
50
|
+
checkError: null,
|
|
51
|
+
bannerEnabled: true,
|
|
52
|
+
bannerLocked: false,
|
|
53
|
+
canUpdate: { ok: true },
|
|
54
|
+
run: null,
|
|
55
|
+
pending: null,
|
|
56
|
+
working: { sessions: 0, reviews: 0, setups: 0 },
|
|
57
|
+
...over,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
let root: Root;
|
|
61
|
+
let host: HTMLElement;
|
|
62
|
+
const render = () => act(async () => root.render(createElement(UpdateBanner)));
|
|
63
|
+
const text = () => document.body.textContent ?? "";
|
|
64
|
+
const button = (label: string) =>
|
|
65
|
+
[...document.querySelectorAll<HTMLButtonElement>("button")].find(
|
|
66
|
+
(b) =>
|
|
67
|
+
b.textContent?.trim() === label || b.getAttribute("aria-label") === label
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
localStorage.clear();
|
|
72
|
+
runMutate.mockReset();
|
|
73
|
+
cancelMutate.mockReset();
|
|
74
|
+
view = make();
|
|
75
|
+
host = document.createElement("div");
|
|
76
|
+
document.body.appendChild(host);
|
|
77
|
+
root = createRoot(host);
|
|
78
|
+
});
|
|
79
|
+
afterEach(async () => {
|
|
80
|
+
await act(async () => root.unmount());
|
|
81
|
+
host.remove();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("UpdateBanner", () => {
|
|
85
|
+
it("says which version is available, with a link to the notes", async () => {
|
|
86
|
+
await render();
|
|
87
|
+
expect(text()).toContain("AgentOS 0.3.5 is available");
|
|
88
|
+
expect(
|
|
89
|
+
document.querySelector<HTMLAnchorElement>("a")?.getAttribute("href")
|
|
90
|
+
).toBe("https://example.com/compare");
|
|
91
|
+
await act(async () => button("What's new")!.click());
|
|
92
|
+
expect(text()).toContain("Faster boot (#9)");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("Dismiss hides it for that version and survives a reload", async () => {
|
|
96
|
+
await render();
|
|
97
|
+
await act(async () => button("Dismiss until the next version")!.click());
|
|
98
|
+
expect(text()).not.toContain("is available");
|
|
99
|
+
expect(localStorage.getItem("agentos.update.dismissed")).toBe("0.3.5");
|
|
100
|
+
|
|
101
|
+
// A new page load reads the same dismissal.
|
|
102
|
+
await act(async () => root.unmount());
|
|
103
|
+
root = createRoot(host);
|
|
104
|
+
await render();
|
|
105
|
+
expect(text()).not.toContain("is available");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("shows again for a newer version", async () => {
|
|
109
|
+
localStorage.setItem("agentos.update.dismissed", "0.3.5");
|
|
110
|
+
view = make({
|
|
111
|
+
latest: { version: "0.3.6", sha: "c", notes: [], url: null },
|
|
112
|
+
});
|
|
113
|
+
await render();
|
|
114
|
+
expect(text()).toContain("AgentOS 0.3.6 is available");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("is absent when the banner is switched off or nothing is newer", async () => {
|
|
118
|
+
view = make({ bannerEnabled: false });
|
|
119
|
+
await render();
|
|
120
|
+
expect(text()).toBe("");
|
|
121
|
+
view = make({ available: false });
|
|
122
|
+
await render();
|
|
123
|
+
expect(text()).toBe("");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("offers no Update button to a caller who can't update, and says why", async () => {
|
|
127
|
+
view = make({
|
|
128
|
+
canUpdate: {
|
|
129
|
+
ok: false,
|
|
130
|
+
code: "forbidden",
|
|
131
|
+
reason: "Ask a device that can approve.",
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
await render();
|
|
135
|
+
expect(button("Update now")).toBeUndefined();
|
|
136
|
+
expect(text()).toContain("Ask a device that can approve.");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("Update now starts at once when nothing is working", async () => {
|
|
140
|
+
await render();
|
|
141
|
+
await act(async () => button("Update now")!.click());
|
|
142
|
+
expect(runMutate).toHaveBeenCalledWith("now", expect.anything());
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("warns when work is running, and can wait for idle", async () => {
|
|
146
|
+
view = make({ working: { sessions: 3, reviews: 0, setups: 0 } });
|
|
147
|
+
await render();
|
|
148
|
+
await act(async () => button("Update now")!.click());
|
|
149
|
+
expect(runMutate).not.toHaveBeenCalled();
|
|
150
|
+
expect(text()).toContain("3 sessions working");
|
|
151
|
+
await act(async () => button("Update when idle")!.click());
|
|
152
|
+
expect(runMutate).toHaveBeenCalledWith("idle", expect.anything());
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("shows a pending update with Cancel", async () => {
|
|
156
|
+
view = make({ pending: { requestedAt: 1 } });
|
|
157
|
+
await render();
|
|
158
|
+
expect(text()).toContain("will update when nothing is working");
|
|
159
|
+
await act(async () => button("Cancel")!.click());
|
|
160
|
+
expect(cancelMutate).toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("shows progress while updating, with no way to dismiss it", async () => {
|
|
164
|
+
view = make({
|
|
165
|
+
run: {
|
|
166
|
+
id: "r",
|
|
167
|
+
phase: "building",
|
|
168
|
+
from: "a",
|
|
169
|
+
fromVersion: "0.3.1",
|
|
170
|
+
toVersion: "0.3.5",
|
|
171
|
+
startedAt: Date.now(),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
await render();
|
|
175
|
+
expect(text()).toContain("Building… step 3 of 4");
|
|
176
|
+
expect(button("Dismiss")).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("shows a failure with its error until dismissed", async () => {
|
|
180
|
+
view = make({
|
|
181
|
+
available: false,
|
|
182
|
+
run: {
|
|
183
|
+
id: "r9",
|
|
184
|
+
phase: "failed",
|
|
185
|
+
from: "a",
|
|
186
|
+
fromVersion: "0.3.1",
|
|
187
|
+
toVersion: "0.3.5",
|
|
188
|
+
startedAt: 1,
|
|
189
|
+
error: "build exploded",
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
await render();
|
|
193
|
+
expect(text()).toContain("build exploded");
|
|
194
|
+
await act(async () => button("Dismiss")!.click());
|
|
195
|
+
expect(text()).toBe("");
|
|
196
|
+
expect(localStorage.getItem("agentos.update.dismissedRun")).toBe("r9");
|
|
197
|
+
});
|
|
198
|
+
});
|