diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 000000000..2460979ee
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,29 @@
+# Categories for GitHub's auto-generated release notes (used with
+# `gh release create --generate-notes`). Merged PRs are grouped by label.
+# See exploration 0195 — these notes are appended below the AI "What's New".
+changelog:
+ exclude:
+ labels:
+ - skip-changelog
+ - internal
+ - dependencies
+ categories:
+ - title: New Features
+ labels:
+ - enhancement
+ - feature
+ - title: Bug Fixes
+ labels:
+ - bug
+ - fix
+ - title: Performance
+ labels:
+ - performance
+ - perf
+ - title: Documentation
+ labels:
+ - documentation
+ - docs
+ - title: Other Changes
+ labels:
+ - '*'
diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml
index 8e7b484d6..87bd3afcd 100644
--- a/.github/workflows/electron-release.yml
+++ b/.github/workflows/electron-release.yml
@@ -280,8 +280,14 @@ jobs:
with:
path: artifacts
+ - uses: ./.github/actions/setup
+
- name: Generate release notes
id: notes
+ env:
+ # Optional: when set, raw commits are rewritten into user-facing prose.
+ # Absent → the script is a no-op passthrough (release still gets notes).
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# Get commits since last release
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
@@ -291,10 +297,15 @@ jobs:
COMMITS=$(git log --pretty=format:"- %s" -20 -- apps/electron packages)
fi
+ # Rewrite the commit list into user-facing "What's New" prose. This is
+ # fail-open: with no API key it echoes the raw commits unchanged.
+ WHATS_NEW=$(printf '%s\n' "$COMMITS" \
+ | node scripts/changelog/ai-release-notes.mjs "v${{ needs.version.outputs.version }}")
+
cat << EOF > release-notes.md
## What's New
- $COMMITS
+ $WHATS_NEW
## Downloads
@@ -346,6 +357,7 @@ jobs:
gh release create "v${{ needs.version.outputs.version }}" \
--title "xNet v${{ needs.version.outputs.version }}" \
--notes-file release-notes.md \
+ --generate-notes \
${{ github.event.inputs.release_type == 'draft' && '--draft' || '' }} \
${{ github.event.inputs.release_type == 'prerelease' && '--prerelease' || '' }} \
"${FILES[@]}"
diff --git a/AGENTS.md b/AGENTS.md
index 70cf755d6..8a990ad89 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -303,6 +303,35 @@ Only use `--no-verify` when hooks are genuinely broken or blocking an emergency
- **Commitlint error**: Reformat your commit message to `type(scope): description`.
- **Lockfile drift**: Run `pnpm install` and stage `pnpm-lock.yaml`.
+## Changelog Entries (User-Facing Changes)
+
+xNet keeps a user-facing changelog (exploration 0195). It surfaces on the
+website (`/changelog`), as JSON/RSS feeds, and inside the app's "What's New"
+panel. The single source is **`site/src/data/changelog.ts`**.
+
+When your change ships something a user will notice — a new feature, a fixed
+bug, a visible UX or performance improvement — **prepend an entry** to the
+`entries` array (newest-first) and bump `updated`:
+
+```ts
+{
+ id: '2026-06-20', // ISO date, unique, newest-first
+ date: 'June 2026', // human label shown on the page
+ title: 'Short, benefit-first headline',
+ summary: 'One paragraph in plain language — what the user can now do.',
+ highlights: ['User-visible point', 'Another user-visible point'],
+ tags: ['app'], // see ChangelogTag in the same file
+ hero: { src: '/images/...', alt: '...' }, // optional; a CI visual works too
+ pr: 0 // your PR number
+}
+```
+
+Write for end users, not engineers: "Deals now sync after import," not
+`fix(schema): correct relation validation`. Skip internal refactors and chores.
+`pnpm --filter site validate:changelog` enforces the shape. This is separate
+from the per-package Changesets developer changelog (`pnpm changeset`), which
+stays focused on library/API consumers.
+
## Key Constraints
**DO:**
diff --git a/apps/electron/src/renderer/components/UpdateNotification.tsx b/apps/electron/src/renderer/components/UpdateNotification.tsx
index ad4ace88e..4e392fa08 100644
--- a/apps/electron/src/renderer/components/UpdateNotification.tsx
+++ b/apps/electron/src/renderer/components/UpdateNotification.tsx
@@ -10,6 +10,14 @@ interface UpdateInfo {
releaseNotes?: string
}
+/** electron-updater may hand us HTML release notes; render them as plain text. */
+function toPlainText(notes: string): string {
+ return notes
+ .replace(/<[^>]+>/g, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+}
+
interface UpdateProgress {
percent: number
transferred: number
@@ -72,20 +80,32 @@ export function UpdateNotification() {
return (
{available && !progress && !ready && (
-
-
Version {available.version} is available
-
-
+
+
+
+ ✨
+ {' '}
+ Version {available.version} is available
+
+ {available.releaseNotes && (
+
+ {toPlainText(available.releaseNotes)}
+
+ )}
+
+
+
+
)}
diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts
index 9b1705f82..08fd787f2 100644
--- a/apps/web/src/env.d.ts
+++ b/apps/web/src/env.d.ts
@@ -7,6 +7,8 @@ interface ImportMetaEnv {
readonly VITE_STORAGE_SCOPE?: string
/** Opt-in flag for the /analytics telemetry dashboard (exploration 0187). */
readonly VITE_TELEMETRY_DASHBOARD?: string
+ /** App version, injected from package.json at build (exploration 0195). */
+ readonly VITE_APP_VERSION?: string
}
interface ImportMeta {
diff --git a/apps/web/src/whats-new/WhatsNewButton.tsx b/apps/web/src/whats-new/WhatsNewButton.tsx
new file mode 100644
index 000000000..a5f627c56
--- /dev/null
+++ b/apps/web/src/whats-new/WhatsNewButton.tsx
@@ -0,0 +1,130 @@
+/**
+ * "What's New" status-bar affordance + panel (exploration 0195).
+ *
+ * Lives in the StatusBar right cluster. Clicking opens a panel that lazily
+ * fetches the public changelog feed and lists recent releases, with new-since
+ * markers and a link to the full changelog on the website.
+ */
+import { Sparkles } from 'lucide-react'
+import { useEffect } from 'react'
+import { CHANGELOG_PAGE_URL, type ChangelogFeedItem } from './feed'
+import { useWhatsNew } from './useWhatsNew'
+
+function EntryCard({ item, isNew }: { item: ChangelogFeedItem; isNew: boolean }) {
+ return (
+
+
+ {item.date}
+ {isNew && (
+
+ New
+
+ )}
+
+ {item.title}
+ {item.summary}
+ {item.image && (
+
+ )}
+ {item.highlights.length > 0 && (
+
+ {item.highlights.map((h) => (
+ -
+ ·
+ {h}
+
+ ))}
+
+ )}
+
+ )
+}
+
+function WhatsNewPanel({ api }: { api: ReturnType
}) {
+ const { items, loading, unseen, closePanel } = api
+ const unseenIds = new Set(unseen.map((u) => u.id))
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') closePanel()
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [closePanel])
+
+ return (
+
+
e.stopPropagation()}
+ role="dialog"
+ aria-label="What's New"
+ >
+
+
+
+ What's New
+
+
+
+
+
+ {loading &&
Loading…
}
+ {!loading && items.length === 0 && (
+
+ No changelog available right now.
+
+ )}
+ {items.map((item) => (
+
+ ))}
+
+
+
+
+
+ )
+}
+
+export function WhatsNewButton() {
+ const api = useWhatsNew()
+ return (
+ <>
+
+ {api.open && }
+ >
+ )
+}
diff --git a/apps/web/src/whats-new/feed.test.ts b/apps/web/src/whats-new/feed.test.ts
new file mode 100644
index 000000000..c6cbf7b57
--- /dev/null
+++ b/apps/web/src/whats-new/feed.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+import { fetchChangelog, parseFeed, selectUnseen } from './feed'
+
+const SAMPLE = {
+ version: 'https://jsonfeed.org/version/1.1',
+ items: [
+ {
+ id: '2026-06-17',
+ url: 'https://xnet.fyi/changelog#2026-06-17',
+ title: 'Automated changelog',
+ content_text: 'fallback text',
+ image: 'https://xnet.fyi/images/workbench-dark.png',
+ tags: ['app', 'ci'],
+ _xnet: { date: 'June 17, 2026', summary: 'A real summary', highlights: ['a', 'b'], pr: 146 }
+ },
+ {
+ id: '2026-06-10',
+ title: 'Older release',
+ tags: ['ci'],
+ _xnet: { date: 'June 2026', summary: 'Older', highlights: ['x'] }
+ }
+ ]
+}
+
+describe('parseFeed', () => {
+ it('maps JSON Feed items, preferring the _xnet extension fields', () => {
+ const items = parseFeed(SAMPLE)
+ expect(items).toHaveLength(2)
+ expect(items[0]).toMatchObject({
+ id: '2026-06-17',
+ title: 'Automated changelog',
+ date: 'June 17, 2026',
+ summary: 'A real summary',
+ highlights: ['a', 'b'],
+ tags: ['app', 'ci'],
+ image: 'https://xnet.fyi/images/workbench-dark.png',
+ pr: 146
+ })
+ })
+
+ it('falls back to id/content_text when the extension is absent', () => {
+ const [item] = parseFeed({ items: [{ id: '2026-01-01', title: 'X', content_text: 'body' }] })
+ expect(item.date).toBe('2026-01-01')
+ expect(item.summary).toBe('body')
+ expect(item.highlights).toEqual([])
+ })
+
+ it('drops malformed items and tolerates non-feed input', () => {
+ expect(parseFeed({ items: [{ title: 'no id' }, { id: 'x', title: 'ok' }] })).toHaveLength(1)
+ expect(parseFeed(null)).toEqual([])
+ expect(parseFeed({})).toEqual([])
+ })
+})
+
+describe('selectUnseen', () => {
+ const items = parseFeed(SAMPLE)
+
+ it('returns entries strictly newer than the last-seen id', () => {
+ expect(selectUnseen(items, '2026-06-10').map((i) => i.id)).toEqual(['2026-06-17'])
+ })
+
+ it('returns nothing when caught up', () => {
+ expect(selectUnseen(items, '2026-06-17')).toEqual([])
+ })
+
+ it('returns nothing when never seen (seeded later by the hook)', () => {
+ expect(selectUnseen(items, null)).toEqual([])
+ })
+})
+
+describe('fetchChangelog', () => {
+ it('returns [] on a failed response without throwing', async () => {
+ const failing = (async () => ({ ok: false }) as Response) as typeof fetch
+ expect(await fetchChangelog(failing)).toEqual([])
+ })
+
+ it('returns [] when fetch rejects (offline)', async () => {
+ const rejecting = (async () => {
+ throw new Error('offline')
+ }) as typeof fetch
+ expect(await fetchChangelog(rejecting)).toEqual([])
+ })
+
+ it('parses a successful response', async () => {
+ const ok = (async () =>
+ ({ ok: true, json: async () => SAMPLE }) as unknown as Response) as typeof fetch
+ expect((await fetchChangelog(ok)).map((i) => i.id)).toEqual(['2026-06-17', '2026-06-10'])
+ })
+})
diff --git a/apps/web/src/whats-new/feed.ts b/apps/web/src/whats-new/feed.ts
new file mode 100644
index 000000000..a42f12692
--- /dev/null
+++ b/apps/web/src/whats-new/feed.ts
@@ -0,0 +1,102 @@
+/**
+ * Pure logic for the in-app "What's New" surface (exploration 0195).
+ *
+ * Reads the public JSON Feed published by the site (site/src/data/changelog.ts
+ * → /changelog.json) and exposes the small helpers the hook/UI need. Kept free
+ * of React and of side effects (the fetch impl is injectable) so it is unit
+ * tested and the network only happens when the panel is actually opened.
+ */
+
+/** Production feed. The site is served from the same origin under xnet.fyi. */
+export const CHANGELOG_FEED_URL = 'https://xnet.fyi/changelog.json'
+export const CHANGELOG_PAGE_URL = 'https://xnet.fyi/changelog'
+
+export interface ChangelogFeedItem {
+ id: string
+ url: string
+ title: string
+ /** Human-facing date label (from the feed's xNet extension, falls back to id). */
+ date: string
+ summary: string
+ highlights: string[]
+ tags: string[]
+ image?: string
+ pr?: number
+}
+
+interface RawFeedItem {
+ id?: unknown
+ url?: unknown
+ title?: unknown
+ content_text?: unknown
+ image?: unknown
+ tags?: unknown
+ _xnet?: {
+ date?: unknown
+ summary?: unknown
+ highlights?: unknown
+ pr?: unknown
+ }
+}
+
+function asString(value: unknown, fallback = ''): string {
+ return typeof value === 'string' ? value : fallback
+}
+
+function asStringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
+}
+
+function toItem(raw: RawFeedItem): ChangelogFeedItem | null {
+ const id = asString(raw.id)
+ const title = asString(raw.title)
+ if (!id || !title) return null
+ const ext = raw._xnet ?? {}
+ return {
+ id,
+ url: asString(raw.url, CHANGELOG_PAGE_URL),
+ title,
+ date: asString(ext.date, id),
+ summary: asString(ext.summary, asString(raw.content_text)),
+ highlights: asStringArray(ext.highlights),
+ tags: asStringArray(raw.tags),
+ image: typeof raw.image === 'string' ? raw.image : undefined,
+ pr: typeof ext.pr === 'number' ? ext.pr : undefined
+ }
+}
+
+/** Parse a JSON Feed document into changelog items (newest-first, malformed dropped). */
+export function parseFeed(json: unknown): ChangelogFeedItem[] {
+ const items = (json as { items?: unknown })?.items
+ if (!Array.isArray(items)) return []
+ return items
+ .map((raw) => toItem(raw as RawFeedItem))
+ .filter((x): x is ChangelogFeedItem => x !== null)
+}
+
+/** Entries newer than the last-seen id. Ids are ISO dates, so string compare works. */
+export function selectUnseen(
+ items: ChangelogFeedItem[],
+ lastSeenId: string | null
+): ChangelogFeedItem[] {
+ if (!lastSeenId) return []
+ return items.filter((item) => item.id > lastSeenId)
+}
+
+export function isUnseen(item: ChangelogFeedItem, lastSeenId: string | null): boolean {
+ return lastSeenId !== null && item.id > lastSeenId
+}
+
+/** Fetch + parse the changelog feed. Never throws — returns [] on any failure. */
+export async function fetchChangelog(
+ fetchImpl: typeof fetch = fetch,
+ url: string = CHANGELOG_FEED_URL
+): Promise {
+ try {
+ const res = await fetchImpl(url)
+ if (!res.ok) return []
+ return parseFeed(await res.json())
+ } catch {
+ return []
+ }
+}
diff --git a/apps/web/src/whats-new/useWhatsNew.ts b/apps/web/src/whats-new/useWhatsNew.ts
new file mode 100644
index 000000000..6577fee0e
--- /dev/null
+++ b/apps/web/src/whats-new/useWhatsNew.ts
@@ -0,0 +1,59 @@
+/**
+ * In-app "What's New" hook (exploration 0195).
+ *
+ * The changelog feed is fetched lazily — only when the panel is opened — so the
+ * app makes no background network request on startup (which would surface as a
+ * console error in the offline e2e environment). Closing the panel marks the
+ * newest entry as seen, persisted in the workbench store.
+ */
+import { useCallback, useState } from 'react'
+import { useWorkbench } from '../workbench/state'
+import { fetchChangelog, selectUnseen, type ChangelogFeedItem } from './feed'
+
+export interface WhatsNewApi {
+ open: boolean
+ items: ChangelogFeedItem[]
+ loading: boolean
+ loaded: boolean
+ /** Entries newer than the last-seen id (empty on first-ever open). */
+ unseen: ChangelogFeedItem[]
+ appVersion: string | undefined
+ openPanel: () => void
+ closePanel: () => void
+}
+
+export function useWhatsNew(): WhatsNewApi {
+ const lastSeenId = useWorkbench((s) => s.lastSeenChangelogId)
+ const setLastSeen = useWorkbench((s) => s.setLastSeenChangelogId)
+ const [open, setOpen] = useState(false)
+ const [items, setItems] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [loaded, setLoaded] = useState(false)
+
+ const openPanel = useCallback(() => {
+ setOpen(true)
+ if (loaded || loading) return
+ setLoading(true)
+ void fetchChangelog().then((data) => {
+ setItems(data)
+ setLoaded(true)
+ setLoading(false)
+ })
+ }, [loaded, loading])
+
+ const closePanel = useCallback(() => {
+ setOpen(false)
+ if (items[0]) setLastSeen(items[0].id)
+ }, [items, setLastSeen])
+
+ return {
+ open,
+ items,
+ loading,
+ loaded,
+ unseen: selectUnseen(items, lastSeenId),
+ appVersion: import.meta.env.VITE_APP_VERSION,
+ openPanel,
+ closePanel
+ }
+}
diff --git a/apps/web/src/workbench/StatusBar.tsx b/apps/web/src/workbench/StatusBar.tsx
index 8c4886634..da4e96123 100644
--- a/apps/web/src/workbench/StatusBar.tsx
+++ b/apps/web/src/workbench/StatusBar.tsx
@@ -13,6 +13,7 @@ import { useTheme } from '@xnetjs/ui'
import { Moon, Sun, Users } from 'lucide-react'
import { useSpaces } from '../hooks/useSpaces'
import { getDataRuntime } from '../lib/data-runtime'
+import { WhatsNewButton } from '../whats-new/WhatsNewButton'
import { statusContributionText, useWorkbenchContributions } from './contributions'
import { navigateToNode } from './navigation'
import { useWorkbench } from './state'
@@ -140,6 +141,7 @@ export function StatusBar() {
{rightItems.map((item) => (
))}
+