Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -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:
- '*'
14 changes: 13 additions & 1 deletion .github/workflows/electron-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Expand All @@ -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

Expand Down Expand Up @@ -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[@]}"
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
48 changes: 34 additions & 14 deletions apps/electron/src/renderer/components/UpdateNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,20 +80,32 @@ export function UpdateNotification() {
return (
<div className="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg border border-neutral-200 bg-white p-4 shadow-lg dark:border-neutral-700 dark:bg-neutral-800">
{available && !progress && !ready && (
<div className="flex items-center gap-3">
<span className="text-sm">Version {available.version} is available</span>
<button
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
onClick={() => invoke('download-update')}
>
Download
</button>
<button
className="rounded px-3 py-1 text-xs text-neutral-500 hover:text-neutral-700"
onClick={() => setAvailable(null)}
>
Later
</button>
<div className="space-y-2">
<div className="text-sm font-medium">
<span role="img" aria-label="sparkles">
</span>{' '}
Version {available.version} is available
</div>
{available.releaseNotes && (
<div className="max-h-44 overflow-y-auto whitespace-pre-wrap rounded bg-neutral-50 p-2 text-xs leading-relaxed text-neutral-600 dark:bg-neutral-900 dark:text-neutral-300">
{toPlainText(available.releaseNotes)}
</div>
)}
<div className="flex items-center gap-3">
<button
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
onClick={() => invoke('download-update')}
>
Download
</button>
<button
className="rounded px-3 py-1 text-xs text-neutral-500 hover:text-neutral-700"
onClick={() => setAvailable(null)}
>
Later
</button>
</div>
</div>
)}

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
130 changes: 130 additions & 0 deletions apps/web/src/whats-new/WhatsNewButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<article className="border-b border-hairline px-4 py-3 last:border-b-0">
<div className="mb-1 flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-ink-3">{item.date}</span>
{isNew && (
<span className="rounded-full bg-success/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-success">
New
</span>
)}
</div>
<h3 className="mb-1 text-sm font-semibold text-ink-1">{item.title}</h3>
<p className="mb-2 text-xs leading-relaxed text-ink-2">{item.summary}</p>
{item.image && (
<img
src={item.image}
alt={item.title}
loading="lazy"
className="mb-2 w-full rounded-md border border-hairline"
/>
)}
{item.highlights.length > 0 && (
<ul className="grid gap-1">
{item.highlights.map((h) => (
<li key={h} className="flex gap-1.5 text-xs text-ink-2">
<span className="text-success">·</span>
<span>{h}</span>
</li>
))}
</ul>
)}
</article>
)
}

function WhatsNewPanel({ api }: { api: ReturnType<typeof useWhatsNew> }) {
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 (
<div
className="fixed inset-0 z-[200] flex items-start justify-center bg-black/40 pt-[10vh]"
onClick={closePanel}
role="presentation"
>
<div
className="flex max-h-[70vh] w-[min(92vw,440px)] flex-col overflow-hidden rounded-xl border border-hairline bg-surface shadow-2xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="What's New"
>
<header className="flex items-center justify-between border-b border-hairline px-4 py-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink-1">
<Sparkles size={14} strokeWidth={1.5} />
What's New
</h2>
<button
type="button"
onClick={closePanel}
aria-label="Close"
className="cursor-pointer border-none bg-transparent text-ink-3 hover:text-ink-1"
>
</button>
</header>

<div className="overflow-y-auto">
{loading && <p className="px-4 py-6 text-center text-xs text-ink-3">Loading…</p>}
{!loading && items.length === 0 && (
<p className="px-4 py-6 text-center text-xs text-ink-3">
No changelog available right now.
</p>
)}
{items.map((item) => (
<EntryCard key={item.id} item={item} isNew={unseenIds.has(item.id)} />
))}
</div>

<footer className="border-t border-hairline px-4 py-2 text-center">
<a
href={CHANGELOG_PAGE_URL}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:underline"
>
View full changelog →
</a>
</footer>
</div>
</div>
)
}

export function WhatsNewButton() {
const api = useWhatsNew()
return (
<>
<button
type="button"
onClick={api.openPanel}
title="What's New"
aria-label="What's New"
className="flex cursor-pointer items-center border-none bg-transparent p-0 text-ink-3 hover:text-ink-1"
>
<Sparkles size={12} strokeWidth={1.5} />
</button>
{api.open && <WhatsNewPanel api={api} />}
</>
)
}
89 changes: 89 additions & 0 deletions apps/web/src/whats-new/feed.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
Loading
Loading