@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.
- package/components/Media/MediaViewer.tsx +111 -0
- package/components/Media/viewer.test.ts +26 -0
- package/components/Media/viewer.ts +23 -0
- package/components/Tips/TipDialog.tsx +1 -1
- package/components/Tips/TipMedia.tsx +38 -3
- package/components/Updates/{UpdateBanner.test.ts → UpdateToast.test.ts} +158 -8
- package/components/Updates/{UpdateBanner.tsx → UpdateToast.tsx} +119 -89
- package/components/Updates/index.ts +1 -1
- package/components/views/DesktopView.tsx +2 -2
- package/components/views/MobileView.tsx +2 -2
- package/content/whats-new.json +16 -0
- package/package.json +1 -1
|
@@ -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={
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
|
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(
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
68
|
-
export function
|
|
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
|
|
137
|
-
failed
|
|
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
|
|
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
|
-
<
|
|
154
|
-
|
|
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="
|
|
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=
|
|
177
|
+
className={link}
|
|
175
178
|
onClick={() => whatsNewActions.open(true)}
|
|
176
179
|
>
|
|
177
180
|
See what'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=
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
236
|
+
const computed = bannerState({
|
|
224
237
|
view,
|
|
225
238
|
dismissedVersion: dismissed.version,
|
|
226
239
|
dismissedRun: dismissed.run,
|
|
227
240
|
});
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
33
|
+
<UpdateToast />
|
|
34
34
|
{/* Swipe sidebar */}
|
|
35
35
|
<SwipeSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)}>
|
|
36
36
|
<div className="flex h-full flex-col">
|
package/content/whats-new.json
CHANGED
|
@@ -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
|
}
|