mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 17:48:43 -10:00
feat(frontend): show real changelog entries in the login What's new panel (#16326)
### Why / What / How
**Why:** The "What's new" list on the login page showed three hard-coded
placeholder items ("New memory upgrades", "Marketplace update",
"Performance boost"). They had not changed since July and linked
nowhere. Closes
[SECRT-3213](https://linear.app/autogpt/issue/SECRT-3213).
**What:** The panel now lists the three latest releases from the
published changelog, in a redesigned timeline card. Each release is
headed by its changelog page title, with its full highlights list
underneath, and links to its own changelog page in a new tab.
**How:** The login page now reads a new public route,
`/api/changelog/latest`. On the server it fetches the published
changelog index (cached for an hour, like `/api/changelog`, and limited
to 2 s) and parses it with the existing `parseChangelogIndex` shared
with the in-app popup, whose behaviour is unchanged. It then takes the
three newest releases and fetches their pages in parallel, each cached
for an hour and limited to 2 s, reading each page's first `# ` heading
as its title. Page URLs are only built from slugs taken from the parsed
index that match `^[a-z0-9]+(?:-[a-z0-9]+)*$`, so request input can't
reach an upstream URL. Each release is headed by its title, sanitised
like the rest of the text, over its full highlights list. A release
whose page can't be read keeps the first-highlight headline with the
remaining highlights below. If the index itself fails the route returns
502, and the panel falls back to the "See what we've shipped lately"
card, as it does after 8 s with no answer. The route's own upstream
budget (4 s) is at most half of that wait, leaving the rest for a slow
connection; a test holds that ratio. The middleware matcher already
excludes `api/changelog` and the paths under it, so the route works
logged out.
All screenshots below come from the real Next.js app at 1440x900 (mobile
at 390x844). Before is `dev`, after is this branch. The after shot shows
the live changelog.
| Before | After |
|---|---|
|

|

|
| Keyboard focus | Hover |
|---|---|
|

|

|
**Edge states.** For these three, the browser was given a fake
`/api/changelog/latest` response, so they are not live data. In the
long-content shot the middle release has no page title, showing the
first-highlight fallback. The emoji renders as a box only because the
capture machine has no emoji font.
| Long, markdown-heavy and unicode entries | Changelog fails (502) |
Changelog never answers (loading) |
|---|---|---|
|

|

|

|
**Unchanged.** Signup and mobile login are pixel-identical to `dev`; the
panel is hidden below `lg`.
| Signup before | Signup after | Mobile login after |
|---|---|---|
| 
| 
|

|
### Changes 🏗️
- New `login/components/LoginWhatsNew/` containing the component, hook,
helpers, list, item, loading placeholder and failure card. The
hard-coded items are removed from `LoginMarketingPanel`.
- Add `GET /api/changelog/latest`: the three newest releases from the
published changelog, each with its page title (`null` when the page
can't be read), cached for an hour, 502 when the index can't be read.
- Share the upstream URL, fetch options and cache header between the two
changelog routes (`src/app/api/changelog/helpers.ts`); `/api/changelog`
behaves exactly as before.
- `ChangelogPopup/helpers.ts` gains a shared `fetchChangelogEntries()`;
`useChangelog` uses it with no change in behaviour.
- `AuthMarketingPanel` can take its own content in place of its feature
list. Signup still passes its items and renders identically.
- Each release is headed by its page title with all of its highlights
below; without a title it falls back to the first highlight.
- Links open in a new tab (`rel="noopener noreferrer"`) so nobody loses
a login in progress, and screen readers hear "(opens in a new tab)".
- Long text wraps, including unbroken words, and is only cut off after
three lines. When it is cut, the full text is in a tooltip and screen
readers still read all of it.
- The loading skeleton is built from the release row's own layout
constants (`releaseRowLayout.ts`) and matches the loaded panel's height
(364px), so nothing moves when releases arrive.
- Contrast: all text is at least 5.07:1 (headlines 13.79:1) and the
focus ring is 3.65:1. The old panel's 12 px grey text was 3.57:1, below
AA.
- Tests: route tests (titles, a failing page, slug checking, an empty
index, 502s, and a stalled index or page actually timing out),
integration tests on the login page with the route mocked (titled and
untitled releases, link URLs, fewer than three, failure, empty, timeout,
long and markdown content), and helper unit tests. Default MSW handlers
added.
### Agents and large language models used
- Claude Code with Claude Opus 5.5
### Checklist 📋
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `/login` when logged out lists the 3 latest real releases from the
live changelog, each linking to its changelog page in a new tab
- [x] Tab reaches each item and shows a visible focus ring; the hover
state works
- [x] A changelog that never answers shows the loading placeholder, then
the failure card after 8 s. A 502 or an empty changelog shows the
failure card. The login form is unaffected in every case
- [x] Long, markdown and unusual-character entries wrap and stay
readable
- [x] `/signup` and mobile `/login` are pixel-identical to `dev`
- [x] ChangelogPopup, login, signup and changelog-route tests pass;
lint, format and types pass
#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2719c4c72f
commit
5bea25d453
20 files changed
+1159
-47
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { render, waitFor } from "@/tests/integrations/test-utils";
|
||||
import { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import LoginPage from "../page";
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { act, render, screen, within } from "@/tests/integrations/test-utils";
|
||||
import { CHANGELOG_TIMEOUT_MS } from "../components/LoginWhatsNew/helpers";
|
||||
import LoginPage from "../page";
|
||||
|
||||
const mockUseAuth = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/providers/onboarding/onboarding-provider", () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hooks/useAuth", () => ({
|
||||
useAuth: mockUseAuth,
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
login: vi.fn(),
|
||||
}));
|
||||
|
||||
const CHANGELOG_URL = "https://agpt.co/docs/platform/changelog/changelog";
|
||||
|
||||
function release(
|
||||
dateRange: string,
|
||||
slug: string,
|
||||
highlights: string,
|
||||
title: string | null = null,
|
||||
) {
|
||||
return {
|
||||
slug,
|
||||
dateRange,
|
||||
highlights,
|
||||
url: `${CHANGELOG_URL}/${slug}`,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
const RELEASES = [
|
||||
release(
|
||||
"October 8 – October 14",
|
||||
"october-8-october-14-2026",
|
||||
"Redesigned wallet, trial cancel keeps access, verified email at signup",
|
||||
"A clearer wallet, a kinder trial, and shorter replies from Otto",
|
||||
),
|
||||
release(
|
||||
"September 24 – September 30",
|
||||
"september-24-september-30-2026",
|
||||
"A bigger roster, Clay & Rock avatars",
|
||||
),
|
||||
release(
|
||||
"September 19",
|
||||
"september-19-2026",
|
||||
"Meet your Experts, routines",
|
||||
"Meet your Experts: hire a specialist and put it to work",
|
||||
),
|
||||
];
|
||||
|
||||
function serveReleases(releases: ReturnType<typeof release>[]) {
|
||||
server.use(
|
||||
http.get("/api/changelog/latest", () => HttpResponse.json({ releases })),
|
||||
);
|
||||
}
|
||||
|
||||
async function findWhatsNew() {
|
||||
return within(await screen.findByRole("region", { name: "What's new" }));
|
||||
}
|
||||
|
||||
describe("LoginPage What's new", () => {
|
||||
beforeEach(() => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: null,
|
||||
isUserLoading: false,
|
||||
isLoggedIn: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("lists the latest releases by title, each linking to its changelog entry", async () => {
|
||||
serveReleases(RELEASES);
|
||||
render(<LoginPage />);
|
||||
const panel = await findWhatsNew();
|
||||
|
||||
const latest = await panel.findByRole("link", {
|
||||
name: "A clearer wallet, a kinder trial, and shorter replies from Otto, October 8 – October 14, latest release (opens in a new tab)",
|
||||
});
|
||||
expect(latest.getAttribute("href")).toBe(
|
||||
`${CHANGELOG_URL}/october-8-october-14-2026`,
|
||||
);
|
||||
expect(latest.getAttribute("target")).toBe("_blank");
|
||||
expect(latest.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
expect(latest.getAttribute("aria-describedby")).not.toBeNull();
|
||||
expect(
|
||||
panel.getByText(
|
||||
"Redesigned wallet, trial cancel keeps access, verified email at signup",
|
||||
),
|
||||
).toBeDefined();
|
||||
|
||||
expect(
|
||||
panel
|
||||
.getByRole("link", {
|
||||
name: "A bigger roster, September 24 – September 30 (opens in a new tab)",
|
||||
})
|
||||
.getAttribute("href"),
|
||||
).toBe(`${CHANGELOG_URL}/september-24-september-30-2026`);
|
||||
expect(panel.getByText("Clay & Rock avatars")).toBeDefined();
|
||||
expect(
|
||||
panel
|
||||
.getByRole("link", {
|
||||
name: "Meet your Experts: hire a specialist and put it to work, September 19 (opens in a new tab)",
|
||||
})
|
||||
.getAttribute("href"),
|
||||
).toBe(`${CHANGELOG_URL}/september-19-2026`);
|
||||
expect(panel.getByText("Meet your Experts, routines")).toBeDefined();
|
||||
|
||||
expect(panel.getAllByRole("listitem")).toHaveLength(3);
|
||||
expect(
|
||||
panel
|
||||
.getByRole("link", { name: "Full changelog (opens in a new tab)" })
|
||||
.getAttribute("href"),
|
||||
).toBe(CHANGELOG_URL);
|
||||
});
|
||||
|
||||
test("shows the releases there are when fewer than three are published", async () => {
|
||||
serveReleases([release("September 19", "september-19-2026", "Solo")]);
|
||||
render(<LoginPage />);
|
||||
const panel = await findWhatsNew();
|
||||
|
||||
expect(
|
||||
await panel.findByRole("link", {
|
||||
name: "Solo, September 19, latest release (opens in a new tab)",
|
||||
}),
|
||||
).toBeDefined();
|
||||
expect(panel.getAllByRole("listitem")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["the changelog request fails", { error: "failed" }, 502],
|
||||
["the changelog has no releases", { releases: [] }, 200],
|
||||
])("links to the changelog when %s", async (_case, body, status) => {
|
||||
server.use(
|
||||
http.get("/api/changelog/latest", () =>
|
||||
HttpResponse.json(body, { status }),
|
||||
),
|
||||
);
|
||||
render(<LoginPage />);
|
||||
const panel = await findWhatsNew();
|
||||
|
||||
const fallback = await panel.findByRole("link", {
|
||||
name: /^See what we've shipped lately/,
|
||||
});
|
||||
expect(fallback.getAttribute("href")).toBe(CHANGELOG_URL);
|
||||
expect(fallback.getAttribute("target")).toBe("_blank");
|
||||
expect(panel.queryByRole("list")).toBeNull();
|
||||
expect(panel.queryByRole("link", { name: /^Full changelog/ })).toBeNull();
|
||||
expect(
|
||||
screen.getByText("Log in to your account to continue"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("stops waiting on a changelog that never answers", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
server.use(
|
||||
http.get("/api/changelog/latest", async ({ request }) => {
|
||||
await new Promise((resolve) =>
|
||||
request.signal.addEventListener("abort", resolve),
|
||||
);
|
||||
return HttpResponse.json({ releases: [] });
|
||||
}),
|
||||
);
|
||||
render(<LoginPage />);
|
||||
const panel = await findWhatsNew();
|
||||
expect(panel.getByTestId("whats-new-skeleton")).toBeDefined();
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(CHANGELOG_TIMEOUT_MS));
|
||||
|
||||
expect(
|
||||
await panel.findByRole("link", {
|
||||
name: /^See what we've shipped lately/,
|
||||
}),
|
||||
).toBeDefined();
|
||||
expect(panel.queryByTestId("whats-new-skeleton")).toBeNull();
|
||||
});
|
||||
|
||||
test("shows markdown, long and unusual highlights as readable text", async () => {
|
||||
const longDetail =
|
||||
"a remarkably long highlight that keeps going well past what fits on a single line of the panel";
|
||||
serveReleases([
|
||||
release(
|
||||
"June 18 – July 9",
|
||||
"june-18-july-9-2026",
|
||||
`🚀 **Bold launch** (beta, invite-only), [linked docs](https://example.com), \`code_name\` & more, ${longDetail}`,
|
||||
),
|
||||
]);
|
||||
render(<LoginPage />);
|
||||
const panel = await findWhatsNew();
|
||||
|
||||
expect(
|
||||
await panel.findByRole("link", {
|
||||
name: "🚀 Bold launch (beta, invite-only), June 18 – July 9, latest release (opens in a new tab)",
|
||||
}),
|
||||
).toBeDefined();
|
||||
const details = panel.getByText(
|
||||
`Linked docs, code_name & more, ${longDetail}`,
|
||||
);
|
||||
expect(details.getAttribute("title")).toBe(details.textContent);
|
||||
});
|
||||
});
|
||||
+4
-21
@@ -1,30 +1,13 @@
|
||||
import { AuthMarketingPanel } from "@/components/auth/AuthSplitLayout/AuthMarketingPanel";
|
||||
import { BrainIcon, GaugeIcon, Store01Icon } from "@hugeicons/core-free-icons";
|
||||
import { Icon } from "@/components/atoms/Icon/Icon";
|
||||
import { LoginWhatsNew } from "./LoginWhatsNew/LoginWhatsNew";
|
||||
|
||||
export function LoginMarketingPanel() {
|
||||
return (
|
||||
<AuthMarketingPanel
|
||||
headingLines={["Welcome back"]}
|
||||
description="Pick up where you left off. Your experts are waiting."
|
||||
itemsTitle="What's new"
|
||||
items={[
|
||||
{
|
||||
icon: <Icon icon={BrainIcon} size={20} />,
|
||||
title: "New memory upgrades",
|
||||
description: "Smarter experts with longer context windows.",
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Store01Icon} size={20} />,
|
||||
title: "Marketplace update",
|
||||
description: "Discover workflows shared by the community.",
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={GaugeIcon} size={20} />,
|
||||
title: "Performance boost",
|
||||
description: "Faster runs and reduced latency.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<LoginWhatsNew />
|
||||
</AuthMarketingPanel>
|
||||
);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { Icon } from "@/components/atoms/Icon/Icon";
|
||||
import { Link } from "@/components/atoms/Link/Link";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CHANGELOG_BASE_URL } from "@/components/molecules/ChangelogPopup/changelog-constants";
|
||||
import { ArrowUpRight01Icon } from "@hugeicons/core-free-icons";
|
||||
import { useId } from "react";
|
||||
import { ChangelogFallback } from "./components/ChangelogFallback";
|
||||
import { ReleaseList } from "./components/ReleaseList";
|
||||
import { ReleaseListSkeleton } from "./components/ReleaseListSkeleton";
|
||||
import { useLoginWhatsNew } from "./useLoginWhatsNew";
|
||||
|
||||
export function LoginWhatsNew() {
|
||||
const { releases, isLoading } = useLoginWhatsNew();
|
||||
const headingID = useId();
|
||||
const isUnavailable = !isLoading && releases.length === 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={headingID}
|
||||
aria-busy={isLoading}
|
||||
className="flex max-w-lg flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Text
|
||||
as="h2"
|
||||
id={headingID}
|
||||
variant="small-medium"
|
||||
tone="secondary"
|
||||
// TODO(tokens): no exact text token for tracking-[0.14em]
|
||||
className="uppercase tracking-[0.14em]"
|
||||
>
|
||||
What's new
|
||||
</Text>
|
||||
{isUnavailable ? null : (
|
||||
<Link
|
||||
href={CHANGELOG_BASE_URL}
|
||||
isExternal
|
||||
className="inline-flex items-center gap-1 text-caption leading-4 text-foreground-secondary hover:text-foreground-strong"
|
||||
>
|
||||
Full changelog
|
||||
<span className="sr-only">(opens in a new tab)</span>
|
||||
<Icon icon={ArrowUpRight01Icon} size={14} aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<WhatsNewBody isLoading={isLoading} releases={releases} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WhatsNewBody({
|
||||
isLoading,
|
||||
releases,
|
||||
}: ReturnType<typeof useLoginWhatsNew>) {
|
||||
if (isLoading) return <ReleaseListSkeleton />;
|
||||
if (releases.length === 0) return <ChangelogFallback />;
|
||||
return <ReleaseList releases={releases} />;
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { splitHighlights, toPlainText, toRelease } from "../helpers";
|
||||
|
||||
describe("toPlainText", () => {
|
||||
test.each([
|
||||
["**bold** and __strong__", "bold and strong"],
|
||||
["[docs](https://agpt.co/docs) link", "docs link"],
|
||||
["`code` and ~~gone~~", "code and gone"],
|
||||
["line<br/>break <em>tag</em>", "line break tag"],
|
||||
["cut off <script", "cut off"],
|
||||
["Clay <3 Rock", "Clay <3 Rock"],
|
||||
["kept <!-- hidden, note --> text", "kept text"],
|
||||
["cut <!-- never closed", "cut"],
|
||||
["[wiki](https://x.org/a_(b)) page", "wiki page"],
|
||||
["Clay & Rock <3", "Clay & Rock <3"],
|
||||
["keeps snake_case_names", "keeps snake_case_names"],
|
||||
[" spaced \n out ", "spaced out"],
|
||||
["emoji 🎉 stays", "emoji 🎉 stays"],
|
||||
])("%j reads as %j", (markdown, text) => {
|
||||
expect(toPlainText(markdown)).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitHighlights", () => {
|
||||
test("keeps commas inside brackets with their highlight", () => {
|
||||
expect(splitHighlights("New models (Claude, GPT), faster runs,,")).toEqual([
|
||||
"New models (Claude, GPT)",
|
||||
"faster runs",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toRelease", () => {
|
||||
const url =
|
||||
"https://agpt.co/docs/platform/changelog/changelog/september-19-2026";
|
||||
|
||||
test("heads the release with its title over all of its highlights", () => {
|
||||
expect(
|
||||
toRelease({
|
||||
slug: "september-19-2026",
|
||||
dateRange: "September 19",
|
||||
highlights: "meet your Experts, routines, [Clay, Rock](https://x.org)",
|
||||
url,
|
||||
title: "Meet your **Experts**: hire a specialist <!-- draft -->",
|
||||
}),
|
||||
).toEqual({
|
||||
slug: "september-19-2026",
|
||||
url,
|
||||
dateRange: "September 19",
|
||||
headline: "Meet your Experts: hire a specialist",
|
||||
details: "Meet your Experts, routines, Clay, Rock",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([null, "", " <!-- only a comment --> "])(
|
||||
"heads the release with its first highlight when the title is %j",
|
||||
(title) => {
|
||||
const release = toRelease({
|
||||
slug: "september-19-2026",
|
||||
dateRange: "September 19",
|
||||
highlights: "meet your Experts, routines, Skills marketplace",
|
||||
url,
|
||||
title,
|
||||
});
|
||||
expect(release.headline).toBe("Meet your Experts");
|
||||
expect(release.details).toBe("Routines, Skills marketplace");
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps a comma in link text and drops HTML comments", () => {
|
||||
const release = toRelease({
|
||||
slug: "s",
|
||||
dateRange: "May 1",
|
||||
highlights:
|
||||
"[Clay, Rock](https://agpt.co/x) avatars <!-- internal, unpublished -->, faster runs",
|
||||
url: "u",
|
||||
title: null,
|
||||
});
|
||||
expect(release.headline).toBe("Clay, Rock avatars");
|
||||
expect(release.details).toBe("Faster runs");
|
||||
});
|
||||
|
||||
test("keeps emphasis that spans highlights from leaving stray markers", () => {
|
||||
const release = toRelease({
|
||||
slug: "s",
|
||||
dateRange: "May 1",
|
||||
highlights: "**new agents, faster runs**, [docs, guides](https://x.org)",
|
||||
url: "u",
|
||||
title: null,
|
||||
});
|
||||
expect(release.headline).toBe("New agents");
|
||||
expect(release.details).toBe("Faster runs, docs, guides");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["iOS deep links", "iOS deep links"],
|
||||
["macOS app", "macOS app"],
|
||||
["e-mail digests", "e-mail digests"],
|
||||
["new models", "New models"],
|
||||
])("capitalises %j as %j", (highlights, headline) => {
|
||||
const release = toRelease({
|
||||
slug: "s",
|
||||
dateRange: "",
|
||||
highlights,
|
||||
url: "u",
|
||||
title: null,
|
||||
});
|
||||
expect(release.headline).toBe(headline);
|
||||
});
|
||||
|
||||
test("names a release with no readable highlights", () => {
|
||||
const release = toRelease({
|
||||
slug: "s",
|
||||
dateRange: "May 1",
|
||||
highlights: " , ",
|
||||
url: "u",
|
||||
title: null,
|
||||
});
|
||||
expect(release.headline).toBe("Release notes");
|
||||
expect(release.details).toBe("");
|
||||
});
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { Icon } from "@/components/atoms/Icon/Icon";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CHANGELOG_BASE_URL } from "@/components/molecules/ChangelogPopup/changelog-constants";
|
||||
import { ArrowUpRight01Icon, NewsIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
// Shown when the changelog can't be read: the section still leads somewhere.
|
||||
export function ChangelogFallback() {
|
||||
return (
|
||||
<a
|
||||
href={CHANGELOG_BASE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-4 rounded-2xl bg-surface p-4 transition-colors duration-150 smooth-shadow-ring-sm hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-info motion-reduce:transition-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent-surface text-accent-strong"
|
||||
>
|
||||
<Icon icon={NewsIcon} size={20} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Text as="span" variant="body-medium" tone="primary">
|
||||
See what we've shipped lately
|
||||
</Text>
|
||||
<Text as="span" variant="body" tone="secondary">
|
||||
Release notes for Otto, your Experts and the AutoGPT Platform
|
||||
<span className="sr-only"> (opens in a new tab)</span>
|
||||
</Text>
|
||||
</span>
|
||||
<Icon
|
||||
icon={ArrowUpRight01Icon}
|
||||
size={16}
|
||||
aria-hidden
|
||||
className="shrink-0 text-foreground-tertiary transition-[color,transform] duration-150 group-hover:-translate-y-px group-hover:translate-x-px group-hover:text-foreground-strong motion-reduce:transition-none motion-reduce:group-hover:transform-none"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { Icon } from "@/components/atoms/Icon/Icon";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUpRight01Icon } from "@hugeicons/core-free-icons";
|
||||
import { useId } from "react";
|
||||
import { getReleaseLabel, Release } from "../helpers";
|
||||
import {
|
||||
RELEASE_DETAILS_GAP_CLASS,
|
||||
RELEASE_DOT_COLUMN_CLASS,
|
||||
RELEASE_HEADLINE_GAP_CLASS,
|
||||
RELEASE_ROW_CLASS,
|
||||
} from "./releaseRowLayout";
|
||||
|
||||
// Text past this many characters may hit the line clamp, so it also gets a
|
||||
// tooltip with the whole of it.
|
||||
const CLAMP_HINT_CHARS = 100;
|
||||
|
||||
interface Props {
|
||||
release: Release;
|
||||
isLatest: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export function ReleaseItem({ release, isLatest, isLast }: Props) {
|
||||
const detailsID = useId();
|
||||
const { headline, details } = release;
|
||||
|
||||
return (
|
||||
<li className="relative">
|
||||
{isLast ? null : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -bottom-4 left-[1.15625rem] top-6 z-10 w-px bg-border-base"
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={release.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={getReleaseLabel(release, isLatest)}
|
||||
aria-describedby={details ? detailsID : undefined}
|
||||
className={cn(
|
||||
RELEASE_ROW_CLASS,
|
||||
"group rounded-xl transition-colors duration-150 hover:bg-surface-hover/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-info motion-reduce:transition-none",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={RELEASE_DOT_COLUMN_CLASS}>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
isLatest
|
||||
? "bg-brand ring-4 ring-accent-surface"
|
||||
: "bg-border-strong",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-2">
|
||||
<Text as="span" variant="small-medium-tight" tone="secondary">
|
||||
{release.dateRange}
|
||||
</Text>
|
||||
{isLatest ? (
|
||||
<span className="rounded-full bg-accent-surface px-2 py-px font-sans text-caption font-medium leading-4 text-accent-strong">
|
||||
Latest
|
||||
</span>
|
||||
) : null}
|
||||
<Icon
|
||||
icon={ArrowUpRight01Icon}
|
||||
size={16}
|
||||
aria-hidden
|
||||
className="ml-auto shrink-0 text-foreground-tertiary transition-[color,transform] duration-150 group-hover:-translate-y-px group-hover:translate-x-px group-hover:text-foreground-strong motion-reduce:transition-none motion-reduce:group-hover:transform-none"
|
||||
/>
|
||||
</span>
|
||||
<Text
|
||||
as="span"
|
||||
variant="body-medium"
|
||||
tone="primary"
|
||||
title={headline.length > CLAMP_HINT_CHARS ? headline : undefined}
|
||||
className={cn(
|
||||
RELEASE_HEADLINE_GAP_CLASS,
|
||||
"line-clamp-3 [overflow-wrap:anywhere]",
|
||||
)}
|
||||
>
|
||||
{headline}
|
||||
</Text>
|
||||
{details ? (
|
||||
<Text
|
||||
as="span"
|
||||
id={detailsID}
|
||||
variant="body-tight"
|
||||
tone="secondary"
|
||||
title={details.length > CLAMP_HINT_CHARS ? details : undefined}
|
||||
className={cn(
|
||||
RELEASE_DETAILS_GAP_CLASS,
|
||||
"line-clamp-3 [overflow-wrap:anywhere]",
|
||||
)}
|
||||
>
|
||||
{details}
|
||||
</Text>
|
||||
) : null}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Release } from "../helpers";
|
||||
import { ReleaseItem } from "./ReleaseItem";
|
||||
import { RELEASE_LIST_CLASS } from "./releaseRowLayout";
|
||||
|
||||
interface Props {
|
||||
releases: Release[];
|
||||
}
|
||||
|
||||
export function ReleaseList({ releases }: Props) {
|
||||
return (
|
||||
<ul className={RELEASE_LIST_CLASS}>
|
||||
{releases.map((release, index) => (
|
||||
<ReleaseItem
|
||||
key={release.slug}
|
||||
release={release}
|
||||
isLatest={index === 0}
|
||||
isLast={index === releases.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DATE_LINE_HEIGHT_CLASS,
|
||||
DETAILS_LINE_HEIGHT_CLASS,
|
||||
HEADLINE_LINE_HEIGHT_CLASS,
|
||||
LATEST_DATE_LINE_HEIGHT_CLASS,
|
||||
RELEASE_DETAILS_GAP_CLASS,
|
||||
RELEASE_DOT_COLUMN_CLASS,
|
||||
RELEASE_HEADLINE_GAP_CLASS,
|
||||
RELEASE_LIST_CLASS,
|
||||
RELEASE_ROW_CLASS,
|
||||
} from "./releaseRowLayout";
|
||||
|
||||
// Detail lines per row, as the published releases usually wrap in the panel
|
||||
// (every highlight, under a one-line title).
|
||||
const ROWS = [2, 2, 2];
|
||||
|
||||
// Built from the release row's own layout, line box for line box.
|
||||
export function ReleaseListSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="whats-new-skeleton"
|
||||
className={RELEASE_LIST_CLASS}
|
||||
>
|
||||
{ROWS.map((detailLines, row) => (
|
||||
<div key={row} className={RELEASE_ROW_CLASS}>
|
||||
<span className={RELEASE_DOT_COLUMN_CLASS}>
|
||||
<span className="size-2 rounded-full bg-surface-hover" />
|
||||
</span>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
row === 0
|
||||
? LATEST_DATE_LINE_HEIGHT_CLASS
|
||||
: DATE_LINE_HEIGHT_CLASS,
|
||||
)}
|
||||
>
|
||||
<Bar className="h-4 w-32" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
RELEASE_HEADLINE_GAP_CLASS,
|
||||
HEADLINE_LINE_HEIGHT_CLASS,
|
||||
"flex items-center",
|
||||
)}
|
||||
>
|
||||
<Bar className="h-3.5 w-1/2" />
|
||||
</div>
|
||||
<div className={cn(RELEASE_DETAILS_GAP_CLASS, "flex flex-col")}>
|
||||
{Array.from({ length: detailLines }, (_, line) => (
|
||||
<div
|
||||
key={line}
|
||||
className={cn(DETAILS_LINE_HEIGHT_CLASS, "flex items-center")}
|
||||
>
|
||||
<Bar
|
||||
className={cn(
|
||||
"h-3",
|
||||
line === detailLines - 1 ? "w-2/3" : "w-full",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bar({ className }: { className: string }) {
|
||||
return (
|
||||
<Skeleton
|
||||
className={cn(className, "rounded-full motion-reduce:animate-none")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// The release list and its loading skeleton share this geometry, so the
|
||||
// panel keeps its height when the releases arrive.
|
||||
|
||||
export const RELEASE_LIST_CLASS =
|
||||
"flex flex-col gap-1 rounded-2xl bg-surface p-1.5 smooth-shadow-ring-sm";
|
||||
|
||||
export const RELEASE_ROW_CLASS = "flex gap-3 px-3.5 py-2.5";
|
||||
|
||||
export const RELEASE_DOT_COLUMN_CLASS =
|
||||
"flex w-2.5 shrink-0 justify-center pt-1";
|
||||
|
||||
// Spacing above the headline and the details.
|
||||
export const RELEASE_HEADLINE_GAP_CLASS = "mt-1";
|
||||
export const RELEASE_DETAILS_GAP_CLASS = "mt-0.5";
|
||||
|
||||
// Line boxes of the Text variants the row uses: the date line
|
||||
// (small-medium-tight, 18px with the "Latest" pill), the headline
|
||||
// (body-medium) and one line of details (body-tight).
|
||||
export const DATE_LINE_HEIGHT_CLASS = "h-4";
|
||||
export const LATEST_DATE_LINE_HEIGHT_CLASS = "h-[1.125rem]";
|
||||
export const HEADLINE_LINE_HEIGHT_CLASS = "h-[1.375rem]";
|
||||
export const DETAILS_LINE_HEIGHT_CLASS = "h-5";
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import type { LatestRelease } from "@/app/api/changelog/helpers";
|
||||
|
||||
const LATEST_RELEASES_URL = "/api/changelog/latest";
|
||||
|
||||
// Past this the panel links to the changelog instead of waiting on it. The
|
||||
// route's own upstream budget is at most half of this, leaving the rest for
|
||||
// the trip from a slow connection to the route and back.
|
||||
export const CHANGELOG_TIMEOUT_MS = 8000;
|
||||
|
||||
export interface Release {
|
||||
slug: string;
|
||||
url: string;
|
||||
dateRange: string;
|
||||
headline: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
// Link URLs may hold one level of brackets: (https://x.org/a_(b)).
|
||||
const MARKDOWN_LINK = /!?\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)/g;
|
||||
|
||||
// Stands in for a comma inside link text while the highlights are split.
|
||||
const LINK_COMMA = "\uE000";
|
||||
|
||||
// The newest releases, each with its page title when the server could read it.
|
||||
export async function fetchLatestReleases(
|
||||
signal: AbortSignal,
|
||||
): Promise<LatestRelease[]> {
|
||||
const response = await fetch(LATEST_RELEASES_URL, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Changelog request failed with ${response.status}`);
|
||||
}
|
||||
const { releases } = (await response.json()) as {
|
||||
releases: LatestRelease[];
|
||||
};
|
||||
return releases;
|
||||
}
|
||||
|
||||
// A release heads with its page title over all of its highlights. Without a
|
||||
// title, the first highlight heads it and the rest are its details.
|
||||
export function toRelease(release: LatestRelease): Release {
|
||||
const title = toPlainText(release.title ?? "");
|
||||
const [first = "", ...rest] = toHighlights(release.highlights);
|
||||
const [headline, details] = title
|
||||
? [title, [first, ...rest]]
|
||||
: [capitalize(first) || "Release notes", rest];
|
||||
return {
|
||||
slug: release.slug,
|
||||
url: release.url,
|
||||
dateRange: toPlainText(release.dateRange),
|
||||
headline,
|
||||
details: capitalize(details.filter(Boolean).join(", ")),
|
||||
};
|
||||
}
|
||||
|
||||
export function getReleaseLabel(release: Release, isLatest: boolean) {
|
||||
const latest = isLatest ? ", latest release" : "";
|
||||
return `${release.headline}, ${release.dateRange}${latest} (opens in a new tab)`;
|
||||
}
|
||||
|
||||
export function toPlainText(markdown: string): string {
|
||||
return (
|
||||
stripComments(markdown)
|
||||
.replace(MARKDOWN_LINK, "$1")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
// An unclosed tag ("<script" at the end) goes too, not just "<b>…</b>".
|
||||
.replace(/<\/?[a-z][^>]*(?:>|$)/gi, "")
|
||||
.replace(/(\*\*|__|~~|`|\*)(\S(?:.*?\S)?)\1/g, "$2")
|
||||
.replace(/\\([\\`*_{}[\]()#+\-.!|~])/g, "$1")
|
||||
.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, decodeEntity)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// The highlights are one comma-separated markdown cell. Commas in link text
|
||||
// ("[Clay, Rock](…)") are set aside first so they don't split a highlight.
|
||||
function toHighlights(markdown: string) {
|
||||
const highlights = markdown.replace(MARKDOWN_LINK, (link) =>
|
||||
link.replaceAll(",", LINK_COMMA),
|
||||
);
|
||||
return splitHighlights(toPlainText(highlights)).map((highlight) =>
|
||||
highlight.replaceAll(LINK_COMMA, ","),
|
||||
);
|
||||
}
|
||||
|
||||
// A comma inside brackets stays in its highlight: "New models (Claude, GPT)".
|
||||
export function splitHighlights(text: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const char of text) {
|
||||
if (char === "(" || char === "[") depth += 1;
|
||||
if ((char === ")" || char === "]") && depth > 0) depth -= 1;
|
||||
if (char === "," && depth === 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.map((part) => part.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
"#39": "'",
|
||||
nbsp: " ",
|
||||
};
|
||||
|
||||
function decodeEntity(_match: string, name: string) {
|
||||
return ENTITIES[name];
|
||||
}
|
||||
|
||||
// An HTML comment, closed or cut off, never reaches the page.
|
||||
function stripComments(text: string) {
|
||||
return text.replace(/<!--[\s\S]*?(?:-->|$)/g, "");
|
||||
}
|
||||
|
||||
// Only an all-lowercase first word is capitalised, so "iOS" and "macOS"
|
||||
// keep their casing.
|
||||
function capitalize(text: string) {
|
||||
if (!/^[a-z]+(?![\w-])/.test(text)) return text;
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CHANGELOG_TIMEOUT_MS,
|
||||
fetchLatestReleases,
|
||||
toRelease,
|
||||
} from "./helpers";
|
||||
|
||||
export function useLoginWhatsNew() {
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: ["changelog", "latest"],
|
||||
queryFn: ({ signal }) => fetchWithTimeout(signal),
|
||||
select: (releases) => releases.map(toRelease),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
// Offline, the default "online" mode pauses the query before the timeout
|
||||
// starts, leaving the placeholder up. "always" fails fast to the fallback.
|
||||
networkMode: "always",
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
releases: data ?? [],
|
||||
isLoading: isPending,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(signal: AbortSignal) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), CHANGELOG_TIMEOUT_MS);
|
||||
const cancel = () => controller.abort();
|
||||
signal.addEventListener("abort", cancel);
|
||||
try {
|
||||
return await fetchLatestReleases(controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ChangelogEntry } from "@/components/molecules/ChangelogPopup/helpers";
|
||||
|
||||
const PUBLISHED_CHANGELOG = "https://agpt.co/docs/platform/changelog/changelog";
|
||||
|
||||
export const PUBLISHED_CHANGELOG_INDEX = `${PUBLISHED_CHANGELOG}.md`;
|
||||
|
||||
export const CHANGELOG_FETCH_INIT = {
|
||||
headers: { Accept: "text/plain, text/markdown, */*" },
|
||||
next: { revalidate: 3600 },
|
||||
};
|
||||
|
||||
export const CHANGELOG_CACHE_CONTROL = "public, max-age=300, s-maxage=3600";
|
||||
|
||||
// The latest-releases route fetches the index, then the pages in parallel.
|
||||
// Together these take at most half the login page's own timeout (a test
|
||||
// holds it), so the route answers well before the page gives up on it.
|
||||
export const INDEX_FETCH_TIMEOUT_MS = 2000;
|
||||
|
||||
// A slow release page costs only its title, never the whole list.
|
||||
export const RELEASE_PAGE_TIMEOUT_MS = 2000;
|
||||
|
||||
// Release page URLs are built from slugs, so only plain kebab-case passes.
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
// The page's first top-level heading: "# A clearer wallet, …".
|
||||
const TITLE_PATTERN = /^#[ \t]+(.+?)[ \t#]*$/m;
|
||||
|
||||
export interface LatestRelease extends ChangelogEntry {
|
||||
// The release page's own title, or null when it couldn't be read.
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export function isSafeSlug(slug: string) {
|
||||
return slug.length <= 100 && SLUG_PATTERN.test(slug);
|
||||
}
|
||||
|
||||
export function getReleaseTitle(markdown: string): string | null {
|
||||
return TITLE_PATTERN.exec(markdown)?.[1].trim() || null;
|
||||
}
|
||||
|
||||
// Resolves to null on any failure: the release then heads with a highlight.
|
||||
export async function fetchReleaseTitle(slug: string): Promise<string | null> {
|
||||
if (!isSafeSlug(slug)) return null;
|
||||
try {
|
||||
const page = await fetch(`${PUBLISHED_CHANGELOG}/${slug}.md`, {
|
||||
...CHANGELOG_FETCH_INIT,
|
||||
signal: AbortSignal.timeout(RELEASE_PAGE_TIMEOUT_MS),
|
||||
});
|
||||
if (!page.ok) return null;
|
||||
return getReleaseTitle(await page.text());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CHANGELOG_TIMEOUT_MS } from "@/app/(no-navbar)/login/components/LoginWhatsNew/helpers";
|
||||
import { INDEX_FETCH_TIMEOUT_MS, RELEASE_PAGE_TIMEOUT_MS } from "../../helpers";
|
||||
import { GET } from "../route";
|
||||
|
||||
const BASE = "https://agpt.co/docs/platform/changelog/changelog";
|
||||
|
||||
function row(slug: string, highlights = "Some news") {
|
||||
return `| [${slug}](/docs/platform/changelog/changelog/${slug}.md) | ${highlights} |`;
|
||||
}
|
||||
|
||||
function page(title: string) {
|
||||
return `> For the complete documentation index, see [llms.txt](https://agpt.co/docs/llms.txt).\n\n# ${title}\n\n*October 8 – October 14, 2026*\n\n## A section\n`;
|
||||
}
|
||||
|
||||
// An upstream that never answers, only giving up when the request is aborted.
|
||||
const STALL = Symbol("stall");
|
||||
|
||||
function serve(pages: Record<string, Response | Error | typeof STALL>) {
|
||||
return vi.spyOn(global, "fetch").mockImplementation(async (input, init) => {
|
||||
const reply = pages[String(input)];
|
||||
if (reply === undefined) throw new Error(`Unexpected fetch ${input}`);
|
||||
if (reply === STALL) {
|
||||
return new Promise<Response>((_, reject) =>
|
||||
init?.signal?.addEventListener("abort", () =>
|
||||
reject(init.signal?.reason),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (reply instanceof Error) throw reply;
|
||||
return reply.clone();
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/changelog/latest", () => {
|
||||
it("returns the three newest releases with their page titles", async () => {
|
||||
const fetchSpy = serve({
|
||||
[`${BASE}.md`]: new Response(
|
||||
[
|
||||
row("c-2026", "Wallet, trial"),
|
||||
row("b-2026"),
|
||||
row("a-2026"),
|
||||
row("old-2026"),
|
||||
].join("\n"),
|
||||
),
|
||||
[`${BASE}/c-2026.md`]: new Response(page("A clearer wallet")),
|
||||
[`${BASE}/b-2026.md`]: new Response(page("A bigger roster")),
|
||||
[`${BASE}/a-2026.md`]: new Response(page("Meet your Experts")),
|
||||
});
|
||||
|
||||
const res = await GET();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Cache-Control")).toBe(
|
||||
"public, max-age=300, s-maxage=3600",
|
||||
);
|
||||
expect(await res.json()).toEqual({
|
||||
releases: [
|
||||
{
|
||||
slug: "c-2026",
|
||||
dateRange: "c-2026",
|
||||
highlights: "Wallet, trial",
|
||||
url: `${BASE}/c-2026`,
|
||||
title: "A clearer wallet",
|
||||
},
|
||||
expect.objectContaining({ slug: "b-2026", title: "A bigger roster" }),
|
||||
expect.objectContaining({ slug: "a-2026", title: "Meet your Experts" }),
|
||||
],
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${BASE}/c-2026.md`,
|
||||
expect.objectContaining({ next: { revalidate: 3600 } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves half the login page's timeout for the network", () => {
|
||||
expect(
|
||||
INDEX_FETCH_TIMEOUT_MS + RELEASE_PAGE_TIMEOUT_MS,
|
||||
).toBeLessThanOrEqual(CHANGELOG_TIMEOUT_MS / 2);
|
||||
});
|
||||
|
||||
describe("when upstream stalls", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// AbortSignal.timeout runs on Node's own clock; drive it from the fake one.
|
||||
vi.spyOn(AbortSignal, "timeout").mockImplementation((ms) => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(
|
||||
() => controller.abort(new DOMException("timed out", "TimeoutError")),
|
||||
ms,
|
||||
);
|
||||
return controller.signal;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("gives up on an index that never answers", async () => {
|
||||
serve({ [`${BASE}.md`]: STALL });
|
||||
|
||||
const pending = GET();
|
||||
await vi.advanceTimersByTimeAsync(INDEX_FETCH_TIMEOUT_MS);
|
||||
const res = await pending;
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it("drops only the title of a page that never answers", async () => {
|
||||
serve({
|
||||
[`${BASE}.md`]: new Response(
|
||||
[row("c-2026"), row("b-2026"), row("a-2026")].join("\n"),
|
||||
),
|
||||
[`${BASE}/c-2026.md`]: STALL,
|
||||
[`${BASE}/b-2026.md`]: new Response(page("A bigger roster")),
|
||||
[`${BASE}/a-2026.md`]: new Response(page("Meet your Experts")),
|
||||
});
|
||||
|
||||
const pending = GET();
|
||||
await vi.advanceTimersByTimeAsync(RELEASE_PAGE_TIMEOUT_MS);
|
||||
const res = await pending;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const { releases } = await res.json();
|
||||
expect(
|
||||
releases.map((release: { title: string | null }) => release.title),
|
||||
).toEqual([null, "A bigger roster", "Meet your Experts"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves out only the title of a release page that can't be read", async () => {
|
||||
serve({
|
||||
[`${BASE}.md`]: new Response(
|
||||
[row("c-2026"), row("b-2026"), row("a-2026")].join("\n"),
|
||||
),
|
||||
[`${BASE}/c-2026.md`]: new Response("missing", { status: 404 }),
|
||||
[`${BASE}/b-2026.md`]: new Error("network"),
|
||||
[`${BASE}/a-2026.md`]: new Response("No heading here, ## or #nope"),
|
||||
});
|
||||
|
||||
const res = await GET();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const { releases } = await res.json();
|
||||
expect(
|
||||
releases.map((release: { title: unknown }) => release.title),
|
||||
).toEqual([null, null, null]);
|
||||
});
|
||||
|
||||
it("never builds a release page URL from a malformed slug", async () => {
|
||||
const fetchSpy = serve({
|
||||
[`${BASE}.md`]: new Response([row("a--b"), row("-x")].join("\n")),
|
||||
});
|
||||
|
||||
const res = await GET();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const { releases } = await res.json();
|
||||
expect(releases).toEqual([
|
||||
expect.objectContaining({ slug: "a--b", title: null }),
|
||||
expect.objectContaining({ slug: "-x", title: null }),
|
||||
]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns no releases when the index lists none", async () => {
|
||||
serve({ [`${BASE}.md`]: new Response("# What's New\n\nNothing yet.") });
|
||||
|
||||
expect(await (await GET()).json()).toEqual({ releases: [] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the index is unavailable", new Response("nope", { status: 503 })],
|
||||
["the index fetch throws", new Error("network")],
|
||||
])("returns 502 when %s", async (_case, reply) => {
|
||||
serve({ [`${BASE}.md`]: reply });
|
||||
|
||||
expect((await GET()).status).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { parseChangelogIndex } from "@/components/molecules/ChangelogPopup/helpers";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CHANGELOG_CACHE_CONTROL,
|
||||
CHANGELOG_FETCH_INIT,
|
||||
fetchReleaseTitle,
|
||||
INDEX_FETCH_TIMEOUT_MS,
|
||||
LatestRelease,
|
||||
PUBLISHED_CHANGELOG_INDEX,
|
||||
} from "../helpers";
|
||||
|
||||
const LATEST_RELEASE_COUNT = 3;
|
||||
|
||||
// The newest releases with their page titles, for the logged-out login page.
|
||||
export async function GET() {
|
||||
try {
|
||||
const upstream = await fetch(PUBLISHED_CHANGELOG_INDEX, {
|
||||
...CHANGELOG_FETCH_INIT,
|
||||
signal: AbortSignal.timeout(INDEX_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!upstream.ok) return upstreamFailed();
|
||||
|
||||
const entries = parseChangelogIndex(await upstream.text()).slice(
|
||||
0,
|
||||
LATEST_RELEASE_COUNT,
|
||||
);
|
||||
const releases: LatestRelease[] = await Promise.all(
|
||||
entries.map(async (entry) => ({
|
||||
...entry,
|
||||
title: await fetchReleaseTitle(entry.slug),
|
||||
})),
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ releases },
|
||||
{ headers: { "Cache-Control": CHANGELOG_CACHE_CONTROL } },
|
||||
);
|
||||
} catch {
|
||||
return upstreamFailed();
|
||||
}
|
||||
}
|
||||
|
||||
function upstreamFailed() {
|
||||
return NextResponse.json(
|
||||
{ error: "Upstream changelog fetch failed" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PUBLISHED_CHANGELOG_INDEX =
|
||||
"https://agpt.co/docs/platform/changelog/changelog.md";
|
||||
import {
|
||||
CHANGELOG_CACHE_CONTROL,
|
||||
CHANGELOG_FETCH_INIT,
|
||||
PUBLISHED_CHANGELOG_INDEX,
|
||||
} from "./helpers";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const upstream = await fetch(PUBLISHED_CHANGELOG_INDEX, {
|
||||
headers: { Accept: "text/plain, text/markdown, */*" },
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
const upstream = await fetch(
|
||||
PUBLISHED_CHANGELOG_INDEX,
|
||||
CHANGELOG_FETCH_INIT,
|
||||
);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
@@ -22,7 +24,7 @@ export async function GET() {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=300, s-maxage=3600",
|
||||
"Cache-Control": CHANGELOG_CACHE_CONTROL,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
|
||||
+6
-3
@@ -8,16 +8,19 @@ interface Props {
|
||||
headingLines: ReactNode[];
|
||||
description?: string;
|
||||
itemsTitle?: string;
|
||||
items: FeatureItem[];
|
||||
items?: FeatureItem[];
|
||||
footerText?: string;
|
||||
/** Shown under the header in place of the feature list. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthMarketingPanel({
|
||||
headingLines,
|
||||
description,
|
||||
itemsTitle,
|
||||
items,
|
||||
items = [],
|
||||
footerText = "Connects with the tools you already use",
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col justify-between gap-12 px-12 pb-12 pt-10 xl:px-16 xl:pb-16">
|
||||
@@ -26,7 +29,7 @@ export function AuthMarketingPanel({
|
||||
headingLines={headingLines}
|
||||
description={description}
|
||||
/>
|
||||
<FeatureList itemsTitle={itemsTitle} items={items} />
|
||||
{children ?? <FeatureList itemsTitle={itemsTitle} items={items} />}
|
||||
</div>
|
||||
<MarketingFooter footerText={footerText} />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { CHANGELOG_BASE_URL } from "./changelog-constants";
|
||||
import {
|
||||
CHANGELOG_BASE_URL,
|
||||
CHANGELOG_INDEX_MD_URL,
|
||||
} from "./changelog-constants";
|
||||
|
||||
export interface ChangelogEntry {
|
||||
slug: string;
|
||||
@@ -27,6 +30,18 @@ export function parseChangelogIndex(md: string): ChangelogEntry[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
// The published releases, newest first. Rejects when the request fails, so
|
||||
// each caller decides what an unavailable changelog looks like.
|
||||
export async function fetchChangelogEntries(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChangelogEntry[]> {
|
||||
const response = await fetch(CHANGELOG_INDEX_MD_URL, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Changelog request failed with ${response.status}`);
|
||||
}
|
||||
return parseChangelogIndex(await response.text());
|
||||
}
|
||||
|
||||
// A release the account is newer than holds nothing new for that user.
|
||||
export function isReleaseBeforeAccount(
|
||||
entry: ChangelogEntry,
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
import { useAuthStore } from "@/lib/auth/hooks/useAuthStore";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AUTO_DISMISS_MS,
|
||||
CHANGELOG_INDEX_MD_URL,
|
||||
STORAGE_KEY,
|
||||
} from "./changelog-constants";
|
||||
import { AUTO_DISMISS_MS, STORAGE_KEY } from "./changelog-constants";
|
||||
import {
|
||||
ChangelogEntry,
|
||||
fetchChangelogEntries,
|
||||
isReleaseBeforeAccount,
|
||||
parseChangelogIndex,
|
||||
} from "./helpers";
|
||||
|
||||
export function useChangelog() {
|
||||
@@ -90,13 +86,9 @@ export function useChangelog() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch(CHANGELOG_INDEX_MD_URL)
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.then((md) => {
|
||||
if (cancelled || !md) return;
|
||||
|
||||
const entries = parseChangelogIndex(md);
|
||||
if (entries.length === 0) return;
|
||||
fetchChangelogEntries()
|
||||
.then((entries) => {
|
||||
if (cancelled || entries.length === 0) return;
|
||||
|
||||
setLatestEntry(entries[0]);
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ export const mockHandlers = [
|
||||
http.get("/api/proxy/api/credits/pro-activation/current", () =>
|
||||
HttpResponse.json({ detail: "No activation in progress" }, { status: 404 }),
|
||||
),
|
||||
// No published releases unless a test provides some.
|
||||
http.get("/api/changelog", () => HttpResponse.text("")),
|
||||
http.get("/api/changelog/latest", () => HttpResponse.json({ releases: [] })),
|
||||
// No flag vendor unless a test provides one, so every flag serves its default.
|
||||
getGetFeatureFlagsMockHandler({ flags: {}, configured: false }),
|
||||
...getAdminMock(),
|
||||
|
||||
Reference in new issue
Block a user