From d18deed51d130bd596af3b56bc60d966870394e9 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:52:00 +0800 Subject: [PATCH] feat: announce each release once with a toast generated from the changelog --- .changeset/whats-new-on-release.md | 5 ++ package.json | 2 +- scripts/generate-whats-new.mjs | 18 ++++++++ scripts/lib/whats-new.mjs | 35 ++++++++++++++ src/web/public/app.js | 2 + src/web/public/settings-ui.js | 33 +++++++++++++ src/web/public/whats-new.json | 13 ++++++ test/whats-new.test.ts | 74 ++++++++++++++++++++++++++++++ 8 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 .changeset/whats-new-on-release.md create mode 100644 scripts/generate-whats-new.mjs create mode 100644 scripts/lib/whats-new.mjs create mode 100644 src/web/public/whats-new.json create mode 100644 test/whats-new.test.ts diff --git a/.changeset/whats-new-on-release.md b/.changeset/whats-new-on-release.md new file mode 100644 index 00000000..bc1a9331 --- /dev/null +++ b/.changeset/whats-new-on-release.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Each release now announces itself. `npm run version-packages` writes `src/web/public/whats-new.json` from the new CHANGELOG section (the bold headline of every change), and the first time a browser loads a newer version it shows one toast naming the headlines, with a link to the full release notes. A browser that has never opened Codeman before records the current version and stays quiet. diff --git a/package.json b/package.json index 35f5bbf9..4b602cd7 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "check:public-assets": "node scripts/check-public-assets.mjs", "capture:subagents": "node scripts/capture-subagent-screenshots.mjs", "changeset": "changeset", - "version-packages": "changeset version && node scripts/sync-plugin.mjs && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs", + "version-packages": "changeset version && node scripts/generate-whats-new.mjs && prettier --write src/web/public/whats-new.json && node scripts/sync-plugin.mjs && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs", "check:lockfile": "node scripts/check-lockfile-sync.mjs", "check:plugin": "node scripts/sync-plugin.mjs --check && claude plugin validate --strict plugins/codeman && claude plugin validate --strict .claude-plugin/marketplace.json", "knip": "npx --yes knip@latest --config config/knip.json", diff --git a/scripts/generate-whats-new.mjs b/scripts/generate-whats-new.mjs new file mode 100644 index 00000000..9611d1c3 --- /dev/null +++ b/scripts/generate-whats-new.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +// Writes src/web/public/whats-new.json for the version in package.json from CHANGELOG.md. +// Runs as part of `npm run version-packages`, so every release carries its own notes and the +// UI can announce them once per version without anyone remembering to. +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildWhatsNew } from './lib/whats-new.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const { version } = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const changelog = readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8'); +const notes = buildWhatsNew(changelog, version); +if (notes.highlights.length === 0) { + console.warn(`whats-new: no bold highlights found for ${version}; the in-app announcement will be skipped`); +} +writeFileSync(resolve(root, 'src/web/public/whats-new.json'), JSON.stringify(notes, null, 2) + '\n'); +console.log(`whats-new: ${version} -> ${notes.highlights.length} highlight(s)`); diff --git a/scripts/lib/whats-new.mjs b/scripts/lib/whats-new.mjs new file mode 100644 index 00000000..c5eaabd9 --- /dev/null +++ b/scripts/lib/whats-new.mjs @@ -0,0 +1,35 @@ +/** + * Pulls the headline of each change out of one version's section of CHANGELOG.md. + * + * Release notes here lead each change with a bold phrase ("**Tab layouts (#538, optional).** ..."), + * so a highlight is that phrase. The Thanks block is credit, not a feature. + */ + +const MAX_HIGHLIGHTS = 8; + +/** The text between `## ` and the next `## ` heading, or '' when absent. */ +export function versionSection(changelog, version) { + const lines = changelog.split(/\r?\n/); + const start = lines.findIndex((l) => l.trim() === `## ${version}`); + if (start === -1) return ''; + let end = lines.findIndex((l, i) => i > start && /^## /.test(l)); + if (end === -1) end = lines.length; + return lines.slice(start + 1, end).join('\n'); +} + +/** Bold lead-ins at the start of a paragraph or bullet, cleaned for display. */ +export function extractHighlights(section) { + const out = []; + for (const line of section.split('\n')) { + const m = /^\s*(?:[-*]\s+)?\*\*([^*]{3,120})\*\*/.exec(line); + if (!m) continue; + const text = m[1].replace(/[\s.:]+$/, '').trim(); + if (!text || /^thanks\b/i.test(text)) continue; + if (!out.includes(text)) out.push(text); + } + return out.slice(0, MAX_HIGHLIGHTS); +} + +export function buildWhatsNew(changelog, version) { + return { version, highlights: extractHighlights(versionSection(changelog, version)) }; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 2795b599..a51453ce 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4843,6 +4843,8 @@ class CodemanApp { } } + if (data.version) this.checkWhatsNew?.(data.version); + // Stop any active voice recording on reconnect VoiceInput.cleanup(); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 7705b086..1fc2621d 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -369,6 +369,39 @@ Object.assign(CodemanApp.prototype, { return select?.value.trim() || ''; }, + /** + * Announce a new release once per browser: one toast naming its headline changes. + * The notes come from /whats-new.json, which `npm run version-packages` regenerates from + * CHANGELOG.md for each release. A browser that has never seen any version just records the + * current one, so a fresh install is not greeted with "what's new". + */ + async checkWhatsNew(runningVersion) { + if (this._whatsNewChecked) return; + this._whatsNewChecked = true; + const KEY = 'codeman-whats-new-seen'; + let seen = null; + try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement */ return; } + if (seen === runningVersion) return; + const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } }; + if (!seen) { remember(); return; } + try { + const res = await fetch('/whats-new.json', { cache: 'no-cache' }); + if (!res.ok) return; + const notes = await res.json(); + if (notes?.version !== runningVersion || !Array.isArray(notes.highlights) || !notes.highlights.length) return; + const shown = notes.highlights.slice(0, 3).join(' ยท '); + const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : ''; + remember(); + this.showToast(`Codeman updated to v${runningVersion}: ${shown}${more}`, 'info', { + duration: 20000, + action: { + label: "See what's new", + onClick: () => window.open(`https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`, '_blank', 'noopener'), + }, + }); + } catch { /* offline or malformed: try again next load */ } + }, + openAppSettings() { // Load current settings const settings = this.loadAppSettingsFromStorage(); diff --git a/src/web/public/whats-new.json b/src/web/public/whats-new.json new file mode 100644 index 00000000..eae9089a --- /dev/null +++ b/src/web/public/whats-new.json @@ -0,0 +1,13 @@ +{ + "version": "1.41.1", + "highlights": [ + "HTML files open as pages, and markdown opens rendered from the terminal (#547)", + "`codeman mcp`: drive Codeman from any MCP client (#615)", + "The codeman skill for every agent CLI (#613)", + "Tiles: move them by hand, or tear one off into its own window (#584)", + "Focus section on the vertical rail (#582)", + "Tabs land in groups (#528)", + "Folder grid and more clickable paths (#604)", + "Phone Attach button and videos (#606)" + ] +} diff --git a/test/whats-new.test.ts b/test/whats-new.test.ts new file mode 100644 index 00000000..95c4ba84 --- /dev/null +++ b/test/whats-new.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +// @ts-expect-error plain .mjs helper shared with scripts/generate-whats-new.mjs +import { extractHighlights, versionSection, buildWhatsNew } from '../scripts/lib/whats-new.mjs'; + +const root = resolve(import.meta.dirname, '..'); + +const CHANGELOG = `# aicodeman + +## 1.2.0 + +### Minor Changes + +- ### Thanks + - @someone for a fix + + **The tile grid: six sessions side by side.** Body text with **inline bold** later. + +- **Split view (#560).** More text. +- A bullet with no bold lead. + + **Behaviour change: \`Ctrl+W\` no longer closes a session.** Details. + +## 1.1.0 + +- **Old feature.** Not for 1.2.0. +`; + +describe('whats-new extraction', () => { + it('limits itself to the requested version section', () => { + const section = versionSection(CHANGELOG, '1.2.0'); + expect(section).toContain('tile grid'); + expect(section).not.toContain('Old feature'); + expect(versionSection(CHANGELOG, '9.9.9')).toBe(''); + }); + + it('takes bold lead-ins only, cleaned, and skips the Thanks block', () => { + expect(extractHighlights(versionSection(CHANGELOG, '1.2.0'))).toEqual([ + 'The tile grid: six sessions side by side', + 'Split view (#560)', + 'Behaviour change: `Ctrl+W` no longer closes a session', + ]); + }); + + it('caps the list', () => { + const many = Array.from({ length: 20 }, (_, i) => `**Feature number ${i}.** text`).join('\n\n'); + expect(extractHighlights(many)).toHaveLength(8); + }); + + it('builds the payload the client reads', () => { + expect(buildWhatsNew(CHANGELOG, '1.2.0').version).toBe('1.2.0'); + }); +}); + +describe('shipped whats-new.json', () => { + it('matches the package version and has highlights, so the release announces itself', () => { + const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); + const notes = JSON.parse(readFileSync(resolve(root, 'src/web/public/whats-new.json'), 'utf8')); + expect(notes.version).toBe(pkg.version); + expect(notes.highlights.length).toBeGreaterThan(0); + }); + + it('is regenerated by the release script and consumed once per version by the client', () => { + const pkg = readFileSync(resolve(root, 'package.json'), 'utf8'); + expect(pkg).toMatch(/"version-packages": "changeset version && node scripts\/generate-whats-new\.mjs/); + const ui = readFileSync(resolve(root, 'src/web/public/settings-ui.js'), 'utf8'); + expect(ui).toContain("'codeman-whats-new-seen'"); + expect(ui).toContain("fetch('/whats-new.json'"); + expect(readFileSync(resolve(root, 'src/web/public/app.js'), 'utf8')).toContain( + 'this.checkWhatsNew?.(data.version)' + ); + }); +});