@saadnvd1/agent-os 0.3.807 → 0.3.810

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.
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
4
+ import { X } from "lucide-react";
5
+ import { useRef, useState, type TouchEvent } from "react";
6
+ import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog";
7
+ import { useMediaQuery } from "@/hooks/useMediaQuery";
8
+ import { isCloseSwipe, type ViewerMedia } from "./viewer";
9
+
10
+ /** Full-screen image or clip over everything; closes on its own, never its parent dialog. */
11
+ export function MediaViewer({
12
+ media,
13
+ open,
14
+ onOpenChange,
15
+ }: {
16
+ media: ViewerMedia;
17
+ open: boolean;
18
+ onOpenChange: (open: boolean) => void;
19
+ }) {
20
+ const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
21
+ const start = useRef<{ x: number; y: number } | null>(null);
22
+ const [drag, setDrag] = useState(0);
23
+
24
+ const onTouchStart = (e: TouchEvent) => {
25
+ const t = e.touches[0];
26
+ start.current = { x: t.clientX, y: t.clientY };
27
+ };
28
+ const onTouchMove = (e: TouchEvent) => {
29
+ if (!start.current) return;
30
+ setDrag(Math.max(0, e.touches[0].clientY - start.current.y));
31
+ };
32
+ const onTouchEnd = (e: TouchEvent) => {
33
+ const s = start.current;
34
+ start.current = null;
35
+ setDrag(0);
36
+ if (!s) return;
37
+ const t = e.changedTouches[0];
38
+ if (isCloseSwipe(t.clientX - s.x, t.clientY - s.y)) onOpenChange(false);
39
+ };
40
+
41
+ return (
42
+ <Dialog open={open} onOpenChange={onOpenChange}>
43
+ <DialogPortal>
44
+ <DialogOverlay className="z-[70] bg-black/90" />
45
+ <DialogPrimitive.Content
46
+ aria-label={media.alt}
47
+ aria-describedby={undefined}
48
+ onClick={(e) => {
49
+ // The backdrop is this full-screen box; the media itself isn't a target.
50
+ if (e.target === e.currentTarget) onOpenChange(false);
51
+ }}
52
+ onTouchStart={onTouchStart}
53
+ onTouchMove={onTouchMove}
54
+ onTouchEnd={onTouchEnd}
55
+ onTouchCancel={() => {
56
+ start.current = null;
57
+ setDrag(0);
58
+ }}
59
+ style={{
60
+ transform: drag ? `translateY(${drag}px)` : undefined,
61
+ paddingTop: "max(1rem, env(safe-area-inset-top))",
62
+ paddingRight: "max(1rem, env(safe-area-inset-right))",
63
+ paddingBottom: "max(1rem, env(safe-area-inset-bottom))",
64
+ paddingLeft: "max(1rem, env(safe-area-inset-left))",
65
+ }}
66
+ className="fixed inset-0 z-[70] grid place-items-center outline-none"
67
+ >
68
+ <DialogPrimitive.Title className="sr-only">
69
+ {media.alt}
70
+ </DialogPrimitive.Title>
71
+ {media.kind === "image" ? (
72
+ // eslint-disable-next-line @next/next/no-img-element
73
+ <img
74
+ src={media.src}
75
+ alt={media.alt}
76
+ width={media.width}
77
+ height={media.height}
78
+ decoding="async"
79
+ className="max-h-full max-w-full rounded-lg object-contain"
80
+ />
81
+ ) : (
82
+ <video
83
+ key={media.src}
84
+ src={media.src}
85
+ poster={media.poster}
86
+ aria-label={media.alt}
87
+ width={media.width}
88
+ height={media.height}
89
+ muted
90
+ loop
91
+ playsInline
92
+ autoPlay={!reduced}
93
+ controls={reduced}
94
+ className="max-h-full max-w-full rounded-lg object-contain"
95
+ />
96
+ )}
97
+ <DialogPrimitive.Close
98
+ aria-label="Close"
99
+ className="absolute grid size-11 place-items-center rounded-full bg-black/60 text-white hover:bg-black/80 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none"
100
+ style={{
101
+ top: "max(0.75rem, env(safe-area-inset-top))",
102
+ right: "max(0.75rem, env(safe-area-inset-right))",
103
+ }}
104
+ >
105
+ <X className="size-5" />
106
+ </DialogPrimitive.Close>
107
+ </DialogPrimitive.Content>
108
+ </DialogPortal>
109
+ </Dialog>
110
+ );
111
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isCloseSwipe, pickSrc } from "./viewer";
3
+
4
+ describe("pickSrc", () => {
5
+ const media = { src: "/a.png", srcDark: "/a-dark.png" };
6
+ it("uses the dark image in a dark theme", () => {
7
+ expect(pickSrc(media, true)).toBe("/a-dark.png");
8
+ });
9
+ it("uses the light image otherwise", () => {
10
+ expect(pickSrc(media, false)).toBe("/a.png");
11
+ });
12
+ it("falls back when there is no dark variant", () => {
13
+ expect(pickSrc({ src: "/a.png" }, true)).toBe("/a.png");
14
+ });
15
+ });
16
+
17
+ describe("isCloseSwipe", () => {
18
+ it("closes on a long downward drag", () => {
19
+ expect(isCloseSwipe(5, 120)).toBe(true);
20
+ });
21
+ it("ignores short drags, upward drags and sideways drags", () => {
22
+ expect(isCloseSwipe(0, 30)).toBe(false);
23
+ expect(isCloseSwipe(0, -200)).toBe(false);
24
+ expect(isCloseSwipe(150, 90)).toBe(false);
25
+ });
26
+ });
@@ -0,0 +1,23 @@
1
+ export interface ViewerMedia {
2
+ kind: "image" | "video";
3
+ src: string;
4
+ alt: string;
5
+ width: number;
6
+ height: number;
7
+ poster?: string;
8
+ }
9
+
10
+ /** The dark variant when the theme is dark and one exists. */
11
+ export function pickSrc(
12
+ media: { src: string; srcDark?: string },
13
+ dark: boolean
14
+ ): string {
15
+ return dark && media.srcDark ? media.srcDark : media.src;
16
+ }
17
+
18
+ export const SWIPE_CLOSE_PX = 80;
19
+
20
+ /** A mostly-vertical downward drag past the threshold closes the viewer. */
21
+ export function isCloseSwipe(dx: number, dy: number): boolean {
22
+ return dy >= SWIPE_CLOSE_PX && dy > Math.abs(dx) * 1.2;
23
+ }
@@ -61,7 +61,7 @@ export function TipDialog({
61
61
  <DialogContent
62
62
  showCloseButton={false}
63
63
  placement={wide ? "center" : "sheet"}
64
- className="flex max-h-[85dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[26rem]"
64
+ className="flex max-h-[85dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[26rem] md:max-w-[36rem]"
65
65
  >
66
66
  <div className="bg-muted-foreground/40 mx-auto mt-2 h-1 w-9 rounded-full md:hidden" />
67
67
  <div className="flex items-center gap-2 px-4 pt-3 pb-1">
@@ -1,9 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { Play } from "lucide-react";
3
+ import { Maximize2, Play } from "lucide-react";
4
4
  import { useTheme } from "next-themes";
5
5
  import { useRef, useState } from "react";
6
6
  import { useMediaQuery } from "@/hooks/useMediaQuery";
7
+ import { MediaViewer } from "@/components/Media/MediaViewer";
8
+ import { pickSrc } from "@/components/Media/viewer";
7
9
  import type { Media } from "@/lib/tips/schema";
8
10
 
9
11
  /** A tip's still or clip, sized by its own dimensions so nothing jumps as it loads. */
@@ -11,18 +13,49 @@ export function TipMedia({ media }: { media: Media }) {
11
13
  const { resolvedTheme } = useTheme();
12
14
  const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
13
15
  const [played, setPlayed] = useState(false);
16
+ const [expanded, setExpanded] = useState(false);
14
17
  const video = useRef<HTMLVideoElement>(null);
15
18
  const dark = !!resolvedTheme && !resolvedTheme.startsWith("light");
16
19
  const frame =
17
20
  "bg-muted relative max-h-[40dvh] w-full overflow-hidden rounded-lg";
18
21
  const style = { aspectRatio: `${media.width} / ${media.height}` };
22
+ const src = pickSrc(media, dark);
23
+ const expand = (
24
+ <>
25
+ <button
26
+ type="button"
27
+ aria-label={`Expand ${media.kind === "image" ? "image" : "clip"}: ${media.alt}`}
28
+ onClick={() => setExpanded(true)}
29
+ className="focus-visible:ring-ring absolute inset-0 cursor-zoom-in focus-visible:ring-2 focus-visible:outline-none"
30
+ >
31
+ <span
32
+ aria-hidden
33
+ className="absolute top-2 right-2 grid size-7 place-items-center rounded-md bg-black/60 text-white"
34
+ >
35
+ <Maximize2 className="size-3.5" />
36
+ </span>
37
+ </button>
38
+ <MediaViewer
39
+ media={{
40
+ kind: media.kind,
41
+ src,
42
+ alt: media.alt,
43
+ width: media.width,
44
+ height: media.height,
45
+ poster: media.kind === "video" ? media.poster : undefined,
46
+ }}
47
+ open={expanded}
48
+ onOpenChange={setExpanded}
49
+ />
50
+ </>
51
+ );
19
52
 
20
53
  if (media.kind === "image")
21
54
  return (
22
55
  <div className={frame} style={style}>
23
56
  {/* eslint-disable-next-line @next/next/no-img-element */}
24
57
  <img
25
- src={dark && media.srcDark ? media.srcDark : media.src}
58
+ src={src}
26
59
  alt={media.alt}
27
60
  width={media.width}
28
61
  height={media.height}
@@ -30,6 +63,7 @@ export function TipMedia({ media }: { media: Media }) {
30
63
  decoding="async"
31
64
  className="size-full object-contain"
32
65
  />
66
+ {expand}
33
67
  </div>
34
68
  );
35
69
 
@@ -51,6 +85,7 @@ export function TipMedia({ media }: { media: Media }) {
51
85
  preload={reduced ? "none" : "metadata"}
52
86
  className="size-full object-contain"
53
87
  />
88
+ {expand}
54
89
  {still && (
55
90
  <button
56
91
  type="button"
@@ -59,7 +94,7 @@ export function TipMedia({ media }: { media: Media }) {
59
94
  setPlayed(true);
60
95
  void video.current?.play();
61
96
  }}
62
- className="absolute inset-0 grid place-items-center bg-black/20"
97
+ className="absolute top-1/2 left-1/2 z-10 -translate-x-1/2 -translate-y-1/2"
63
98
  >
64
99
  <span className="bg-background/90 grid size-11 place-items-center rounded-full shadow">
65
100
  <Play className="size-5" />
@@ -1,5 +1,6 @@
1
1
  import "@/components/Chat/composer/test-dom";
2
- import { act, createElement } from "react";
2
+ import { act, createElement, useEffect, useState } from "react";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
4
  import { createRoot, type Root } from "react-dom/client";
4
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
6
  import type { UpdateView } from "@/lib/updates/banner";
@@ -19,6 +20,14 @@ for (const key of [
19
20
  writable: true,
20
21
  });
21
22
 
23
+ let desktopMatches = true;
24
+ window.matchMedia = ((query: string) => ({
25
+ matches: desktopMatches,
26
+ media: query,
27
+ addEventListener: () => {},
28
+ removeEventListener: () => {},
29
+ })) as unknown as typeof window.matchMedia;
30
+
22
31
  let view: UpdateView | undefined;
23
32
  const runMutate = vi.fn();
24
33
  const cancelMutate = vi.fn();
@@ -27,9 +36,42 @@ vi.mock("@/data/updates", () => ({
27
36
  useRunUpdate: () => ({ mutate: runMutate, isPending: false }),
28
37
  useCancelUpdate: () => ({ mutate: cancelMutate, isPending: false }),
29
38
  }));
30
- vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
39
+ // A stand-in for sonner: it records what the toast was asked to show, and a
40
+ // tray component renders the latest content like the real Toaster would.
41
+ interface Shown {
42
+ render: (id: string) => React.ReactNode;
43
+ opts: Record<string, unknown>;
44
+ }
45
+ const shown = new Map<string, Shown>();
46
+ const listeners = new Set<() => void>();
47
+ const emit = () => listeners.forEach((l) => l());
48
+ const custom = vi.fn((render: Shown["render"], opts: Shown["opts"]) => {
49
+ shown.set(opts.id as string, { render, opts });
50
+ emit();
51
+ });
52
+ const dismiss = vi.fn((id: string) => {
53
+ shown.delete(id);
54
+ emit();
55
+ });
56
+ vi.mock("sonner", () => ({ toast: { error: vi.fn(), custom, dismiss } }));
57
+
58
+ const { UpdateToast } = await import("./UpdateToast");
59
+ const { settingsUi } = await import("@/stores/settingsUi");
31
60
 
32
- const { UpdateBanner } = await import("./UpdateBanner");
61
+ function Tray() {
62
+ const [, tick] = useState(0);
63
+ useEffect(() => {
64
+ const l = () => tick((n) => n + 1);
65
+ listeners.add(l);
66
+ l();
67
+ return () => void listeners.delete(l);
68
+ }, []);
69
+ return createElement(
70
+ "div",
71
+ null,
72
+ [...shown].map(([id, t]) => createElement("div", { key: id }, t.render(id)))
73
+ );
74
+ }
33
75
 
34
76
  (
35
77
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
@@ -60,7 +102,18 @@ const make = (over: Partial<UpdateView> = {}): UpdateView => ({
60
102
 
61
103
  let root: Root;
62
104
  let host: HTMLElement;
63
- const render = () => act(async () => root.render(createElement(UpdateBanner)));
105
+ const qc = new QueryClient();
106
+ const render = () =>
107
+ act(async () =>
108
+ root.render(
109
+ createElement(
110
+ QueryClientProvider,
111
+ { client: qc },
112
+ createElement(UpdateToast),
113
+ createElement(Tray)
114
+ )
115
+ )
116
+ );
64
117
  const text = () => document.body.textContent ?? "";
65
118
  const button = (label: string) =>
66
119
  [...document.querySelectorAll<HTMLButtonElement>("button")].find(
@@ -70,6 +123,11 @@ const button = (label: string) =>
70
123
 
71
124
  beforeEach(() => {
72
125
  localStorage.clear();
126
+ shown.clear();
127
+ custom.mockClear();
128
+ dismiss.mockClear();
129
+ settingsUi.open = false;
130
+ settingsUi.section = null;
73
131
  runMutate.mockReset();
74
132
  cancelMutate.mockReset();
75
133
  view = make();
@@ -82,7 +140,7 @@ afterEach(async () => {
82
140
  host.remove();
83
141
  });
84
142
 
85
- describe("UpdateBanner", () => {
143
+ describe("UpdateToast", () => {
86
144
  it("says which version is available, with a link to the notes", async () => {
87
145
  await render();
88
146
  expect(text()).toContain("AgentOS 0.3.5 is available");
@@ -119,6 +177,7 @@ describe("UpdateBanner", () => {
119
177
  view = make({ bannerEnabled: false });
120
178
  await render();
121
179
  expect(text()).toBe("");
180
+ expect(shown.size).toBe(0);
122
181
  view = make({ available: false });
123
182
  await render();
124
183
  expect(text()).toBe("");
@@ -161,7 +220,7 @@ describe("UpdateBanner", () => {
161
220
  expect(cancelMutate).toHaveBeenCalled();
162
221
  });
163
222
 
164
- it("shows progress while updating, with no way to dismiss it", async () => {
223
+ it("shows progress while updating and stays up, with a close button", async () => {
165
224
  view = make({
166
225
  run: {
167
226
  id: "r",
@@ -174,10 +233,10 @@ describe("UpdateBanner", () => {
174
233
  });
175
234
  await render();
176
235
  expect(text()).toContain("Building… step 3 of 5");
177
- expect(button("Dismiss")).toBeUndefined();
236
+ expect(custom.mock.calls.at(-1)![1].duration).toBe(Infinity);
178
237
  });
179
238
 
180
- it("shows a failure with its error until dismissed", async () => {
239
+ it("shows a failure with its error and a link to the log, until dismissed", async () => {
181
240
  view = make({
182
241
  available: false,
183
242
  run: {
@@ -192,6 +251,11 @@ describe("UpdateBanner", () => {
192
251
  });
193
252
  await render();
194
253
  expect(text()).toContain("build exploded");
254
+ expect(custom.mock.calls.at(-1)![1].duration).toBe(Infinity);
255
+ expect(document.querySelector("pre")).toBeNull();
256
+ await act(async () => button("View log")!.click());
257
+ expect(settingsUi.open).toBe(true);
258
+ expect(settingsUi.section).toBe("updates");
195
259
  await act(async () => button("Dismiss")!.click());
196
260
  expect(text()).toBe("");
197
261
  expect(localStorage.getItem("agentos.update.dismissedRun")).toBe("r9");
@@ -234,4 +298,90 @@ describe("UpdateBanner", () => {
234
298
  expect(text()).toContain("went back to 0.3.1");
235
299
  expect(document.querySelector('[role="alert"]')).toBeNull();
236
300
  });
301
+
302
+ it("keeps one toast id and updates it in place through the update", async () => {
303
+ await render();
304
+ const run = (phase: "building" | "restarting" | "done") => ({
305
+ id: "r",
306
+ phase,
307
+ auto: true,
308
+ from: "a",
309
+ fromVersion: "0.3.1",
310
+ toVersion: "0.3.5",
311
+ startedAt: Date.now(),
312
+ finishedAt: phase === "done" ? Date.now() : undefined,
313
+ });
314
+ for (const phase of ["building", "restarting", "done"] as const) {
315
+ view = make({ available: false, run: run(phase) });
316
+ await render();
317
+ }
318
+ expect(text()).toContain("updated itself to 0.3.5");
319
+ expect(new Set(custom.mock.calls.map((c) => c[1].id))).toEqual(
320
+ new Set(["agentos-update"])
321
+ );
322
+ expect(shown.size).toBe(1);
323
+ });
324
+
325
+ it("auto-dismisses the updated note after a few seconds", async () => {
326
+ view = make({
327
+ available: false,
328
+ run: {
329
+ id: "a1",
330
+ phase: "done",
331
+ auto: true,
332
+ from: "a",
333
+ fromVersion: "0.3.1",
334
+ toVersion: "0.3.5",
335
+ startedAt: 1,
336
+ finishedAt: Date.now(),
337
+ },
338
+ });
339
+ await render();
340
+ const opts = custom.mock.calls.at(-1)![1];
341
+ expect(opts.duration).toBeGreaterThan(0);
342
+ expect(opts.duration).toBeLessThanOrEqual(10_000);
343
+ await act(async () => (opts.onAutoClose as () => void)());
344
+ expect(text()).toBe("");
345
+ expect(localStorage.getItem("agentos.update.dismissedRun")).toBe("a1");
346
+ });
347
+
348
+ it("closing a running toast hides it without cancelling the update", async () => {
349
+ view = make({
350
+ run: {
351
+ id: "r",
352
+ phase: "building",
353
+ from: "a",
354
+ fromVersion: "0.3.1",
355
+ toVersion: "0.3.5",
356
+ startedAt: Date.now(),
357
+ },
358
+ });
359
+ await render();
360
+ await act(async () => button("Dismiss")!.click());
361
+ expect(text()).toBe("");
362
+ view = make({
363
+ run: {
364
+ id: "r",
365
+ phase: "failed",
366
+ from: "a",
367
+ fromVersion: "0.3.1",
368
+ toVersion: "0.3.5",
369
+ startedAt: 1,
370
+ error: "boom",
371
+ },
372
+ });
373
+ await render();
374
+ expect(text()).toContain("boom");
375
+ });
376
+
377
+ it("sits top-center on phones and bottom-right on desktop", async () => {
378
+ await render();
379
+ expect(custom.mock.calls.at(-1)![1].position).toBe("bottom-right");
380
+ desktopMatches = false;
381
+ await act(async () => root.unmount());
382
+ root = createRoot(host);
383
+ await render();
384
+ desktopMatches = true;
385
+ expect(custom.mock.calls.at(-1)![1].position).toBe("top-center");
386
+ });
237
387
  });
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useEffect, useState, type ReactNode } from "react";
4
+ import { useQueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { toast } from "sonner";
4
6
  import { ArrowUpCircle, ChevronDown, Loader2, X } from "lucide-react";
5
7
  import { Button } from "@/components/ui/button";
6
8
  import { useUpdates } from "@/data/updates";
@@ -15,6 +17,7 @@ import { cn } from "@/lib/utils";
15
17
  import { TipHost } from "@/components/Tips";
16
18
  import { WhatsNewHost } from "@/components/WhatsNew";
17
19
  import { whatsNewActions } from "@/stores/whatsNew";
20
+ import { settingsUiActions } from "@/stores/settingsUi";
18
21
  import { UpdateActions } from "./UpdateActions";
19
22
 
20
23
  const STEPS = [
@@ -43,29 +46,12 @@ function useSince(startedAt: number | undefined, on: boolean) {
43
46
  return startedAt ? now - startedAt : 0;
44
47
  }
45
48
 
46
- function Log({ lines }: { lines: string[] }) {
47
- const [open, setOpen] = useState(false);
48
- if (!lines.length) return null;
49
- return (
50
- <>
51
- <button
52
- type="button"
53
- className="text-muted-foreground hover:text-foreground min-h-11 text-xs underline md:min-h-0"
54
- onClick={() => setOpen(!open)}
55
- >
56
- {open ? "Hide log" : "Show log"}
57
- </button>
58
- {open && (
59
- <pre className="bg-background/60 mt-1 max-h-48 w-full overflow-auto rounded-md p-2 text-xs whitespace-pre-wrap">
60
- {lines.join("\n")}
61
- </pre>
62
- )}
63
- </>
64
- );
65
- }
49
+ export const UPDATE_TOAST_ID = "agentos-update";
50
+ const UPDATED_AUTOCLOSE_MS = 6000;
51
+ const DESKTOP_QUERY = "(min-width: 768px)";
66
52
 
67
- /** The bar itself; what to show and what the buttons do come in as props. */
68
- export function UpdateBannerView({
53
+ /** The toast's content; what to show and what the buttons do come in as props. */
54
+ export function UpdateToastView({
69
55
  state,
70
56
  view,
71
57
  onDismiss,
@@ -79,6 +65,8 @@ export function UpdateBannerView({
79
65
  const elapsed = useSince(run?.startedAt, state === "running");
80
66
  const latest = view.latest;
81
67
  const failed = state === "failed";
68
+ const link =
69
+ "text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs underline md:min-h-0";
82
70
 
83
71
  let text: React.ReactNode;
84
72
  if (state === "available") text = <>AgentOS {latest?.version} is available</>;
@@ -113,31 +101,15 @@ export function UpdateBannerView({
113
101
  );
114
102
  }
115
103
 
116
- const extra = (
117
- <>
118
- {state === "available" && !view.canUpdate.ok && view.canUpdate.reason && (
119
- <p className="text-muted-foreground text-xs">{view.canUpdate.reason}</p>
120
- )}
121
- {(failed || state === "rolled_back") && <Log lines={run?.log ?? []} />}
122
- {notesOpen && latest && (
123
- <ul className="text-muted-foreground list-disc space-y-0.5 pl-4 text-xs">
124
- {latest.notes.map((n, i) => (
125
- <li key={i}>{n}</li>
126
- ))}
127
- </ul>
128
- )}
129
- </>
130
- );
131
-
132
104
  return (
133
105
  <div
134
106
  role={failed ? "alert" : "status"}
135
107
  className={cn(
136
- "flex items-start gap-2 border-b px-3 py-2 text-sm",
137
- failed ? "bg-destructive/10 text-foreground" : "bg-muted/60"
108
+ "bg-card text-foreground pointer-events-auto flex w-[356px] max-w-[calc(100vw-2rem)] items-start gap-2 rounded-lg border p-3 text-sm shadow-lg",
109
+ failed && "border-destructive/50"
138
110
  )}
139
111
  >
140
- <span className="flex h-6 shrink-0 items-center md:h-8">
112
+ <span className="flex h-6 shrink-0 items-center">
141
113
  {state === "running" ? (
142
114
  <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
143
115
  ) : (
@@ -150,13 +122,44 @@ export function UpdateBannerView({
150
122
  )}
151
123
  </span>
152
124
  <div className="min-w-0 flex-1 space-y-1">
153
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 md:min-h-8">
154
- <p className="min-w-0 flex-1 basis-40">{text}</p>
125
+ <p className="min-h-6 leading-6">{text}</p>
126
+ {state === "available" &&
127
+ !view.canUpdate.ok &&
128
+ view.canUpdate.reason && (
129
+ <p className="text-muted-foreground text-xs">
130
+ {view.canUpdate.reason}
131
+ </p>
132
+ )}
133
+ {notesOpen && latest && (
134
+ <ul className="text-muted-foreground list-disc space-y-0.5 pl-4 text-xs">
135
+ {latest.notes.map((n, i) => (
136
+ <li key={i}>{n}</li>
137
+ ))}
138
+ </ul>
139
+ )}
140
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
141
+ {(state === "available" || state === "pending") && (
142
+ <UpdateActions view={view} />
143
+ )}
144
+ {state === "running" && elapsed > SLOW_MS && (
145
+ <Button size="sm" onClick={() => window.location.reload()}>
146
+ Reload
147
+ </Button>
148
+ )}
149
+ {(failed || state === "rolled_back") && (
150
+ <button
151
+ type="button"
152
+ className={link}
153
+ onClick={() => settingsUiActions.open("updates")}
154
+ >
155
+ View log
156
+ </button>
157
+ )}
155
158
  {state === "available" && latest && latest.notes.length > 0 && (
156
159
  <button
157
160
  type="button"
158
161
  aria-expanded={notesOpen}
159
- className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center gap-1 text-xs md:min-h-0"
162
+ className={cn(link, "gap-1 no-underline")}
160
163
  onClick={() => setNotesOpen(!notesOpen)}
161
164
  >
162
165
  Commit list
@@ -171,7 +174,7 @@ export function UpdateBannerView({
171
174
  {(state === "available" || state === "updated") && (
172
175
  <button
173
176
  type="button"
174
- className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs underline md:min-h-0"
177
+ className={link}
175
178
  onClick={() => whatsNewActions.open(true)}
176
179
  >
177
180
  See what&apos;s new
@@ -182,71 +185,98 @@ export function UpdateBannerView({
182
185
  href={latest.url}
183
186
  target="_blank"
184
187
  rel="noreferrer noopener"
185
- className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs underline md:min-h-0"
188
+ className={link}
186
189
  >
187
190
  Release notes
188
191
  </a>
189
192
  )}
190
- {(state === "available" || state === "pending") && (
191
- <UpdateActions view={view} />
192
- )}
193
- {state === "running" && elapsed > SLOW_MS && (
194
- <Button size="sm" onClick={() => window.location.reload()}>
195
- Reload
196
- </Button>
197
- )}
198
193
  </div>
199
- {extra}
200
194
  </div>
201
- {state !== "running" && state !== "pending" && (
202
- <Button
203
- variant="ghost"
204
- size="icon-sm"
205
- aria-label={
206
- state === "available" ? "Dismiss until the next version" : "Dismiss"
207
- }
208
- className="-my-1 -mr-1 h-11 w-11 shrink-0 md:h-8 md:w-8"
209
- onClick={onDismiss}
210
- >
211
- <X className="h-4 w-4" />
212
- </Button>
213
- )}
195
+ <Button
196
+ variant="ghost"
197
+ size="icon-sm"
198
+ aria-label={
199
+ state === "available" ? "Dismiss until the next version" : "Dismiss"
200
+ }
201
+ className="-my-1 -mr-1 h-11 w-11 shrink-0 md:h-8 md:w-8"
202
+ onClick={onDismiss}
203
+ >
204
+ <X className="h-4 w-4" />
205
+ </Button>
214
206
  </div>
215
207
  );
216
208
  }
217
209
 
218
- // Held apart: the page re-renders on every status push.
219
- export function UpdateBanner() {
210
+ function useIsDesktop() {
211
+ const [desktop, setDesktop] = useState(true);
212
+ useEffect(() => {
213
+ const mq = window.matchMedia(DESKTOP_QUERY);
214
+ const on = () => setDesktop(mq.matches);
215
+ on();
216
+ mq.addEventListener("change", on);
217
+ return () => mq.removeEventListener("change", on);
218
+ }, []);
219
+ return desktop;
220
+ }
221
+
222
+ /**
223
+ * One sonner toast with a fixed id, updated in place as the update moves on.
224
+ * Bottom-right on desktop; top-center on phones, where the bottom is the
225
+ * composer. Held apart: the page re-renders on every status push.
226
+ */
227
+ export function UpdateToast() {
220
228
  const { data: view } = useUpdates();
221
229
  const dismissed = useDismissedUpdate();
230
+ const queryClient = useQueryClient();
231
+ const desktop = useIsDesktop();
232
+ // Closing a running or pending toast hides it for that run only; the
233
+ // result (updated, failed) still shows afterwards.
234
+ const [closed, setClosed] = useState<string | null>(null);
222
235
  useReloadWhenUpdated(view);
223
- const state = bannerState({
236
+ const computed = bannerState({
224
237
  view,
225
238
  dismissedVersion: dismissed.version,
226
239
  dismissedRun: dismissed.run,
227
240
  });
228
- if (state === "hidden" || !view)
229
- return (
230
- <>
231
- <WhatsNewHost />
232
- <TipHost />
233
- </>
241
+ const key =
242
+ computed === "running"
243
+ ? `running:${view?.run?.id}`
244
+ : computed === "pending"
245
+ ? `pending:${view?.pending?.requestedAt}`
246
+ : null;
247
+ const state = key && key === closed ? "hidden" : computed;
248
+
249
+ const { dismissVersion, dismissRun } = dismissed;
250
+ useEffect(() => {
251
+ if (state === "hidden" || !view) {
252
+ toast.dismiss(UPDATE_TOAST_ID);
253
+ return;
254
+ }
255
+ const onDismiss = () => {
256
+ toast.dismiss(UPDATE_TOAST_ID);
257
+ if (key) setClosed(key);
258
+ else if (state === "available" && view.latest)
259
+ dismissVersion(view.latest.version);
260
+ else if (view.run) dismissRun(view.run.id);
261
+ };
262
+ const el: ReactNode = (
263
+ <QueryClientProvider client={queryClient}>
264
+ <UpdateToastView state={state} view={view} onDismiss={onDismiss} />
265
+ </QueryClientProvider>
234
266
  );
267
+ toast.custom(() => el, {
268
+ id: UPDATE_TOAST_ID,
269
+ position: desktop ? "bottom-right" : "top-center",
270
+ duration: state === "updated" ? UPDATED_AUTOCLOSE_MS : Infinity,
271
+ onAutoClose: onDismiss,
272
+ });
273
+ }, [state, view, key, desktop, queryClient, dismissVersion, dismissRun]);
274
+ useEffect(() => () => void toast.dismiss(UPDATE_TOAST_ID), []);
275
+
235
276
  return (
236
277
  <>
237
278
  <WhatsNewHost />
238
279
  <TipHost />
239
- <UpdateBannerView
240
- state={state}
241
- view={view}
242
- onDismiss={() =>
243
- state === "available" && view.latest
244
- ? dismissed.dismissVersion(view.latest.version)
245
- : view.run
246
- ? dismissed.dismissRun(view.run.id)
247
- : undefined
248
- }
249
- />
250
280
  </>
251
281
  );
252
282
  }
@@ -1,2 +1,2 @@
1
- export { UpdateBanner } from "./UpdateBanner";
1
+ export { UpdateToast } from "./UpdateToast";
2
2
  export { UpdatesSection } from "./UpdatesSection";
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { DesktopSidebar } from "./DesktopSidebar";
4
4
  import { DesktopAppActions, SidebarToggle } from "./DesktopAppActions";
5
- import { UpdateBanner } from "@/components/Updates";
5
+ import { UpdateToast } from "@/components/Updates";
6
6
  import { PaneLayout } from "@/components/PaneLayout";
7
7
  import { PaneBarSlotsProvider } from "@/components/Pane/PaneBarSlots";
8
8
  import type { ViewProps } from "./types";
@@ -53,7 +53,7 @@ export function DesktopView({
53
53
 
54
54
  {/* Main content */}
55
55
  <div className="flex min-w-0 flex-1 flex-col">
56
- <UpdateBanner />
56
+ <UpdateToast />
57
57
  {/* Pane Layout - full height */}
58
58
  <div className="min-h-0 flex-1">
59
59
  <PaneBarSlotsProvider
@@ -4,7 +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
+ import { UpdateToast } from "@/components/Updates";
8
8
  import type { ViewProps } from "./types";
9
9
  import { CodeSearchDialog, StartServerDialog } from "./lazy";
10
10
  import { fileOpenActions } from "@/stores/fileOpen";
@@ -30,7 +30,7 @@ export function MobileView({
30
30
  }: ViewProps) {
31
31
  return (
32
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 />
33
+ <UpdateToast />
34
34
  {/* Swipe sidebar */}
35
35
  <SwipeSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)}>
36
36
  <div className="flex h-full flex-col">
@@ -1849,6 +1849,22 @@
1849
1849
  "area": "Chat",
1850
1850
  "text": "Pressing Esc now takes you out of the message box when there is nothing else for it to do.",
1851
1851
  "url": "https://github.com/saadnvd1/agent-os/pull/541"
1852
+ },
1853
+ {
1854
+ "id": 542,
1855
+ "date": "2026-10-31",
1856
+ "kind": "improved",
1857
+ "area": "AgentOS",
1858
+ "text": "Tip of the Day images are larger on desktop, and you can tap one to see it full screen.",
1859
+ "url": "https://github.com/saadnvd1/agent-os/pull/542"
1860
+ },
1861
+ {
1862
+ "id": 543,
1863
+ "date": "2026-10-31",
1864
+ "kind": "improved",
1865
+ "area": "Updates",
1866
+ "text": "Update notices now appear as a small pop-up instead of a bar across the top of the app.",
1867
+ "url": "https://github.com/saadnvd1/agent-os/pull/543"
1852
1868
  }
1853
1869
  ]
1854
1870
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.807",
3
+ "version": "0.3.810",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"