@saadnvd1/agent-os 0.3.794 → 0.3.799
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/busy/route.ts +2 -2
- package/app/api/tips/route.test.ts +56 -0
- package/app/api/tips/route.ts +45 -0
- package/app/api/updates/health/route.ts +22 -0
- package/app/api/updates/route.test.ts +53 -0
- package/app/api/updates/settings/route.ts +37 -10
- package/components/Chat/ChatPanel.tsx +28 -0
- package/components/Chat/needs-more-history.test.ts +28 -0
- package/components/Chat/needs-more-history.ts +14 -0
- package/components/Recipes/TimerFields.tsx +2 -1
- package/components/Schedules/ScheduleForm.tsx +1 -1
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/components/SidebarFooter.tsx +2 -0
- package/components/Tips/TipButton.tsx +39 -0
- package/components/Tips/TipDialog.tsx +148 -0
- package/components/Tips/TipHost.tsx +14 -0
- package/components/Tips/TipMedia.tsx +71 -0
- package/components/Tips/index.ts +2 -0
- package/components/Updates/AutoUpdates.tsx +141 -0
- package/components/Updates/UpdateBanner.test.ts +39 -1
- package/components/Updates/UpdateBanner.tsx +31 -5
- package/components/Updates/UpdatesSection.tsx +24 -3
- package/content/tips.json +456 -0
- package/content/whats-new.json +24 -0
- package/data/tips/index.ts +49 -0
- package/data/updates/index.ts +5 -0
- package/hooks/useReloadWhenUpdated.ts +5 -1
- package/lib/busy.ts +23 -1
- package/lib/db/migrations.ts +21 -0
- package/lib/recipes/timer.ts +3 -1
- package/lib/schedules/cron.ts +14 -5
- package/lib/schedules/store.ts +3 -2
- package/lib/security/demo.ts +2 -0
- package/lib/tasks/finish.ts +5 -5
- package/lib/tips/conditions.ts +38 -0
- package/lib/tips/index.ts +2 -0
- package/lib/tips/load.ts +26 -0
- package/lib/tips/media.ts +101 -0
- package/lib/tips/pick.ts +35 -0
- package/lib/tips/schema.ts +82 -0
- package/lib/tips/state.ts +26 -0
- package/lib/tips/view.ts +60 -0
- package/lib/updates/auto.ts +149 -0
- package/lib/updates/banner.ts +78 -3
- package/lib/updates/check.ts +64 -8
- package/lib/updates/history.ts +99 -0
- package/lib/updates/run.ts +334 -23
- package/lib/updates/settings.ts +66 -0
- package/lib/updates/view.ts +14 -3
- package/package.json +2 -2
- package/public/tips/.gitkeep +0 -0
- package/public/tips/chat-slash-menu-poster.webp +0 -0
- package/public/tips/chat-slash-menu.mp4 +0 -0
- package/scripts/autodeploy +4 -0
- package/scripts/autodeploy.test.ts +12 -0
- package/scripts/docs-links.test.ts +1 -25
- package/scripts/lib/commands.sh +32 -1
- package/scripts/lib/deps-swap.sh +8 -6
- package/scripts/lib/heading-slugs.ts +26 -0
- package/scripts/lib/update-verify.mjs +90 -0
- package/scripts/redeploy +31 -2
- package/scripts/redeploy.test.ts +94 -3
- package/scripts/tips-lint.test.ts +77 -0
- package/scripts/tips-media.ts +31 -0
- package/scripts/update-e2e.test.ts +215 -0
- package/scripts/update-recover.mjs +130 -0
- package/scripts/update-recover.test.ts +144 -0
- package/scripts/update-run.mjs +182 -40
- package/scripts/update-run.test.ts +208 -2
- package/server.ts +3 -2
- package/stores/tips.ts +14 -0
package/app/api/busy/route.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import { inFlight } from "@/lib/busy";
|
|
2
|
+
import { inFlight, inFlightTotal } from "@/lib/busy";
|
|
3
3
|
import { requireLocalTrust } from "@/lib/security/route-guard";
|
|
4
4
|
|
|
5
5
|
// GET /api/busy - work a restart would cut off; scripts/redeploy waits
|
|
@@ -8,6 +8,6 @@ export async function GET(request: NextRequest) {
|
|
|
8
8
|
const refused = requireLocalTrust(request);
|
|
9
9
|
if (refused) return refused;
|
|
10
10
|
const work = inFlight();
|
|
11
|
-
const busy = work
|
|
11
|
+
const busy = inFlightTotal(work) > 0;
|
|
12
12
|
return NextResponse.json({ ...work, busy });
|
|
13
13
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { putSetting } from "@/lib/settings-store";
|
|
4
|
+
import { GET, POST } from "./route";
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
for (const k of ["tips.off", "tips.seen", "tips.today"]) putSetting(k, null);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const get = (q = "?day=2026-11-01", headers: Record<string, string> = {}) =>
|
|
11
|
+
GET(new NextRequest(`http://localhost/api/tips${q}`, { headers }));
|
|
12
|
+
const post = (body: unknown) =>
|
|
13
|
+
POST(
|
|
14
|
+
new NextRequest("http://localhost/api/tips", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
body: JSON.stringify(body),
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe("/api/tips", () => {
|
|
21
|
+
it("returns today's tip and the deck, and 304s on the same ETag", async () => {
|
|
22
|
+
const res = await get();
|
|
23
|
+
const body = await res.json();
|
|
24
|
+
expect(body.today.id).toEqual(expect.any(String));
|
|
25
|
+
expect(body.deck.length).toBeGreaterThan(10);
|
|
26
|
+
const again = await get("?day=2026-11-01", {
|
|
27
|
+
"if-none-match": res.headers.get("etag")!,
|
|
28
|
+
});
|
|
29
|
+
expect(again.status).toBe(304);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects a bad day and a bad body", async () => {
|
|
33
|
+
expect((await get("?day=tomorrow")).status).toBe(400);
|
|
34
|
+
expect((await post({ action: "dance" })).status).toBe(400);
|
|
35
|
+
expect((await post({ action: "seen", ids: ["a"], day: "x" })).status).toBe(
|
|
36
|
+
400
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("remembers a tip as read", async () => {
|
|
41
|
+
const { today } = await (await get()).json();
|
|
42
|
+
const after = await (
|
|
43
|
+
await post({ action: "seen", ids: [today.id], day: "2026-11-01" })
|
|
44
|
+
).json();
|
|
45
|
+
expect(after.today).toEqual({ id: today.id, read: true });
|
|
46
|
+
expect((await (await get()).json()).today.read).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("turns tips off and on", async () => {
|
|
50
|
+
const off = await (await post({ action: "off", off: true })).json();
|
|
51
|
+
expect(off).toMatchObject({ off: true, today: null, deck: [] });
|
|
52
|
+
expect((await (await get()).json()).off).toBe(true);
|
|
53
|
+
const on = await (await post({ action: "off", off: false })).json();
|
|
54
|
+
expect(on.off).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { markSeen, setTipsOff, tipsView } from "@/lib/tips";
|
|
5
|
+
|
|
6
|
+
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
7
|
+
|
|
8
|
+
const post = z.discriminatedUnion("action", [
|
|
9
|
+
z.object({
|
|
10
|
+
action: z.literal("seen"),
|
|
11
|
+
ids: z.array(z.string().max(80)).max(100),
|
|
12
|
+
day,
|
|
13
|
+
}),
|
|
14
|
+
z.object({ action: z.literal("off"), off: z.boolean() }),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// GET /api/tips?day=YYYY-MM-DD - today's tip and the deck that applies now.
|
|
18
|
+
// `day` is the caller's local date, so the tip changes at their midnight.
|
|
19
|
+
export async function GET(request: NextRequest) {
|
|
20
|
+
const parsed = day
|
|
21
|
+
.optional()
|
|
22
|
+
.safeParse(request.nextUrl.searchParams.get("day") ?? undefined);
|
|
23
|
+
if (!parsed.success)
|
|
24
|
+
return NextResponse.json({ error: "Bad day" }, { status: 400 });
|
|
25
|
+
const text = JSON.stringify(tipsView(parsed.data));
|
|
26
|
+
const etag = `"${createHash("sha1").update(text).digest("hex").slice(0, 16)}"`;
|
|
27
|
+
const headers = { ETag: etag, "Cache-Control": "private, no-cache" };
|
|
28
|
+
if (request.headers.get("if-none-match") === etag)
|
|
29
|
+
return new NextResponse(null, { status: 304, headers });
|
|
30
|
+
return new NextResponse(text, {
|
|
31
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// POST /api/tips - { action: "seen", ids, day } or { action: "off", off }.
|
|
36
|
+
export async function POST(request: NextRequest) {
|
|
37
|
+
const body = post.safeParse(await request.json().catch(() => null));
|
|
38
|
+
if (!body.success)
|
|
39
|
+
return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
|
40
|
+
if (body.data.action === "seen") markSeen(body.data.ids, body.data.day);
|
|
41
|
+
else setTipsOff(body.data.off);
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
tipsView(body.data.action === "seen" ? body.data.day : undefined)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { buildId } from "@/lib/build";
|
|
3
|
+
import { db } from "@/lib/db";
|
|
4
|
+
import { migrationStatus } from "@/lib/db/migrations";
|
|
5
|
+
|
|
6
|
+
const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
7
|
+
|
|
8
|
+
// GET /api/updates/health - what scripts/update-run.mjs checks after an
|
|
9
|
+
// update's restart: the build this server runs, whether every migration it
|
|
10
|
+
// knows applied, and when it started (a change means it crashed and came back).
|
|
11
|
+
export function GET() {
|
|
12
|
+
let migrations: { known: number; applied: number } | null = null;
|
|
13
|
+
try {
|
|
14
|
+
migrations = migrationStatus(db);
|
|
15
|
+
} catch {}
|
|
16
|
+
return NextResponse.json({
|
|
17
|
+
ok: !!migrations,
|
|
18
|
+
build: buildId(),
|
|
19
|
+
startedAt,
|
|
20
|
+
migrations,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -32,6 +32,7 @@ const root = await import("./route");
|
|
|
32
32
|
const runRoute = await import("./run/route");
|
|
33
33
|
const checkRoute = await import("./check/route");
|
|
34
34
|
const settingsRoute = await import("./settings/route");
|
|
35
|
+
const healthRoute = await import("./health/route");
|
|
35
36
|
|
|
36
37
|
const req = (
|
|
37
38
|
path: string,
|
|
@@ -65,6 +66,7 @@ beforeEach(() => {
|
|
|
65
66
|
throttled = undefined;
|
|
66
67
|
refreshed = null;
|
|
67
68
|
delete process.env.AGENTOS_UPDATE_BANNER;
|
|
69
|
+
delete process.env.AGENTOS_AUTO_UPDATE;
|
|
68
70
|
process.env.AGENTOS_DEV = "1";
|
|
69
71
|
db.prepare(`DELETE FROM settings WHERE key LIKE 'updates.%'`).run();
|
|
70
72
|
});
|
|
@@ -205,3 +207,54 @@ describe("PUT /api/updates/settings", () => {
|
|
|
205
207
|
).toBe(409);
|
|
206
208
|
});
|
|
207
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("automatic updates settings", () => {
|
|
212
|
+
it("are on by default, and turn off and back on, with an idle time", async () => {
|
|
213
|
+
const first = await (await root.GET(req("", "GET", loopback))).json();
|
|
214
|
+
expect(first.auto).toMatchObject({
|
|
215
|
+
enabled: true,
|
|
216
|
+
locked: false,
|
|
217
|
+
idleMinutes: 10,
|
|
218
|
+
});
|
|
219
|
+
let view = await (
|
|
220
|
+
await settingsRoute.PUT(
|
|
221
|
+
req("/settings", "PUT", loopback, { auto: false, idleMinutes: 30 })
|
|
222
|
+
)
|
|
223
|
+
).json();
|
|
224
|
+
expect(view.auto).toMatchObject({ enabled: false, idleMinutes: 30 });
|
|
225
|
+
view = await (
|
|
226
|
+
await settingsRoute.PUT(req("/settings", "PUT", loopback, { auto: true }))
|
|
227
|
+
).json();
|
|
228
|
+
expect(view.auto.enabled).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("refuses an idle time it doesn't offer, and a pinned switch", async () => {
|
|
232
|
+
expect(
|
|
233
|
+
(
|
|
234
|
+
await settingsRoute.PUT(
|
|
235
|
+
req("/settings", "PUT", loopback, { idleMinutes: 7 })
|
|
236
|
+
)
|
|
237
|
+
).status
|
|
238
|
+
).toBe(400);
|
|
239
|
+
process.env.AGENTOS_AUTO_UPDATE = "off";
|
|
240
|
+
expect(
|
|
241
|
+
(
|
|
242
|
+
await settingsRoute.PUT(
|
|
243
|
+
req("/settings", "PUT", loopback, { auto: true })
|
|
244
|
+
)
|
|
245
|
+
).status
|
|
246
|
+
).toBe(409);
|
|
247
|
+
const view = await (await root.GET(req("", "GET", loopback))).json();
|
|
248
|
+
expect(view.auto).toMatchObject({ enabled: false, locked: true });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("GET /api/updates/health", () => {
|
|
253
|
+
it("says which build runs and that every migration applied", async () => {
|
|
254
|
+
const body = await healthRoute.GET().json();
|
|
255
|
+
expect(body.ok).toBe(true);
|
|
256
|
+
expect(body.build).toBeTruthy();
|
|
257
|
+
expect(body.migrations.known).toBeGreaterThan(0);
|
|
258
|
+
expect(body.migrations.applied).toBe(body.migrations.known);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -1,25 +1,52 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
IDLE_MINUTES,
|
|
5
|
+
autoLocked,
|
|
6
|
+
bannerLocked,
|
|
7
|
+
idleMinutesLocked,
|
|
8
|
+
setAutoEnabled,
|
|
9
|
+
setBannerEnabled,
|
|
10
|
+
setIdleMinutes,
|
|
11
|
+
} from "@/lib/updates/settings";
|
|
4
12
|
import { updateView } from "@/lib/updates/view";
|
|
5
13
|
|
|
6
|
-
|
|
14
|
+
const conflict = (error: string) =>
|
|
15
|
+
NextResponse.json({ error }, { status: 409 });
|
|
16
|
+
|
|
17
|
+
// PUT /api/updates/settings {banner?, auto?, idleMinutes?} - show the update
|
|
18
|
+
// banner, install updates automatically, and how long everything must be
|
|
19
|
+
// idle first.
|
|
7
20
|
export async function PUT(request: NextRequest) {
|
|
8
21
|
const allowed = requireApprover(request);
|
|
9
22
|
if (!allowed.ok) return allowed.response;
|
|
10
23
|
const body = (await request.json().catch(() => null)) as {
|
|
11
24
|
banner?: unknown;
|
|
25
|
+
auto?: unknown;
|
|
26
|
+
idleMinutes?: unknown;
|
|
12
27
|
} | null;
|
|
13
|
-
|
|
28
|
+
const { banner, auto, idleMinutes } = body ?? {};
|
|
29
|
+
if (
|
|
30
|
+
(banner === undefined && auto === undefined && idleMinutes === undefined) ||
|
|
31
|
+
(banner !== undefined && typeof banner !== "boolean") ||
|
|
32
|
+
(auto !== undefined && typeof auto !== "boolean") ||
|
|
33
|
+
(idleMinutes !== undefined &&
|
|
34
|
+
!(IDLE_MINUTES as readonly unknown[]).includes(idleMinutes))
|
|
35
|
+
)
|
|
14
36
|
return NextResponse.json(
|
|
15
|
-
{
|
|
37
|
+
{
|
|
38
|
+
error: `Send banner or auto (true or false), or idleMinutes (${IDLE_MINUTES.join(", ")}).`,
|
|
39
|
+
},
|
|
16
40
|
{ status: 400 }
|
|
17
41
|
);
|
|
18
|
-
if (bannerLocked())
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
42
|
+
if (banner !== undefined && bannerLocked())
|
|
43
|
+
return conflict("The banner is pinned off by AGENTOS_UPDATE_BANNER.");
|
|
44
|
+
if (auto !== undefined && autoLocked())
|
|
45
|
+
return conflict("Automatic updates are pinned off by AGENTOS_AUTO_UPDATE.");
|
|
46
|
+
if (idleMinutes !== undefined && idleMinutesLocked())
|
|
47
|
+
return conflict("The idle time is pinned by AGENTOS_AUTO_UPDATE_IDLE_MIN.");
|
|
48
|
+
if (typeof banner === "boolean") setBannerEnabled(banner);
|
|
49
|
+
if (typeof auto === "boolean") setAutoEnabled(auto);
|
|
50
|
+
if (typeof idleMinutes === "number") setIdleMinutes(idleMinutes);
|
|
24
51
|
return NextResponse.json(await updateView({ mayUpdate: true }));
|
|
25
52
|
}
|
|
@@ -63,6 +63,7 @@ import { ContextMeter } from "./ContextMeter";
|
|
|
63
63
|
import { SetupCard } from "./SetupCard";
|
|
64
64
|
import { UserTurn } from "./EventRow";
|
|
65
65
|
import { EventGroup } from "./EventTurns";
|
|
66
|
+
import { needsMoreHistory } from "./needs-more-history";
|
|
66
67
|
import { useGoToItem } from "./useGoToItem";
|
|
67
68
|
import { NeedsYouStrip } from "@/components/Orchestrator/NeedsYouStrip";
|
|
68
69
|
import { useResolveQuestion } from "@/data/orchestrators";
|
|
@@ -525,6 +526,33 @@ export function ChatPanel({
|
|
|
525
526
|
if (el && o.hasMore && !o.loadingOlder && el.scrollTop < el.clientHeight)
|
|
526
527
|
o.loadOlder();
|
|
527
528
|
}, []);
|
|
529
|
+
// Fill the screen on open: a page can fold into too few rows to scroll.
|
|
530
|
+
// One request per item count, so a page that fails or adds nothing stops it.
|
|
531
|
+
const filledAt = useRef({ sessionId, count: -1 });
|
|
532
|
+
useLayoutEffect(() => {
|
|
533
|
+
const el = listRef.current;
|
|
534
|
+
if (filledAt.current.sessionId !== sessionId)
|
|
535
|
+
filledAt.current = { sessionId, count: -1 };
|
|
536
|
+
if (!el || filledAt.current.count === items.length) return;
|
|
537
|
+
if (
|
|
538
|
+
needsMoreHistory({
|
|
539
|
+
hasMore,
|
|
540
|
+
loading: loadingOlder,
|
|
541
|
+
scrollHeight: el.scrollHeight,
|
|
542
|
+
clientHeight: el.clientHeight,
|
|
543
|
+
})
|
|
544
|
+
) {
|
|
545
|
+
filledAt.current.count = items.length;
|
|
546
|
+
loadOlder();
|
|
547
|
+
}
|
|
548
|
+
}, [
|
|
549
|
+
sessionId,
|
|
550
|
+
items.length,
|
|
551
|
+
hasMore,
|
|
552
|
+
loadingOlder,
|
|
553
|
+
loadOlder,
|
|
554
|
+
blocks.length,
|
|
555
|
+
]);
|
|
528
556
|
const scrollToBlock = useCallback(async (key: string) => {
|
|
529
557
|
// The row may only just have been added.
|
|
530
558
|
await new Promise((r) => requestAnimationFrame(r));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { needsMoreHistory } from "./needs-more-history";
|
|
3
|
+
|
|
4
|
+
const base = {
|
|
5
|
+
hasMore: true,
|
|
6
|
+
loading: false,
|
|
7
|
+
scrollHeight: 300,
|
|
8
|
+
clientHeight: 600,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("needsMoreHistory", () => {
|
|
12
|
+
it("asks for more when the list is shorter than the screen", () => {
|
|
13
|
+
expect(needsMoreHistory(base)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it("asks while less than one extra screen overflows", () => {
|
|
16
|
+
expect(needsMoreHistory({ ...base, scrollHeight: 1200 })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it("stops once there is more than a screen of overflow", () => {
|
|
19
|
+
expect(needsMoreHistory({ ...base, scrollHeight: 1201 })).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
it("does nothing without more history or while a page is loading", () => {
|
|
22
|
+
expect(needsMoreHistory({ ...base, hasMore: false })).toBe(false);
|
|
23
|
+
expect(needsMoreHistory({ ...base, loading: true })).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it("ignores a list that has no size yet", () => {
|
|
26
|
+
expect(needsMoreHistory({ ...base, clientHeight: 0 })).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface HistoryFill {
|
|
2
|
+
hasMore: boolean;
|
|
3
|
+
loading: boolean;
|
|
4
|
+
scrollHeight: number;
|
|
5
|
+
clientHeight: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// An older page can fold into a few collapsed rows, leaving a list too short
|
|
9
|
+
// to scroll, so no scroll event would ever ask for the next page. Keep about
|
|
10
|
+
// one extra screen of content above the fold.
|
|
11
|
+
export function needsMoreHistory(f: HistoryFill): boolean {
|
|
12
|
+
if (!f.hasMore || f.loading || f.clientHeight <= 0) return false;
|
|
13
|
+
return f.scrollHeight <= f.clientHeight * 2;
|
|
14
|
+
}
|
|
@@ -77,7 +77,8 @@ export function timerProblem(v: TimerValue): string | null {
|
|
|
77
77
|
if (!isTimezone(v.timezone)) return "Unknown time zone";
|
|
78
78
|
const bad = cronError(cron);
|
|
79
79
|
if (bad) return bad;
|
|
80
|
-
return minGapMinutes(cron, v.timezone) <
|
|
80
|
+
return minGapMinutes(cron, v.timezone, Date.now(), MIN_GAP_MINUTES) <
|
|
81
|
+
MIN_GAP_MINUTES
|
|
81
82
|
? "A recipe runs at most once an hour"
|
|
82
83
|
: null;
|
|
83
84
|
}
|
|
@@ -303,7 +303,7 @@ export function ScheduleForm({
|
|
|
303
303
|
!once &&
|
|
304
304
|
!cronProblem &&
|
|
305
305
|
tzOk &&
|
|
306
|
-
minGapMinutes(cron, timezone, openedAt) < minGap,
|
|
306
|
+
minGapMinutes(cron, timezone, openedAt, minGap) < minGap,
|
|
307
307
|
[minGap, once, cron, cronProblem, timezone, tzOk, openedAt]
|
|
308
308
|
);
|
|
309
309
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TipButton } from "@/components/Tips";
|
|
1
2
|
import { WhatsNewButton } from "@/components/WhatsNew";
|
|
2
3
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
|
3
4
|
import {
|
|
@@ -12,6 +13,7 @@ export function SidebarFooter() {
|
|
|
12
13
|
<div className="flex items-center justify-between">
|
|
13
14
|
<span className="label-mono text-muted-foreground">Theme</span>
|
|
14
15
|
<div className="flex items-center">
|
|
16
|
+
<TipButton />
|
|
15
17
|
<WhatsNewButton />
|
|
16
18
|
<ThemeToggle />
|
|
17
19
|
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Lightbulb } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { useTips } from "@/data/tips";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { tipsActions } from "@/stores/tips";
|
|
8
|
+
|
|
9
|
+
/** A small footer icon that opens the tip dialog; a dot marks today's unread tip. */
|
|
10
|
+
export function TipButton() {
|
|
11
|
+
const { data } = useTips();
|
|
12
|
+
if (data && (data.off || !data.deck.length)) return null;
|
|
13
|
+
// Holds the icon's place while the first answer loads, so What's New doesn't jump.
|
|
14
|
+
const fresh = !!data && !data.quiet && data.today?.read === false;
|
|
15
|
+
return (
|
|
16
|
+
<Button
|
|
17
|
+
type="button"
|
|
18
|
+
variant="ghost"
|
|
19
|
+
size="icon"
|
|
20
|
+
title="Tip of the Day"
|
|
21
|
+
aria-label={fresh ? "Tip of the Day (new tip)" : "Tip of the Day"}
|
|
22
|
+
disabled={!data}
|
|
23
|
+
onClick={() => tipsActions.open()}
|
|
24
|
+
// The ::after widens the tap area to 44px on a phone without adding height.
|
|
25
|
+
className={cn(
|
|
26
|
+
"text-muted-foreground relative h-8 w-8 after:absolute after:-inset-1.5 after:content-[''] md:after:hidden [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11",
|
|
27
|
+
!data && "invisible"
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
<Lightbulb className="h-4 w-4" />
|
|
31
|
+
{fresh && (
|
|
32
|
+
<span
|
|
33
|
+
data-testid="tip-dot"
|
|
34
|
+
className="bg-primary absolute top-1.5 right-1.5 size-1.5 rounded-full"
|
|
35
|
+
/>
|
|
36
|
+
)}
|
|
37
|
+
</Button>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ChevronLeft, ChevronRight, Lightbulb, X } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogClose,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "@/components/ui/dialog";
|
|
14
|
+
import { useMarkTipSeen, useSetTipsOff, useTips } from "@/data/tips";
|
|
15
|
+
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
16
|
+
import { TipMedia } from "./TipMedia";
|
|
17
|
+
|
|
18
|
+
export function TipDialog({
|
|
19
|
+
open,
|
|
20
|
+
onOpenChange,
|
|
21
|
+
}: {
|
|
22
|
+
open: boolean;
|
|
23
|
+
onOpenChange: (open: boolean) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const wide = useMediaQuery("(min-width: 768px)");
|
|
26
|
+
const { data } = useTips();
|
|
27
|
+
const markSeen = useMarkTipSeen();
|
|
28
|
+
const setOff = useSetTipsOff();
|
|
29
|
+
const deck = data?.deck ?? [];
|
|
30
|
+
const [index, setIndex] = useState(() =>
|
|
31
|
+
Math.max(
|
|
32
|
+
0,
|
|
33
|
+
deck.findIndex((t) => t.id === data?.today?.id)
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
const tip = deck[index];
|
|
37
|
+
|
|
38
|
+
// Whatever tip is on screen counts as seen; today's one clears the dot.
|
|
39
|
+
const tipId = tip?.id;
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (tipId) markSeen.mutate(tipId);
|
|
42
|
+
// markSeen is a new object each render; the tip is the trigger.
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
}, [tipId]);
|
|
45
|
+
|
|
46
|
+
const step = (by: number) =>
|
|
47
|
+
setIndex((i) => (i + by + deck.length) % deck.length);
|
|
48
|
+
|
|
49
|
+
const turnOff = () => {
|
|
50
|
+
setOff.mutate(true, {
|
|
51
|
+
onSuccess: () =>
|
|
52
|
+
toast("Tips are off. Turn them back on in Settings → Updates.", {
|
|
53
|
+
action: { label: "Undo", onClick: () => setOff.mutate(false) },
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
onOpenChange(false);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
61
|
+
<DialogContent
|
|
62
|
+
showCloseButton={false}
|
|
63
|
+
placement={wide ? "center" : "sheet"}
|
|
64
|
+
className="flex max-h-[85dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[26rem]"
|
|
65
|
+
>
|
|
66
|
+
<div className="bg-muted-foreground/40 mx-auto mt-2 h-1 w-9 rounded-full md:hidden" />
|
|
67
|
+
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
|
68
|
+
<span className="bg-muted grid size-7 shrink-0 place-items-center rounded-lg">
|
|
69
|
+
<Lightbulb className="size-4" />
|
|
70
|
+
</span>
|
|
71
|
+
<DialogDescription className="label-mono text-muted-foreground min-w-0 flex-1 text-xs">
|
|
72
|
+
Tip of the Day
|
|
73
|
+
{deck.length > 1 && ` · ${index + 1} of ${deck.length}`}
|
|
74
|
+
</DialogDescription>
|
|
75
|
+
<DialogClose asChild>
|
|
76
|
+
<Button
|
|
77
|
+
variant="ghost"
|
|
78
|
+
size="icon-sm"
|
|
79
|
+
aria-label="Close"
|
|
80
|
+
className="-mr-2 size-11 shrink-0 md:size-8"
|
|
81
|
+
>
|
|
82
|
+
<X className="size-4" />
|
|
83
|
+
</Button>
|
|
84
|
+
</DialogClose>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 pt-2 pb-4">
|
|
87
|
+
{tip ? (
|
|
88
|
+
<>
|
|
89
|
+
{tip.media && <TipMedia key={tip.id} media={tip.media} />}
|
|
90
|
+
<DialogTitle className="text-base font-semibold">
|
|
91
|
+
{tip.title}
|
|
92
|
+
</DialogTitle>
|
|
93
|
+
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
94
|
+
{tip.body}
|
|
95
|
+
</p>
|
|
96
|
+
{tip.url && (
|
|
97
|
+
<a
|
|
98
|
+
href={tip.url}
|
|
99
|
+
target="_blank"
|
|
100
|
+
rel="noopener noreferrer"
|
|
101
|
+
className="text-primary w-fit text-sm underline"
|
|
102
|
+
>
|
|
103
|
+
Learn more
|
|
104
|
+
</a>
|
|
105
|
+
)}
|
|
106
|
+
</>
|
|
107
|
+
) : (
|
|
108
|
+
<DialogTitle className="text-sm font-medium">
|
|
109
|
+
No tips right now.
|
|
110
|
+
</DialogTitle>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
<div className="bg-muted/40 flex items-center gap-2 px-4 py-2">
|
|
114
|
+
<Button
|
|
115
|
+
variant="ghost"
|
|
116
|
+
size="sm"
|
|
117
|
+
className="text-muted-foreground h-11 px-2 md:h-8"
|
|
118
|
+
onClick={turnOff}
|
|
119
|
+
>
|
|
120
|
+
Don't show tips
|
|
121
|
+
</Button>
|
|
122
|
+
<div className="ml-auto flex items-center gap-1">
|
|
123
|
+
<Button
|
|
124
|
+
variant="ghost"
|
|
125
|
+
size="icon-sm"
|
|
126
|
+
aria-label="Previous tip"
|
|
127
|
+
disabled={deck.length < 2}
|
|
128
|
+
onClick={() => step(-1)}
|
|
129
|
+
className="size-11 md:size-8"
|
|
130
|
+
>
|
|
131
|
+
<ChevronLeft className="size-4" />
|
|
132
|
+
</Button>
|
|
133
|
+
<Button
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="icon-sm"
|
|
136
|
+
aria-label="Next tip"
|
|
137
|
+
disabled={deck.length < 2}
|
|
138
|
+
onClick={() => step(1)}
|
|
139
|
+
className="size-11 md:size-8"
|
|
140
|
+
>
|
|
141
|
+
<ChevronRight className="size-4" />
|
|
142
|
+
</Button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</DialogContent>
|
|
146
|
+
</Dialog>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSnapshot } from "valtio";
|
|
4
|
+
import { tipsActions, tipsStore } from "@/stores/tips";
|
|
5
|
+
import { TipDialog } from "./TipDialog";
|
|
6
|
+
|
|
7
|
+
/** Mounted once, near the top of the app: the footer icon opens it. */
|
|
8
|
+
export function TipHost() {
|
|
9
|
+
const { open } = useSnapshot(tipsStore);
|
|
10
|
+
if (!open) return null;
|
|
11
|
+
return (
|
|
12
|
+
<TipDialog open onOpenChange={(next) => !next && tipsActions.close()} />
|
|
13
|
+
);
|
|
14
|
+
}
|