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
5 changes: 5 additions & 0 deletions .changeset/whats-new-on-release.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions scripts/generate-whats-new.mjs
Original file line number Diff line number Diff line change
@@ -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)`);
35 changes: 35 additions & 0 deletions scripts/lib/whats-new.mjs
Original file line number Diff line number Diff line change
@@ -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 `## <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 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)) };
}
2 changes: 2 additions & 0 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4843,6 +4843,8 @@ class CodemanApp {
}
}

if (data.version) this.checkWhatsNew?.(data.version);

// Stop any active voice recording on reconnect
VoiceInput.cleanup();

Expand Down
33 changes: 33 additions & 0 deletions src/web/public/settings-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
13 changes: 13 additions & 0 deletions src/web/public/whats-new.json
Original file line number Diff line number Diff line change
@@ -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)"
]
}
74 changes: 74 additions & 0 deletions test/whats-new.test.ts
Original file line number Diff line number Diff line change
@@ -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)'
);
});
});
Loading