@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,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
+ });
@@ -0,0 +1,212 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ArrowUpCircle, ChevronDown, Loader2, X } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { useUpdates } from "@/data/updates";
7
+ import { useDismissedUpdate } from "@/hooks/useDismissedUpdate";
8
+ import { useReloadWhenUpdated } from "@/hooks/useReloadWhenUpdated";
9
+ import {
10
+ bannerState,
11
+ type BannerState,
12
+ type UpdateView,
13
+ } from "@/lib/updates/banner";
14
+ import { cn } from "@/lib/utils";
15
+ import { UpdateActions } from "./UpdateActions";
16
+
17
+ const STEPS = ["pulling", "installing", "building", "restarting"] as const;
18
+ const LABEL: Record<(typeof STEPS)[number], string> = {
19
+ pulling: "Pulling the new version",
20
+ installing: "Installing dependencies",
21
+ building: "Building",
22
+ restarting: "Restarting",
23
+ };
24
+ const SLOW_MS = 10 * 60_000;
25
+
26
+ function useSince(startedAt: number | undefined, on: boolean) {
27
+ const [now, setNow] = useState(() => Date.now());
28
+ useEffect(() => {
29
+ if (!on) return;
30
+ const t = setInterval(() => setNow(Date.now()), 5000);
31
+ return () => clearInterval(t);
32
+ }, [on]);
33
+ return startedAt ? now - startedAt : 0;
34
+ }
35
+
36
+ function Log({ lines }: { lines: string[] }) {
37
+ const [open, setOpen] = useState(false);
38
+ if (!lines.length) return null;
39
+ return (
40
+ <>
41
+ <button
42
+ type="button"
43
+ className="text-muted-foreground hover:text-foreground min-h-11 text-xs underline md:min-h-0"
44
+ onClick={() => setOpen(!open)}
45
+ >
46
+ {open ? "Hide log" : "Show log"}
47
+ </button>
48
+ {open && (
49
+ <pre className="bg-background/60 mt-1 max-h-48 w-full overflow-auto rounded-md p-2 text-xs whitespace-pre-wrap">
50
+ {lines.join("\n")}
51
+ </pre>
52
+ )}
53
+ </>
54
+ );
55
+ }
56
+
57
+ /** The bar itself; what to show and what the buttons do come in as props. */
58
+ export function UpdateBannerView({
59
+ state,
60
+ view,
61
+ onDismiss,
62
+ }: {
63
+ state: Exclude<BannerState, "hidden">;
64
+ view: UpdateView;
65
+ onDismiss: () => void;
66
+ }) {
67
+ const [notesOpen, setNotesOpen] = useState(false);
68
+ const run = view.run;
69
+ const elapsed = useSince(run?.startedAt, state === "running");
70
+ const latest = view.latest;
71
+ const failed = state === "failed";
72
+
73
+ let text: React.ReactNode;
74
+ if (state === "available") text = <>AgentOS {latest?.version} is available</>;
75
+ else if (state === "pending")
76
+ text = <>AgentOS will update when nothing is working</>;
77
+ else if (state === "built")
78
+ text = <>Built {run?.toVersion}. Restart AgentOS to finish updating.</>;
79
+ else if (failed) text = <>The update didn&apos;t finish. {run?.error}</>;
80
+ else {
81
+ const step = Math.max(
82
+ 0,
83
+ STEPS.indexOf(run!.phase as (typeof STEPS)[number])
84
+ );
85
+ text =
86
+ elapsed > SLOW_MS ? (
87
+ <>Taking longer than expected. Check the update log, then reload.</>
88
+ ) : (
89
+ <>
90
+ {LABEL[STEPS[step]]}… step {step + 1} of {STEPS.length}
91
+ </>
92
+ );
93
+ }
94
+
95
+ const extra = (
96
+ <>
97
+ {state === "available" && !view.canUpdate.ok && view.canUpdate.reason && (
98
+ <p className="text-muted-foreground text-xs">{view.canUpdate.reason}</p>
99
+ )}
100
+ {failed && <Log lines={run?.log ?? []} />}
101
+ {notesOpen && latest && (
102
+ <ul className="text-muted-foreground list-disc space-y-0.5 pl-4 text-xs">
103
+ {latest.notes.map((n, i) => (
104
+ <li key={i}>{n}</li>
105
+ ))}
106
+ </ul>
107
+ )}
108
+ </>
109
+ );
110
+
111
+ return (
112
+ <div
113
+ role={failed ? "alert" : "status"}
114
+ className={cn(
115
+ "flex items-start gap-2 border-b px-3 py-2 text-sm",
116
+ failed ? "bg-destructive/10 text-foreground" : "bg-muted/60"
117
+ )}
118
+ >
119
+ <span className="flex h-6 shrink-0 items-center md:h-8">
120
+ {state === "running" ? (
121
+ <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
122
+ ) : (
123
+ <ArrowUpCircle
124
+ className={cn(
125
+ "h-4 w-4",
126
+ failed ? "text-destructive" : "text-primary"
127
+ )}
128
+ />
129
+ )}
130
+ </span>
131
+ <div className="min-w-0 flex-1 space-y-1">
132
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 md:min-h-8">
133
+ <p className="min-w-0 flex-1 basis-40">{text}</p>
134
+ {state === "available" && latest && latest.notes.length > 0 && (
135
+ <button
136
+ type="button"
137
+ aria-expanded={notesOpen}
138
+ className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center gap-1 text-xs md:min-h-0"
139
+ onClick={() => setNotesOpen(!notesOpen)}
140
+ >
141
+ What&apos;s new
142
+ <ChevronDown
143
+ className={cn(
144
+ "h-3 w-3 transition-transform",
145
+ notesOpen && "rotate-180"
146
+ )}
147
+ />
148
+ </button>
149
+ )}
150
+ {state === "available" && latest?.url && (
151
+ <a
152
+ href={latest.url}
153
+ target="_blank"
154
+ rel="noreferrer noopener"
155
+ className="text-primary inline-flex min-h-11 items-center text-xs underline md:min-h-0"
156
+ >
157
+ Release notes
158
+ </a>
159
+ )}
160
+ {(state === "available" || state === "pending") && (
161
+ <UpdateActions view={view} />
162
+ )}
163
+ {state === "running" && elapsed > SLOW_MS && (
164
+ <Button size="sm" onClick={() => window.location.reload()}>
165
+ Reload
166
+ </Button>
167
+ )}
168
+ </div>
169
+ {extra}
170
+ </div>
171
+ {state !== "running" && state !== "pending" && (
172
+ <Button
173
+ variant="ghost"
174
+ size="icon-sm"
175
+ aria-label={
176
+ state === "available" ? "Dismiss until the next version" : "Dismiss"
177
+ }
178
+ className="-my-1 -mr-1 h-11 w-11 shrink-0 md:h-8 md:w-8"
179
+ onClick={onDismiss}
180
+ >
181
+ <X className="h-4 w-4" />
182
+ </Button>
183
+ )}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ // Held apart: the page re-renders on every status push.
189
+ export function UpdateBanner() {
190
+ const { data: view } = useUpdates();
191
+ const dismissed = useDismissedUpdate();
192
+ useReloadWhenUpdated(view);
193
+ const state = bannerState({
194
+ view,
195
+ dismissedVersion: dismissed.version,
196
+ dismissedRun: dismissed.run,
197
+ });
198
+ if (state === "hidden" || !view) return null;
199
+ return (
200
+ <UpdateBannerView
201
+ state={state}
202
+ view={view}
203
+ onDismiss={() =>
204
+ state === "available" && view.latest
205
+ ? dismissed.dismissVersion(view.latest.version)
206
+ : view.run
207
+ ? dismissed.dismissRun(view.run.id)
208
+ : undefined
209
+ }
210
+ />
211
+ );
212
+ }
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import { toast } from "sonner";
4
+ import { Loader2 } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { SettingToggle } from "@/components/Settings/SettingToggle";
7
+ import {
8
+ useCheckForUpdates,
9
+ useUpdateBannerSetting,
10
+ useUpdates,
11
+ } from "@/data/updates";
12
+ import { isActivePhase } from "@/lib/updates/banner";
13
+ import { UpdateActions } from "./UpdateActions";
14
+
15
+ function ago(at: number | null): string {
16
+ if (!at) return "not yet";
17
+ const min = Math.floor((Date.now() - at) / 60_000);
18
+ if (min < 1) return "just now";
19
+ if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`;
20
+ const h = Math.floor(min / 60);
21
+ return h < 48
22
+ ? `${h} hour${h === 1 ? "" : "s"} ago`
23
+ : `${Math.floor(h / 24)} days ago`;
24
+ }
25
+
26
+ function Row({ label, value }: { label: string; value: React.ReactNode }) {
27
+ return (
28
+ <div className="flex items-baseline justify-between gap-4 py-1.5 text-sm">
29
+ <dt className="text-muted-foreground">{label}</dt>
30
+ <dd className="min-w-0 text-right font-medium">{value}</dd>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ // Settings > Updates: what's installed, what's out, and the switch for the banner.
36
+ export function UpdatesSection() {
37
+ const { data: view, isLoading, error } = useUpdates();
38
+ const check = useCheckForUpdates();
39
+ const banner = useUpdateBannerSetting();
40
+
41
+ if (isLoading || !view)
42
+ return (
43
+ <p className="text-muted-foreground text-sm">
44
+ {error ? "Couldn't load update status." : "Loading…"}
45
+ </p>
46
+ );
47
+
48
+ const running = isActivePhase(view.run?.phase);
49
+ const onCheck = () =>
50
+ check.mutate(undefined, {
51
+ onError: (e) => {
52
+ // Asked again inside a minute: the answer on screen is fresh.
53
+ if ((e as { status?: number }).status !== 429) toast.error(e.message);
54
+ },
55
+ });
56
+
57
+ let summary = "AgentOS is up to date.";
58
+ if (view.status === "diverged")
59
+ summary =
60
+ "This checkout has commits the remote doesn't, so it can't update itself.";
61
+ else if (view.status === "unknown")
62
+ summary = view.checkError ?? "Updates haven't been checked yet.";
63
+ else if (view.available)
64
+ summary = `${view.latest?.version} is available (${view.behind} newer change${view.behind === 1 ? "" : "s"}).`;
65
+
66
+ return (
67
+ <div className="space-y-5">
68
+ <dl className="divide-border divide-y">
69
+ <Row
70
+ label="Installed"
71
+ value={
72
+ view.current ? (
73
+ <>
74
+ {view.current.version}{" "}
75
+ <span className="text-muted-foreground font-mono text-xs">
76
+ {view.current.sha.slice(0, 7)}
77
+ </span>
78
+ </>
79
+ ) : (
80
+ "Unknown"
81
+ )
82
+ }
83
+ />
84
+ <Row label="Latest" value={view.latest?.version ?? "Unknown"} />
85
+ <Row label="Last checked" value={ago(view.checkedAt)} />
86
+ </dl>
87
+
88
+ <p className="text-sm" role="status">
89
+ {summary}
90
+ </p>
91
+ {view.checkError && view.status === "ok" && (
92
+ <p className="text-muted-foreground text-xs">
93
+ The last check failed: {view.checkError}
94
+ </p>
95
+ )}
96
+ {view.run?.phase === "failed" && (
97
+ <p className="text-destructive text-sm" role="alert">
98
+ The last update failed. {view.run.error}
99
+ </p>
100
+ )}
101
+ {running && (
102
+ <p className="text-muted-foreground flex items-center gap-2 text-sm">
103
+ <Loader2 className="h-4 w-4 animate-spin" /> Updating (
104
+ {view.run?.phase})…
105
+ </p>
106
+ )}
107
+ {view.available && !view.canUpdate.ok && view.canUpdate.reason && (
108
+ <p className="text-muted-foreground text-xs">{view.canUpdate.reason}</p>
109
+ )}
110
+ {view.pending && (
111
+ <p className="text-muted-foreground text-sm">
112
+ Will update when nothing is working.
113
+ </p>
114
+ )}
115
+
116
+ <div className="flex flex-wrap items-center gap-2">
117
+ <Button
118
+ variant="outline"
119
+ size="sm"
120
+ className="min-h-11 md:min-h-0"
121
+ disabled={check.isPending || running}
122
+ onClick={onCheck}
123
+ >
124
+ {check.isPending ? "Checking…" : "Check for updates"}
125
+ </Button>
126
+ {view.available && !running && <UpdateActions view={view} />}
127
+ {view.available && view.latest?.url && (
128
+ <a
129
+ href={view.latest.url}
130
+ target="_blank"
131
+ rel="noreferrer noopener"
132
+ className="text-primary text-sm underline"
133
+ >
134
+ Release notes
135
+ </a>
136
+ )}
137
+ </div>
138
+
139
+ <SettingToggle
140
+ title="Show update banner"
141
+ detail="Tell everyone using this AgentOS when a newer version is out. Nothing updates unless someone chooses to."
142
+ checked={view.bannerEnabled}
143
+ locked={view.bannerLocked}
144
+ onChange={(on) =>
145
+ banner.mutate(on, { onError: (e) => toast.error(e.message) })
146
+ }
147
+ />
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,2 @@
1
+ export { UpdateBanner } from "./UpdateBanner";
2
+ export { UpdatesSection } from "./UpdatesSection";
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { DesktopSidebar } from "./DesktopSidebar";
4
4
  import { DesktopAppActions, SidebarToggle } from "./DesktopAppActions";
5
+ import { UpdateBanner } from "@/components/Updates";
5
6
  import { PaneLayout } from "@/components/PaneLayout";
6
7
  import { PaneBarSlotsProvider } from "@/components/Pane/PaneBarSlots";
7
8
  import type { ViewProps } from "./types";
@@ -53,6 +54,7 @@ export function DesktopView({
53
54
 
54
55
  {/* Main content */}
55
56
  <div className="flex min-w-0 flex-1 flex-col">
57
+ <UpdateBanner />
56
58
  {/* Pane Layout - full height */}
57
59
  <div className="min-h-0 flex-1">
58
60
  <PaneBarSlotsProvider
@@ -4,6 +4,7 @@ import { SessionList } from "@/components/SessionList";
4
4
  import { SidebarFooter } from "@/components/SidebarFooter";
5
5
  import { PaneLayout } from "@/components/PaneLayout";
6
6
  import { SwipeSidebar } from "@/components/mobile/SwipeSidebar";
7
+ import { UpdateBanner } from "@/components/Updates";
7
8
  import type { ViewProps } from "./types";
8
9
  import { QuickSwitcher, StartServerDialog } from "./lazy";
9
10
  import { fileOpenActions } from "@/stores/fileOpen";
@@ -29,6 +30,7 @@ export function MobileView({
29
30
  }: ViewProps) {
30
31
  return (
31
32
  <main className="app-backdrop h-app fixed inset-x-0 top-[var(--app-top,0px)] flex flex-col overflow-hidden pb-[env(safe-area-inset-bottom)]">
33
+ <UpdateBanner />
32
34
  {/* Swipe sidebar */}
33
35
  <SwipeSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)}>
34
36
  <div className="flex h-full flex-col">