Compare commits

...

6 Commits

Author SHA1 Message Date
Nicholas Tindle
19b86fe80f Merge branch 'dev' into feat/changelog-popup 2026-04-07 13:02:27 -05:00
Nicholas Tindle
9ec8419cc8 fix(changelog): address all PR review feedback
Fixes every actionable item from CodeRabbit, Bugbot, and manual reviews:

Bugs fixed:
- Auto-dismiss timer no longer closes the full modal unexpectedly
- Dismiss-while-hovering no longer cancels the fade timer via mouseleave
- Race condition in markdown fetch resolved with AbortController
- Added .catch() to all fetch calls to prevent unhandled rejections

Convention compliance:
- Delete barrel index.ts, import ChangelogPopup directly
- Remove UseChangelogReturn interface, let TypeScript infer
- Replace all useCallback/useMemo with plain functions
- Remove all eslint-disable suppressors
- Replace hardcoded Tailwind colors with design tokens
- Remove unnecessary JSX comments

Architecture:
- Extract ChangelogModal into components/ChangelogModal.tsx
- Extract ChangelogMarkdownContent into its own file
- Lazy-load react-markdown and remark-gfm via next/dynamic
- Add mobile entry navigation via select dropdown in top bar
2026-04-03 12:29:39 +00:00
ntindle
6f4fc1af22 refactor(changelog): fetch .md endpoints and render markdown natively
Replace HTML scraping and iframe embed with clean .md fetching:

- Fetch changelog index from .md endpoint (clean markdown table)
- Parse entry slugs, date ranges, and highlights from the table
- Fetch individual entry .md on demand when user clicks 'Read more'
- Render entry content natively with react-markdown + remark-gfm
  (already a project dependency)
- Strip GitBook-specific directives before rendering
- No iframe, no HTML parsing, no hardcoded data
- New entries auto-appear when docs team updates the changelog
2026-04-03 10:48:29 +00:00
ntindle
7ff71050db refactor(changelog): fetch & parse docs page at runtime + iframe embed
Replaces hardcoded changelog data with runtime fetching and parsing
of the GitBook docs page. The full changelog view now uses an iframe
embed of the docs site instead of re-rendering entries in React.

Changes:
- DELETE changelog-data.ts (no more hardcoded entries)
- ADD changelog-constants.ts (URLs + config)
- REWRITE useChangelog.ts:
  - Fetches https://agpt.co/docs/platform/changelog/changelog at runtime
  - Parses entry slugs, titles, and URLs from the HTML
  - Derives human-readable date ranges from URL slugs
  - Same auto-dismiss + localStorage seen-tracking behavior
- REWRITE ChangelogPopup.tsx:
  - Toast shows parsed title + date from live docs
  - 'Read more' opens iframe embed of the docs page
  - Sidebar lists all entries, clicking loads in the iframe
  - No hardcoded content to maintain — new entries appear automatically
2026-04-03 10:25:52 +00:00
Nicholas Tindle
ee6a22bfd8 feat(frontend): add Linear-style changelog popup
Adds a changelog notification popup that displays the latest platform
updates to users in a Linear-inspired UI.

Features:
- Bottom-right slide-in popup shows on page load for new releases
- Auto-fades after 8 seconds if not interacted with
- Hovering pauses the auto-dismiss timer
- 'View all updates' opens a full scrollable changelog modal
- Each entry links to the full docs page on agpt.co
- Tracks last-seen version in localStorage to avoid repeat popups
- Follows existing component patterns (atoms/molecules architecture)
- Uses existing design tokens and Phosphor icons

Components:
- ChangelogPopup.tsx - Main popup + full modal view
- useChangelog.ts - State management hook with auto-dismiss logic
- changelog-data.ts - Typed changelog entries from docs
- index.ts - Barrel export

Data sourced from: https://agpt.co/docs/platform/changelog/changelog
2026-04-02 14:18:43 +00:00
Zamil Majdy
1750c833ee fix(frontend): upgrade Docker Node.js from v21 (EOL) to v22 LTS (#12561)
## Summary
Upgrade the frontend **Docker image** from **Node.js v21** (EOL since
June 2024) to **Node.js v22 LTS** (supported through April 2027).

> **Scope:** This only affects the **Dockerfile** used for local
development (`docker compose`) and CI. It does **not** affect Vercel
(which manages its own Node.js runtime) or Kubernetes (the frontend Helm
chart was removed in Dec 2025 — the frontend is deployed exclusively via
Vercel).

## Why
- Node v21.7.3 has a **known TransformStream race condition bug**
causing `TypeError: controller[kState].transformAlgorithm is not a
function` — this is
[BUILDER-3KF](https://significant-gravitas.sentry.io/issues/BUILDER-3KF)
with **567,000+ Sentry events**
- The error is entirely in Node.js internals
(`node:internal/webstreams/transformstream`), zero first-party code
- Node 21 is **not an LTS release** and has been EOL since June 2024
- `package.json` already declares `"engines": { "node": "22.x" }` — the
Dockerfile was inconsistent
- Node 22.x LTS (v22.22.1) fixes the TransformStream bug
- Next.js 15.4.x requires Node 18.18+, so Node 22 is fully compatible

## Changes
- `autogpt_platform/frontend/Dockerfile`: `node:21-alpine` →
`node:22.22-alpine3.23` (both `base` and `prod` stages)

## Test plan
- [ ] Verify frontend Docker image builds successfully via `docker
compose`
- [ ] Verify frontend starts and serves pages correctly in local Docker
environment
- [ ] Monitor Sentry for BUILDER-3KF — should drop to zero for
Docker-based runs
2026-03-27 13:11:23 +07:00
6 changed files with 541 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import "./globals.css";
import { Providers } from "@/app/providers";
import { CookieConsentBanner } from "@/components/molecules/CookieConsentBanner/CookieConsentBanner";
import { ErrorBoundary } from "@/components/molecules/ErrorBoundary/ErrorBoundary";
import { ChangelogPopup } from "@/components/molecules/ChangelogPopup/ChangelogPopup";
import TallyPopupSimple from "@/components/molecules/TallyPoup/TallyPopup";
import { Toaster } from "@/components/molecules/Toast/toaster";
import { SetupAnalytics } from "@/services/analytics";
@@ -65,6 +66,7 @@ export default async function RootLayout({
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch">
{children}
<TallyPopupSimple />
<ChangelogPopup />
<VercelAnalyticsWrapper />
{/* React Query DevTools is only available in development */}

View File

@@ -0,0 +1,109 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { ArrowRight, ArrowSquareOut, Sparkle, X } from "@phosphor-icons/react";
import { ChangelogModal } from "./components/ChangelogModal";
import { useChangelog } from "./useChangelog";
export function ChangelogPopup() {
const {
isVisible,
latestEntry,
allEntries,
isFading,
dismiss,
pauseAutoDismiss,
resumeAutoDismiss,
showFullChangelog,
openFullChangelog,
closeFullChangelog,
selectedEntry,
selectEntry,
entryMarkdown,
isLoadingMarkdown,
} = useChangelog();
if (showFullChangelog) {
return (
<ChangelogModal
entries={allEntries}
selectedEntry={selectedEntry}
entryMarkdown={entryMarkdown}
isLoadingMarkdown={isLoadingMarkdown}
onSelectEntry={selectEntry}
onClose={closeFullChangelog}
/>
);
}
if (!isVisible || !latestEntry) return null;
return (
<div
className={`fixed bottom-6 right-6 z-50 w-[400px] max-w-[calc(100vw-2rem)] transition-all duration-500 ease-out ${
isFading
? "pointer-events-none translate-y-2 opacity-0"
: "translate-y-0 opacity-100"
}`}
onMouseEnter={pauseAutoDismiss}
onMouseLeave={resumeAutoDismiss}
role="dialog"
aria-label="What's new"
>
<div className="overflow-hidden rounded-xl border border-border bg-background shadow-2xl shadow-black/10">
<div className="bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 px-5 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkle className="h-4 w-4 text-white/90" weight="fill" />
<Text
variant="body-medium"
as="span"
className="text-sm font-semibold text-white"
>
What&apos;s New
</Text>
</div>
<button
onClick={dismiss}
className="rounded-md p-0.5 text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Dismiss changelog"
>
<X className="h-4 w-4" weight="bold" />
</button>
</div>
</div>
<div className="px-5 py-4">
<Text
variant="body-medium"
className="text-[15px] font-semibold leading-snug text-foreground"
>
{latestEntry.highlights}
</Text>
<Text variant="body" className="mt-1 text-xs text-muted-foreground">
{latestEntry.dateRange}
</Text>
</div>
<div className="flex items-center justify-between border-t border-border bg-secondary/50 px-5 py-2.5">
<button
onClick={() => openFullChangelog(latestEntry)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Read more
<ArrowRight className="h-3 w-3" />
</button>
<a
href={latestEntry.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs font-medium text-accent transition-colors hover:text-accent/80"
>
Open in docs
<ArrowSquareOut className="h-3 w-3" />
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export const CHANGELOG_BASE_URL =
"https://agpt.co/docs/platform/changelog/changelog";
export const CHANGELOG_INDEX_MD_URL = `${CHANGELOG_BASE_URL}.md`;
export const STORAGE_KEY = "autogpt-changelog-last-seen";
export const AUTO_DISMISS_MS = 8000;

View File

@@ -0,0 +1,36 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function ChangelogMarkdownContent({ markdown }: { markdown: string }) {
return (
<ReactMarkdown
className="prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-foreground prose-img:rounded-lg prose-img:shadow-md"
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, href, ...props }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
),
img: ({ src, alt, ...props }) => (
<img
src={src}
alt={alt || ""}
className="my-4 h-auto max-w-full rounded-lg shadow-md"
loading="lazy"
{...(props as React.ImgHTMLAttributes<HTMLImageElement>)}
/>
),
}}
>
{markdown}
</ReactMarkdown>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { ArrowSquareOut, Sparkle, X } from "@phosphor-icons/react";
import dynamic from "next/dynamic";
import { CHANGELOG_BASE_URL } from "../changelog-constants";
import { ChangelogEntry } from "../useChangelog";
const ChangelogMarkdownContent = dynamic(
() =>
import("./ChangelogMarkdownContent").then(
(mod) => mod.ChangelogMarkdownContent,
),
{
loading: () => (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-accent" />
Loading
</div>
),
},
);
interface ChangelogModalProps {
entries: ChangelogEntry[];
selectedEntry: ChangelogEntry | null;
entryMarkdown: string | null;
isLoadingMarkdown: boolean;
onSelectEntry: (entry: ChangelogEntry) => void;
onClose: () => void;
}
export function ChangelogModal({
entries,
selectedEntry,
entryMarkdown,
isLoadingMarkdown,
onSelectEntry,
onClose,
}: ChangelogModalProps) {
return (
<>
<div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
<div className="fixed inset-4 z-50 flex overflow-hidden rounded-2xl border border-border bg-background shadow-2xl sm:inset-6 md:inset-10">
<div className="hidden w-72 shrink-0 flex-col border-r border-border bg-secondary md:flex">
<div className="flex items-center gap-2 bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 px-4 py-3">
<Sparkle className="h-4 w-4 text-white" weight="fill" />
<Text
variant="body-medium"
as="span"
className="text-sm font-bold text-white"
>
Changelog
</Text>
</div>
<nav className="flex-1 overflow-y-auto p-2">
{entries.map((entry) => (
<button
key={entry.slug}
onClick={() => onSelectEntry(entry)}
className={`mb-1 block w-full rounded-lg px-3 py-2.5 text-left transition-colors ${
selectedEntry?.slug === entry.slug
? "bg-accent/10 ring-1 ring-accent/20"
: "hover:bg-secondary"
}`}
>
<Text
variant="body-medium"
className="text-[13px] font-medium leading-snug text-foreground"
>
{entry.highlights}
</Text>
<Text
variant="body"
className="mt-0.5 text-[11px] text-muted-foreground"
>
{entry.dateRange}
</Text>
</button>
))}
</nav>
<div className="border-t border-border p-3">
<a
href={CHANGELOG_BASE_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 rounded-full bg-primary px-3 py-2 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
View on Docs
<ArrowSquareOut className="h-3 w-3" />
</a>
</div>
</div>
<div className="flex flex-1 flex-col">
<div className="flex items-center justify-between border-b border-border bg-background px-4 py-2">
<div className="flex items-center gap-2 md:hidden">
<Sparkle className="h-4 w-4 text-accent" weight="fill" />
{entries.length > 0 && (
<select
className="max-w-[200px] truncate rounded-md border border-border bg-background px-2 py-1 text-sm text-foreground"
value={selectedEntry?.slug || ""}
onChange={(e) => {
const entry = entries.find((en) => en.slug === e.target.value);
if (entry) onSelectEntry(entry);
}}
>
{entries.map((entry) => (
<option key={entry.slug} value={entry.slug}>
{entry.dateRange}
</option>
))}
</select>
)}
</div>
{selectedEntry && (
<a
href={selectedEntry.url}
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground md:flex"
>
Open in docs
<ArrowSquareOut className="h-3 w-3" />
</a>
)}
<button
onClick={onClose}
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Close changelog"
>
<X className="h-5 w-5" weight="bold" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-6 md:px-10">
{isLoadingMarkdown && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-accent" />
Loading
</div>
)}
{entryMarkdown && (
<ChangelogMarkdownContent markdown={entryMarkdown} />
)}
{!isLoadingMarkdown && !entryMarkdown && selectedEntry && (
<div className="text-sm text-muted-foreground">
Could not load changelog entry.{" "}
<a
href={selectedEntry.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline"
>
View on docs
</a>
</div>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
AUTO_DISMISS_MS,
CHANGELOG_BASE_URL,
CHANGELOG_INDEX_MD_URL,
STORAGE_KEY,
} from "./changelog-constants";
export interface ChangelogEntry {
slug: string;
dateRange: string;
highlights: string;
url: string;
mdUrl: string;
}
function parseChangelogIndex(md: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
const rowPattern =
/\|\s*\[([^\]]+)\]\((https?:\/\/[^)]+\/changelog\/changelog\/([a-z0-9-]+))\)\s*\|\s*([^|]+)\|/g;
let match;
while ((match = rowPattern.exec(md)) !== null) {
const [, dateRange, url, slug, highlights] = match;
entries.push({
slug,
dateRange: dateRange.trim(),
highlights: highlights.trim(),
url,
mdUrl: `${CHANGELOG_BASE_URL}/${slug}.md`,
});
}
return entries;
}
export function useChangelog() {
const [isVisible, setIsVisible] = useState(false);
const [isFading, setIsFading] = useState(false);
const [showFullChangelog, setShowFullChangelog] = useState(false);
const [latestEntry, setLatestEntry] = useState<ChangelogEntry | null>(null);
const [allEntries, setAllEntries] = useState<ChangelogEntry[]>([]);
const [selectedEntry, setSelectedEntry] = useState<ChangelogEntry | null>(
null,
);
const [entryMarkdown, setEntryMarkdown] = useState<string | null>(null);
const [isLoadingMarkdown, setIsLoadingMarkdown] = useState(false);
const autoDismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isPaused = useRef(false);
const isDismissing = useRef(false);
const mdAbort = useRef<AbortController | null>(null);
function clearTimers() {
if (autoDismissTimer.current) clearTimeout(autoDismissTimer.current);
if (fadeTimer.current) clearTimeout(fadeTimer.current);
autoDismissTimer.current = null;
fadeTimer.current = null;
}
function markAsSeen(slug: string) {
try {
localStorage.setItem(STORAGE_KEY, slug);
} catch {
/* noop */
}
}
function dismiss() {
if (isDismissing.current) return;
isDismissing.current = true;
clearTimers();
setIsFading(true);
fadeTimer.current = setTimeout(() => {
setIsVisible(false);
setIsFading(false);
isDismissing.current = false;
if (latestEntry) markAsSeen(latestEntry.slug);
}, 500);
}
function startAutoDismiss() {
if (isPaused.current || showFullChangelog) return;
clearTimers();
autoDismissTimer.current = setTimeout(() => {
if (!isPaused.current && !showFullChangelog) dismiss();
}, AUTO_DISMISS_MS);
}
function pauseAutoDismiss() {
isPaused.current = true;
if (autoDismissTimer.current) {
clearTimeout(autoDismissTimer.current);
autoDismissTimer.current = null;
}
}
function resumeAutoDismiss() {
if (isDismissing.current) return;
isPaused.current = false;
startAutoDismiss();
}
function fetchEntryMarkdown(entry: ChangelogEntry) {
mdAbort.current?.abort();
const controller = new AbortController();
mdAbort.current = controller;
setIsLoadingMarkdown(true);
setEntryMarkdown(null);
fetch(entry.mdUrl, { signal: controller.signal })
.then((res) => (res.ok ? res.text() : ""))
.then((md) => {
if (controller.signal.aborted) return;
const cleaned = md
.replace(/\{%.*?%\}/gs, "")
.replace(/<figure>|<\/figure>/g, "")
.replace(/<figcaption>.*?<\/figcaption>/gs, "")
.replace(/<details>/g, "\n---\n")
.replace(/<\/details>/g, "")
.replace(/<summary>(.*?)<\/summary>/g, "### $1");
setEntryMarkdown(cleaned);
})
.catch(() => {
/* abort or network error — non-critical */
})
.finally(() => {
if (!controller.signal.aborted) setIsLoadingMarkdown(false);
});
}
function openFullChangelog(entry?: ChangelogEntry) {
clearTimers();
isPaused.current = true;
setIsVisible(false);
setIsFading(false);
isDismissing.current = false;
const target = entry || latestEntry;
if (target) {
setSelectedEntry(target);
fetchEntryMarkdown(target);
markAsSeen(target.slug);
}
setShowFullChangelog(true);
}
function closeFullChangelog() {
mdAbort.current?.abort();
setShowFullChangelog(false);
setEntryMarkdown(null);
setSelectedEntry(null);
}
function selectEntry(entry: ChangelogEntry) {
setSelectedEntry(entry);
fetchEntryMarkdown(entry);
}
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;
setAllEntries(entries);
setLatestEntry(entries[0]);
try {
const lastSeen = localStorage.getItem(STORAGE_KEY);
if (lastSeen === entries[0].slug) return;
} catch {
/* show anyway */
}
setTimeout(() => {
if (!cancelled) setIsVisible(true);
}, 1500);
})
.catch(() => {
/* non-critical */
});
return () => {
cancelled = true;
clearTimers();
mdAbort.current?.abort();
};
}, []);
useEffect(() => {
if (isVisible && !isFading && !showFullChangelog) startAutoDismiss();
}, [isVisible, isFading, showFullChangelog]);
return {
isVisible,
isFading,
latestEntry,
allEntries,
entryMarkdown,
isLoadingMarkdown,
dismiss,
pauseAutoDismiss,
resumeAutoDismiss,
showFullChangelog,
openFullChangelog,
closeFullChangelog,
selectedEntry,
selectEntry,
};
}