diff --git a/backend/apps/help/bundle.py b/backend/apps/help/bundle.py
index 48e7caf1..07a19415 100644
--- a/backend/apps/help/bundle.py
+++ b/backend/apps/help/bundle.py
@@ -170,6 +170,22 @@ async def get_help_knowledge() -> HelpKnowledgeResponse:
return build_knowledge_response()
+@help_app.router.get("/whats-new")
+def whats_new() -> dict:
+ """The release story for the in-app What's New card. Same words the Help agent and the GitHub
+ body get, so a user never reads two different accounts of the same release."""
+ from backend.apps.help.changelog import as_markdown, latest_release, release_notes
+ from backend.apps.service.version import APP_VERSION
+ note = release_notes(APP_VERSION) or latest_release()
+ return {
+ "version": note.version,
+ "headline": note.headline,
+ "highlights": note.highlights,
+ "fixes": note.fixes,
+ "markdown": as_markdown(note),
+ }
+
+
@help_app.router.post("/bundle")
@typechecked
async def build_bundle(body: BundleRequest) -> dict:
diff --git a/backend/apps/help/changelog.py b/backend/apps/help/changelog.py
index 1f557afc..c2e7281a 100644
--- a/backend/apps/help/changelog.py
+++ b/backend/apps/help/changelog.py
@@ -64,7 +64,7 @@ def latest_release() -> ReleaseNote:
@typechecked
def as_markdown(note: ReleaseNote) -> str:
"""The GitHub release body; identical words to the in-app card, so nobody reads two stories."""
- lines = [f"## {note.version} — {note.headline}", ""]
+ lines = [f"## {note.version}: {note.headline}", ""]
if note.highlights:
lines.append("### New")
lines += [f"- {h}" for h in note.highlights]
diff --git a/backend/tests/test_changelog.py b/backend/tests/test_changelog.py
index 4140c27b..54bf9e4b 100644
--- a/backend/tests/test_changelog.py
+++ b/backend/tests/test_changelog.py
@@ -21,6 +21,12 @@ def test_notes_are_written_for_users_not_committers():
assert "—" not in line and "–" not in line, "house style: no em/en dashes"
+def test_no_em_dashes_anywhere_in_a_release_body():
+ # House rule, and the header was the one place the earlier test did not look.
+ md = as_markdown(latest_release())
+ assert "\u2014" not in md and "\u2013" not in md, "release bodies use plain punctuation"
+
+
def test_markdown_body_carries_the_same_words_as_the_app():
note = latest_release()
md = as_markdown(note)
diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx
index 1cbdc6f9..742003d5 100644
--- a/frontend/src/app/components/Layout/AppShell.tsx
+++ b/frontend/src/app/components/Layout/AppShell.tsx
@@ -25,6 +25,7 @@ import { ackRun, runWorkflowNow } from '@/shared/state/workflowsSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import UpdateReadyPill from '@/app/components/Layout/UpdateReadyPill';
+import WhatsNewCard from '@/app/components/Layout/WhatsNewCard';
import ShareRequestHost from '@/app/components/share/ShareRequestHost';
import CardContextMenu from '@/app/pages/Dashboard/desktop/CardContextMenu';
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
@@ -590,6 +591,8 @@ const AppShell: React.FC = () => {
{/* Shell-global right-click host (portals to body): chat surfaces render on non-dashboard routes too, so the menu can't live inside DashboardCanvas. */}
+
+
);
diff --git a/frontend/src/app/components/Layout/WhatsNewCard.tsx b/frontend/src/app/components/Layout/WhatsNewCard.tsx
new file mode 100644
index 00000000..a366aac0
--- /dev/null
+++ b/frontend/src/app/components/Layout/WhatsNewCard.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import Fade from '@mui/material/Fade';
+import { API_BASE } from '@/shared/config';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+// Shown ONCE per version, right after an update: what actually changed, in the same words the Help
+// agent and the GitHub release body carry. A release that ships with no story is a bug the backend
+// test catches; a user who never hears about the fix is the bug this card catches.
+interface WhatsNew {
+ version: string;
+ headline: string;
+ highlights: string[];
+ fixes: string[];
+}
+
+const SEEN_KEY = 'openswarm.whatsNew.seenVersion';
+
+export default function WhatsNewCard(): React.ReactElement | null {
+ const c = useClaudeTokens();
+ const [note, setNote] = React.useState(null);
+
+ React.useEffect(() => {
+ let cancelled = false;
+ fetch(`${API_BASE}/help/whats-new`)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((data: WhatsNew | null) => {
+ if (cancelled || !data?.version) return;
+ let seen: string | null = null;
+ try { seen = window.localStorage.getItem(SEEN_KEY); } catch { /* private mode */ }
+ if (seen === data.version) return;
+ setNote(data);
+ })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, []);
+
+ const dismiss = React.useCallback(() => {
+ if (note) {
+ try { window.localStorage.setItem(SEEN_KEY, note.version); } catch { /* private mode */ }
+ }
+ setNote(null);
+ }, [note]);
+
+ if (!note) return null;
+ const lines = [...note.highlights.map((t) => ({ t, kind: 'new' })), ...note.fixes.map((t) => ({ t, kind: 'fixed' }))];
+
+ return (
+
+
+
+ {`What's new in ${note.version}`}
+
+
+ {note.headline}
+
+
+ {lines.slice(0, 5).map((l) => (
+
+ {l.t}
+
+ ))}
+
+
+
+
+
+
+ );
+}