Merge pull request #618 from opticon454/feat/whats-new

feat: announce each release once with a toast generated from the changelog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-16 02:39:49 +02:00
commit 15a73dfa05
10 files changed
+249 -2

No files matched your search

+5
View File
@@ -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.
+1 -1
View File
@@ -19,7 +19,7 @@ When user says "COM":
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions. It also writes `src/web/public/whats-new.json` (`scripts/generate-whats-new.mjs`): the bold lead-in of each change in the new `CHANGELOG.md` section, which the browser announces once per version in a toast. Commit it with the release; `test/whats-new.test.ts` fails when its version differs from `package.json`, so a hand bump needs `node scripts/generate-whats-new.mjs && npx prettier --write src/web/public/whats-new.json` too. A release whose notes have no bold lead-ins ships an empty list and shows no toast)
4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json`
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
+1 -1
View File
@@ -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",
+18
View File
@@ -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)`);
+42
View File
@@ -0,0 +1,42 @@
/**
* 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 `## <version>` 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 phrases the release notes use as group labels rather than as the headline of a change:
// "**Fixes.**" heads the list of fixes, "**Fixes applied while landing.**" the review fixes.
const SECTION_LABELS =
/^(?:thanks\b.*|fixes|fixes applied while landing|docs|docs and tests|new features|also in this release)$/i;
/** 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')) {
// `changeset version` prefixes the first line of a committed changeset with its commit
// ("- d18deed: **Lead.** text"), which is the shape every changesets-bot release has.
const m = /^\s*(?:[-*]\s+)?(?:[0-9a-f]{7,40}:\s+)?\*\*([^*]{3,120})\*\*/.exec(line);
if (!m) continue;
const text = m[1].replace(/[\s.:]+$/, '').trim();
if (!text || SECTION_LABELS.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)) };
}
+2
View File
@@ -4834,6 +4834,8 @@ class CodemanApp {
}
}
if (data.version) this.checkWhatsNew?.(data.version);
// Stop any active voice recording on reconnect
VoiceInput.cleanup();
+6
View File
@@ -842,6 +842,8 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
"See what's new": '查看更新内容',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
@@ -1290,6 +1292,10 @@
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[
/^Codeman updated to v([^:\s]+): (.+?)(?: \(\+(\d+) more\))?$/,
(_m, version, shown, more) => `Codeman 已更新到 v${version}:${shown}${more ? `(另有 ${more} 项)` : ''}`,
],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
+33
View File
@@ -373,6 +373,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();
+13
View File
@@ -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)"
]
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
// @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('reads a lead-in behind the commit prefix changesets writes for a committed changeset', () => {
const section = '- d18deed: **Each release announces itself (#618).** Text.\n- 13e652e: A fix with no lead-in.';
expect(extractHighlights(section)).toEqual(['Each release announces itself (#618)']);
});
it('skips group labels the release notes put in bold (Fixes, Fixes applied while landing)', () => {
const section = [
' **Tab layouts (#538).** Text.',
' **Fixes.**',
' - **Shells reload cleanly (#601).** Text.',
' **Fixes applied while landing.** Remote tabs keep their scrollback.',
].join('\n');
expect(extractHighlights(section)).toEqual(['Tab layouts (#538)', 'Shells reload cleanly (#601)']);
});
it('yields no highlights for a release written without bold lead-ins', () => {
const changelog =
'## 1.32.1\n\n### Patch Changes\n\n- 13e652e: Terminal copy: no gutter on the clipboard (#469).\n';
expect(buildWhatsNew(changelog, '1.32.1')).toEqual({ version: '1.32.1', highlights: [] });
});
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, so a release cannot ship the previous release's notes", () => {
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);
// A release written without bold lead-ins legitimately has none (the client then stays
// quiet), so an empty list must not turn the release commit's CI red.
expect(Array.isArray(notes.highlights)).toBe(true);
});
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)'
);
});
});
describe('the announcement in zh-CN', () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(readFileSync(resolve(root, 'src/web/public/i18n.js'), 'utf8'), dom.getInternalVMContext());
const api = (
dom.window as unknown as { CodemanI18n: { t(s: string): string; configure(o: Record<string, string>): void } }
).CodemanI18n;
api.configure({ language: 'zh-CN' });
it('translates the button and the toast frame, keeping the version and the English highlights', () => {
expect(api.t("See what's new")).toBe('查看更新内容');
expect(api.t('Codeman updated to v1.41.2: Tab layouts (#538) · Split view')).toBe(
'Codeman 已更新到 v1.41.2:Tab layouts (#538) · Split view'
);
expect(api.t('Codeman updated to v1.41.2: A · B · C (+5 more)')).toBe(
'Codeman 已更新到 v1.41.2:A · B · C(另有 5 项)'
);
});
it('builds the strings the pattern expects', () => {
const ui = readFileSync(resolve(root, 'src/web/public/settings-ui.js'), 'utf8');
expect(ui).toContain('`Codeman updated to v${runningVersion}: ${shown}${more}`');
expect(ui).toContain('` (+${notes.highlights.length - 3} more)`');
expect(ui).toContain(`label: "See what's new"`);
});
});