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.title} + )} + {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) => ( ))} +