From 3dc357dd09ed374036caefb37ef2f9c8e73d34e2 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Fri, 17 Jul 2026 13:30:15 +0400 Subject: [PATCH 1/6] feat(ui): Changelog browser pilot on the shared design system (U5 B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First screen of the B1 "Code & review" batch, ported from desktop-changelog.html: - libs/ui: new ChangelogView screen — timeline rail grouped by year (semver-type dots, position-qualified selection, aria-current), release feed (version/name head, type badge, typed section icons, sha chips), optional right meta rail on the shared UiMetaSection card family; loading/error/empty states. New shared types: UiRelease/UiReleaseSection/UiReleaseEntry/UiReleaseType/ UiReleaseSectionKind (UiRelease.id is required — real CHANGELOG files repeat versions across format blocks). - Electron: pure changelog-releases parser (keep-a-changelog + the generator's loose/name-suffixed headings; ISO dates formatted in UTC so labels don't shift west of UTC; inline markdown stripped; ids unique under duplicate versions) + ChangelogPilotView over the existing changelog:readExisting IPC, mounted as "Changelog (new UI)" beside the legacy generator wizard. - i18n: new changelog namespace (en+fr), navigation.changelogNext keys, and registration of the previously-unregistered kanban namespace so the Kanban pilot stops rendering raw keys. Adversarial review workflow (18 agents) confirmed 6 defects during the slice — timezone-shifted dates + TZ-flaky test, duplicate-version React keys/ref-map collisions, "Jun 8" Date-parsing into a bogus 2001 year group, paren-eating heading regex, generator name-suffixes rendered as dates, meta-card CSS drift — all fixed and pinned by tests (15 passing, verified under TZ=America/New_York and against this repo's real CHANGELOG.md). Co-Authored-By: Claude Fable 5 --- apps/frontend/src/renderer/App.tsx | 4 + .../__tests__/changelog-releases.test.ts | 213 +++++++++++++ .../components/ChangelogPilotView.tsx | 124 ++++++++ .../src/renderer/components/Sidebar.tsx | 3 +- .../src/renderer/lib/changelog-releases.ts | 196 ++++++++++++ apps/frontend/src/shared/i18n/index.ts | 14 +- .../src/shared/i18n/locales/en/changelog.json | 17 ++ .../shared/i18n/locales/en/navigation.json | 3 +- .../src/shared/i18n/locales/fr/changelog.json | 17 ++ .../shared/i18n/locales/fr/navigation.json | 3 +- libs/ui/src/client/types.ts | 45 +++ libs/ui/src/index.ts | 8 + libs/ui/src/screens/ChangelogView.css | 287 ++++++++++++++++++ libs/ui/src/screens/ChangelogView.stories.tsx | 155 ++++++++++ libs/ui/src/screens/ChangelogView.tsx | 255 ++++++++++++++++ 15 files changed, 1338 insertions(+), 6 deletions(-) create mode 100644 apps/frontend/src/renderer/__tests__/changelog-releases.test.ts create mode 100644 apps/frontend/src/renderer/components/ChangelogPilotView.tsx create mode 100644 apps/frontend/src/renderer/lib/changelog-releases.ts create mode 100644 apps/frontend/src/shared/i18n/locales/en/changelog.json create mode 100644 apps/frontend/src/shared/i18n/locales/fr/changelog.json create mode 100644 libs/ui/src/screens/ChangelogView.css create mode 100644 libs/ui/src/screens/ChangelogView.stories.tsx create mode 100644 libs/ui/src/screens/ChangelogView.tsx diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 83a7176f9..cf4fa53d1 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -70,6 +70,7 @@ import { GitHubSetupModal } from './components/GitHubSetupModal'; import { useProjectStore, loadProjects, addProject, initializeProject, removeProject } from './stores/project-store'; import { useTaskStore, loadTasks } from './stores/task-store'; import { KanbanPilotView } from './components/KanbanPilotView'; +import { ChangelogPilotView } from './components/ChangelogPilotView'; import { useSettingsStore, loadSettings, loadProfiles, saveSettings } from './stores/settings-store'; import { useClaudeProfileStore } from './stores/claude-profile-store'; import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store'; @@ -1101,6 +1102,9 @@ export function App() { {activeView === 'changelog' && (activeProjectId || selectedProjectId) && ( )} + {activeView === 'changelog-next' && (activeProjectId || selectedProjectId) && ( + + )} {activeView === 'worktrees' && (activeProjectId || selectedProjectId) && ( )} diff --git a/apps/frontend/src/renderer/__tests__/changelog-releases.test.ts b/apps/frontend/src/renderer/__tests__/changelog-releases.test.ts new file mode 100644 index 000000000..88f1bb78c --- /dev/null +++ b/apps/frontend/src/renderer/__tests__/changelog-releases.test.ts @@ -0,0 +1,213 @@ +/** + * Tests for the CHANGELOG.md → UiRelease[] parser feeding ChangelogView. + */ + +import { describe, expect, it } from 'vitest'; +import { + parseChangelogMarkdown, + releaseTypeOf, + sectionKindFromTitle, + stripInlineMarkdown, +} from '../lib/changelog-releases'; + +const KEEP_A_CHANGELOG = `# Changelog + +All notable changes to this project. + +## [Unreleased] + +### Added +- Command palette with cross-cutting search + +## [2.9.0] - 2026-05-22 + +### Added +- i18n parity enforced in CI (a4f8e92) +- Onboarding readiness flow + +### Fixed +- Sidebar no longer leaks raw keys (e6df2b8) + +### Documentation +- Token guidelines in CONTRIBUTING.md + +## [2.8.3] - 2026-05-14 + +### Fixed +- Windows path separator fix + +## [2.0.0] - 2025-12-04 + +### Breaking Changes +- Dropped legacy config.yaml +`; + +describe('parseChangelogMarkdown', () => { + const releases = parseChangelogMarkdown(KEEP_A_CHANGELOG, 'en-US'); + + it('parses every release with entries, newest first', () => { + expect(releases.map((release) => release.version)).toEqual([ + 'Unreleased', + '2.9.0', + '2.8.3', + '2.0.0', + ]); + }); + + it('classifies release types against the previous release', () => { + expect(releases.map((release) => release.type)).toEqual([ + 'draft', // Unreleased + 'minor', // 2.9.0 vs 2.8.3 + 'minor', // 2.8.3 vs 2.0.0 — the minor version moved + 'major', // 2.0.0 by its own shape (x.0.0), no earlier release + ]); + }); + + it('maps section titles onto canonical kinds, keeping titles verbatim', () => { + const v290 = releases[1]; + expect(v290.sections.map((section) => [section.kind, section.title])).toEqual([ + ['features', 'Added'], + ['fixes', 'Fixed'], + ['docs', 'Documentation'], + ]); + }); + + it('extracts trailing short shas from entries', () => { + const added = releases[1].sections[0]; + expect(added.entries[0]).toEqual({ + text: 'i18n parity enforced in CI', + sha: 'a4f8e92', + }); + expect(added.entries[1]).toEqual({ + text: 'Onboarding readiness flow', + sha: undefined, + }); + }); + + it('formats calendar dates in UTC regardless of host timezone', () => { + const v290 = releases[1]; + expect(v290.dateLabel).toBe('May 22'); + expect(v290.yearLabel).toBe('2026'); + expect(releases[0].dateLabel).toBeUndefined(); + // A Jan 1 release must stay in its own year even west of UTC. + const newYear = parseChangelogMarkdown( + '## [3.0.0] - 2026-01-01\n- entry\n', + 'en-US', + ); + expect(newYear[0].yearLabel).toBe('2026'); + expect(newYear[0].dateLabel).toBe('Jan 1'); + }); + + it('treats non-date heading suffixes as release names, never dates', () => { + const named = parseChangelogMarkdown( + [ + '## 2.7.5 - Security & Platform Improvements', + '- hardened the thing', + '## v2.10.0 - Jun 8 (planned)', + '- big feature', + ].join('\n'), + 'en-US', + ); + expect(named[0].name).toBe('Security & Platform Improvements'); + expect(named[0].dateLabel).toBeUndefined(); + expect(named[0].yearLabel).toBeUndefined(); + // "Jun 8" must not Date-parse into a bogus 2001 year group. + expect(named[1].name).toBe('Jun 8 (planned)'); + expect(named[1].yearLabel).toBeUndefined(); + }); + + it('assigns position-qualified unique ids even for duplicate versions', () => { + const duplicated = parseChangelogMarkdown( + [ + '## [2.7.5] - 2026-01-27', + '- from the keep-a-changelog block', + '## 2.7.5 - Security & Platform Improvements', + '- from the generator block', + ].join('\n'), + 'en-US', + ); + expect(duplicated.map((release) => release.version)).toEqual([ + '2.7.5', + '2.7.5', + ]); + const ids = duplicated.map((release) => release.id); + expect(new Set(ids).size).toBe(2); + }); + + it('parses loose heading variants and entries without section headings', () => { + const loose = parseChangelogMarkdown( + `## v1.2.0 (2026-01-05)\n- did a thing\n- did another (deadbeef)\n`, + 'en-US', + ); + expect(loose).toHaveLength(1); + expect(loose[0].version).toBe('v1.2.0'); + expect(loose[0].dateLabel).toBe('Jan 5'); + expect(loose[0].sections).toEqual([ + { + kind: 'other', + title: '', + entries: [ + { text: 'did a thing', sha: undefined }, + { text: 'did another', sha: 'deadbeef' }, + ], + }, + ]); + }); + + it('strips inline markdown from entry texts', () => { + const rich = parseChangelogMarkdown( + '## [1.1.0] - 2026-02-02\n- **Bold** fix for [the docs](https://x.test) via `npm ci`\n', + 'en-US', + ); + expect(rich[0].sections[0].entries[0].text).toBe( + 'Bold fix for the docs via npm ci', + ); + }); + + it('drops releases without entries and survives malformed input', () => { + expect(parseChangelogMarkdown('')).toEqual([]); + expect(parseChangelogMarkdown('## [1.0.0] - 2026-01-01\n\nno bullets')).toEqual([]); + expect(parseChangelogMarkdown('just some prose\n- floating bullet')).toEqual([]); + }); +}); + +describe('releaseTypeOf', () => { + it('compares against the previous release when available', () => { + expect(releaseTypeOf('2.9.0', '2.8.3')).toBe('minor'); + expect(releaseTypeOf('3.0.0', '2.9.0')).toBe('major'); + expect(releaseTypeOf('2.8.3', '2.8.2')).toBe('patch'); + }); + + it('falls back to the version shape without a comparison point', () => { + expect(releaseTypeOf('2.0.0')).toBe('major'); + expect(releaseTypeOf('2.9.0')).toBe('minor'); + expect(releaseTypeOf('2.8.3')).toBe('patch'); + }); + + it('handles drafts and unparseable versions', () => { + expect(releaseTypeOf('Unreleased')).toBe('draft'); + expect(releaseTypeOf('next')).toBe('patch'); + }); +}); + +describe('sectionKindFromTitle', () => { + it('maps common titles and defaults to other', () => { + expect(sectionKindFromTitle('Added')).toBe('features'); + expect(sectionKindFromTitle('Features')).toBe('features'); + expect(sectionKindFromTitle('Fixed')).toBe('fixes'); + expect(sectionKindFromTitle('Bug Fixes')).toBe('fixes'); + expect(sectionKindFromTitle('Breaking Changes')).toBe('breaking'); + expect(sectionKindFromTitle('Docs')).toBe('docs'); + expect(sectionKindFromTitle('Security')).toBe('other'); + }); +}); + +describe('stripInlineMarkdown', () => { + it('unwraps links, emphasis, and code spans', () => { + expect(stripInlineMarkdown('[label](https://x.test)')).toBe('label'); + expect(stripInlineMarkdown('**bold** and _em_ and `code`')).toBe( + 'bold and em and code', + ); + expect(stripInlineMarkdown('plain text stays')).toBe('plain text stays'); + }); +}); diff --git a/apps/frontend/src/renderer/components/ChangelogPilotView.tsx b/apps/frontend/src/renderer/components/ChangelogPilotView.tsx new file mode 100644 index 000000000..cb7d56209 --- /dev/null +++ b/apps/frontend/src/renderer/components/ChangelogPilotView.tsx @@ -0,0 +1,124 @@ +/** + * Changelog pilot on the shared design system (U5 B1). + * + * Renders `libs/ui`'s ChangelogView from the project's CHANGELOG.md, read + * over the existing `changelog:readExisting` IPC and parsed with the pure + * `changelog-releases` helper. Read-only browser — the generator wizard + * stays on the legacy Changelog view. Reachable via the + * "Changelog (new UI)" sidebar item next to it. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChangelogView } from '@auto-code/ui'; +import type { UiMetaSection, UiRelease, UiReleaseType } from '@auto-code/ui'; +import { parseChangelogMarkdown } from '../lib/changelog-releases'; + +interface ChangelogPilotViewProps { + projectId: string; +} + +interface PilotState { + releases: UiRelease[] | null; + loading: boolean; + error: Error | null; +} + +export function ChangelogPilotView({ + projectId, +}: Readonly) { + const { t, i18n } = useTranslation(['changelog']); + const [state, setState] = useState({ + releases: null, + loading: true, + error: null, + }); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + let active = true; + setState({ releases: null, loading: true, error: null }); + window.electronAPI + .readExistingChangelog(projectId) + .then((result) => { + if (!active) return; + if (!result.success) { + setState({ + releases: null, + loading: false, + error: new Error(result.error ?? t('changelog:pilot.error')), + }); + return; + } + const content = result.data?.content ?? ''; + setState({ + releases: parseChangelogMarkdown(content, i18n.language), + loading: false, + error: null, + }); + }) + .catch((err: unknown) => { + if (!active) return; + setState({ + releases: null, + loading: false, + error: err instanceof Error ? err : new Error(String(err)), + }); + }); + return () => { + active = false; + }; + }, [projectId, reloadKey, i18n.language, t]); + + const reload = useCallback(() => setReloadKey((key) => key + 1), []); + + const typeLabels = useMemo>>( + () => ({ + major: t('changelog:pilot.types.major'), + minor: t('changelog:pilot.types.minor'), + patch: t('changelog:pilot.types.patch'), + draft: t('changelog:pilot.types.draft'), + }), + [t], + ); + + const metaSections = useMemo(() => { + const latest = state.releases?.find((release) => release.type !== 'draft'); + if (latest == null) return undefined; + return [ + { + title: t('changelog:pilot.meta.latestTitle'), + rows: [ + { label: t('changelog:pilot.meta.version'), value: latest.version }, + ...(latest.dateLabel != null + ? [ + { + label: t('changelog:pilot.meta.released'), + value: [latest.dateLabel, latest.yearLabel] + .filter(Boolean) + .join(' '), + }, + ] + : []), + { + label: t('changelog:pilot.meta.releases'), + value: String(state.releases?.length ?? 0), + }, + ], + }, + ]; + }, [state.releases, t]); + + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index c62e87b58..dd9d32637 100644 --- a/apps/frontend/src/renderer/components/Sidebar.tsx +++ b/apps/frontend/src/renderer/components/Sidebar.tsx @@ -71,7 +71,7 @@ import { SessionContextIndicator } from './SessionContextIndicator'; import { NavIndicator } from './NavIndicator'; import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig } from '../../shared/types'; -export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'productivity' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'model-usage' | 'agent-inspector'; +export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'productivity' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'model-usage' | 'agent-inspector'; interface SidebarProps { onSettingsClick: () => void; @@ -96,6 +96,7 @@ const baseNavItems: NavItem[] = [ { id: 'roadmap', labelKey: 'navigation:items.roadmap', icon: MapIcon, shortcut: 'D' }, { id: 'ideation', labelKey: 'navigation:items.ideation', icon: Lightbulb, shortcut: 'I' }, { id: 'changelog', labelKey: 'navigation:items.changelog', icon: FileText, shortcut: 'L' }, + { id: 'changelog-next', labelKey: 'navigation:items.changelogNext', icon: FileText }, { id: 'scheduler', labelKey: 'navigation:items.scheduler', icon: Calendar, shortcut: 'S' }, { id: 'context', labelKey: 'navigation:items.context', icon: BookOpen, shortcut: 'C' }, { id: 'webhooks', labelKey: 'navigation:items.webhooks', icon: Webhook }, diff --git a/apps/frontend/src/renderer/lib/changelog-releases.ts b/apps/frontend/src/renderer/lib/changelog-releases.ts new file mode 100644 index 000000000..9fceb9dff --- /dev/null +++ b/apps/frontend/src/renderer/lib/changelog-releases.ts @@ -0,0 +1,196 @@ +/** + * Parses a CHANGELOG.md body into the shared UiRelease[] shape for the + * ChangelogView screen. Pure and defensive: the file is user-editable and + * format drift is expected, so unrecognized lines are ignored rather than + * thrown on. Handles keep-a-changelog (`## [1.2.3] - 2026-05-22`) plus the + * loose `## v1.2.3 (2026-05-22)` and name-suffixed + * `## 1.2.3 - Some Release Name` variants this project's generator emits. + */ + +import type { + UiRelease, + UiReleaseSection, + UiReleaseSectionKind, + UiReleaseType, +} from '@auto-code/ui'; + +/** `## [1.2.3] …`, `## v1.2.3 …`, `## Unreleased` — suffix parsed separately. */ +const RELEASE_HEADING = + /^##\s+\[?(?unreleased|v?\d+\.\d+(?:\.\d+)?[^\]\s]*)\]?(?.*)$/i; + +const SECTION_HEADING = /^###\s+(?.+?)\s*$/; + +const ENTRY_LINE = /^[-*]\s+(?<text>.+)$/; + +/** Trailing short-sha reference: "… fix the thing (a4f8e92)". */ +const TRAILING_SHA = /\s*\((?<sha>[0-9a-f]{7,40})\)\s*$/i; + +/** Calendar dates only — anything else is a release name, not a date. */ +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +const SECTION_KINDS: ReadonlyArray<[RegExp, UiReleaseSectionKind]> = [ + [/break/i, 'breaking'], + [/^(added|features?|new)/i, 'features'], + [/^(fixed|fixes|bug ?fixes)/i, 'fixes'], + [/^(docs|documentation)/i, 'docs'], +]; + +export function sectionKindFromTitle(title: string): UiReleaseSectionKind { + for (const [pattern, kind] of SECTION_KINDS) { + if (pattern.test(title)) return kind; + } + return 'other'; +} + +/** + * Drop inline markdown so entries render as plain text: links keep their + * label, emphasis/backtick markers are stripped. + */ +export function stripInlineMarkdown(text: string): string { + return text + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/(\*\*|__)(.*?)\1/g, '$2') + .replace(/(\*|_)([^*_]+)\1/g, '$2') + .replace(/`([^`]*)`/g, '$1') + .trim(); +} + +interface HeadingSuffix { + dateRaw?: string; + name?: string; +} + +/** + * Interpret the text after the version: an ISO calendar date becomes the + * release date; anything else non-empty is the release name, verbatim. + */ +function parseHeadingSuffix(rest: string): HeadingSuffix { + let text = rest.trim(); + // Version-link tail from generators: `[1.2.3](https://…/compare/…)`. + text = text.replace(/^\((?:https?:\/\/)[^)]*\)\s*/, ''); + // Leading `-` / `–` / `—` separator. + text = text.replace(/^[-–—]\s*/, ''); + // Fully parenthesized suffix: `(2026-01-05)`. + const wrapped = /^\(([^()]*)\)$/.exec(text); + if (wrapped != null) text = wrapped[1].trim(); + if (text === '') return {}; + if (ISO_DATE.test(text)) return { dateRaw: text }; + return { name: stripInlineMarkdown(text) }; +} + +interface RawRelease { + version: string; + name?: string; + dateRaw?: string; + sections: UiReleaseSection[]; +} + +function parseSemver(version: string): [number, number, number] | null { + const match = /^v?(\d+)\.(\d+)(?:\.(\d+))?/.exec(version); + if (match == null) return null; + return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]; +} + +/** + * Classify a release against the one released before it (next in the + * newest-first file order). Falls back to the version's own shape when + * there is nothing to compare against. + */ +export function releaseTypeOf(version: string, previous?: string): UiReleaseType { + if (/unreleased/i.test(version)) return 'draft'; + const own = parseSemver(version); + if (own == null) return 'patch'; + const prev = previous != null ? parseSemver(previous) : null; + if (prev != null) { + if (own[0] !== prev[0]) return 'major'; + if (own[1] !== prev[1]) return 'minor'; + return 'patch'; + } + if (own[1] === 0 && own[2] === 0) return 'major'; + if (own[2] === 0) return 'minor'; + return 'patch'; +} + +interface DateLabels { + dateLabel?: string; + yearLabel?: string; +} + +/** + * Format an ISO calendar date in UTC — changelog dates are calendar dates, + * so local-timezone formatting would shift them a day west of UTC. + */ +function dateLabelsOf(dateRaw: string | undefined, locale?: string): DateLabels { + if (dateRaw == null || !ISO_DATE.test(dateRaw)) return {}; + const parsed = new Date(dateRaw); + if (Number.isNaN(parsed.getTime())) return {}; + return { + dateLabel: parsed.toLocaleDateString(locale, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + yearLabel: String(parsed.getUTCFullYear()), + }; +} + +export function parseChangelogMarkdown( + content: string, + locale?: string, +): UiRelease[] { + const raw: RawRelease[] = []; + let release: RawRelease | null = null; + let section: UiReleaseSection | null = null; + + for (const line of content.split(/\r?\n/)) { + const releaseMatch = RELEASE_HEADING.exec(line); + if (releaseMatch?.groups != null) { + release = { + version: releaseMatch.groups.version, + ...parseHeadingSuffix(releaseMatch.groups.rest), + sections: [], + }; + section = null; + raw.push(release); + continue; + } + if (release == null) continue; + + const sectionMatch = SECTION_HEADING.exec(line); + if (sectionMatch?.groups != null) { + const title = stripInlineMarkdown(sectionMatch.groups.title); + section = { kind: sectionKindFromTitle(title), title, entries: [] }; + release.sections.push(section); + continue; + } + + const entryMatch = ENTRY_LINE.exec(line.trim()); + if (entryMatch?.groups != null) { + // Entries before any `###` heading land in an untitled default section. + if (section == null) { + section = { kind: 'other', title: '', entries: [] }; + release.sections.push(section); + } + const shaMatch = TRAILING_SHA.exec(entryMatch.groups.text); + section.entries.push({ + text: stripInlineMarkdown( + entryMatch.groups.text.replace(TRAILING_SHA, ''), + ), + sha: shaMatch?.groups?.sha, + }); + } + } + + return raw + .filter((item) => item.sections.some((s) => s.entries.length > 0)) + .map((item, index, all) => ({ + // CHANGELOG files can repeat a version (e.g. a keep-a-changelog block + // and a generator block for the same release) — qualify by position. + id: `${item.version}#${index}`, + version: item.version, + name: item.name, + type: releaseTypeOf(item.version, all[index + 1]?.version), + ...dateLabelsOf(item.dateRaw, locale), + sections: item.sections.filter((s) => s.entries.length > 0), + })); +} diff --git a/apps/frontend/src/shared/i18n/index.ts b/apps/frontend/src/shared/i18n/index.ts index 3fa09c56c..dc74bf7d7 100644 --- a/apps/frontend/src/shared/i18n/index.ts +++ b/apps/frontend/src/shared/i18n/index.ts @@ -21,6 +21,8 @@ import enCodeReview from './locales/en/codeReview.json'; import enQuality from './locales/en/quality.json'; import enAgentInspector from './locales/en/agent-inspector.json'; import enTimeline from './locales/en/timeline.json'; +import enKanban from './locales/en/kanban.json'; +import enChangelog from './locales/en/changelog.json'; // Import French translation resources import frCommon from './locales/fr/common.json'; @@ -42,6 +44,8 @@ import frCodeReview from './locales/fr/codeReview.json'; import frQuality from './locales/fr/quality.json'; import frAgentInspector from './locales/fr/agent-inspector.json'; import frTimeline from './locales/fr/timeline.json'; +import frKanban from './locales/fr/kanban.json'; +import frChangelog from './locales/fr/changelog.json'; export const defaultNS = 'common'; @@ -65,7 +69,9 @@ export const resources = { codeReview: enCodeReview, quality: enQuality, 'agent-inspector': enAgentInspector, - timeline: enTimeline + timeline: enTimeline, + kanban: enKanban, + changelog: enChangelog }, fr: { common: frCommon, @@ -86,7 +92,9 @@ export const resources = { codeReview: frCodeReview, quality: frQuality, 'agent-inspector': frAgentInspector, - timeline: frTimeline + timeline: frTimeline, + kanban: frKanban, + changelog: frChangelog } } as const; @@ -97,7 +105,7 @@ i18n lng: 'en', // Default language (will be overridden by settings) fallbackLng: 'en', defaultNS, - ns: ['common', 'navigation', 'settings', 'security', 'tasks', 'welcome', 'onboarding', 'dialogs', 'github', 'gitlab', 'taskReview', 'terminal', 'errors', 'analytics', 'model-usage', 'codeReview', 'quality', 'agent-inspector', 'timeline'], + ns: ['common', 'navigation', 'settings', 'security', 'tasks', 'welcome', 'onboarding', 'dialogs', 'github', 'gitlab', 'taskReview', 'terminal', 'errors', 'analytics', 'model-usage', 'codeReview', 'quality', 'agent-inspector', 'timeline', 'kanban', 'changelog'], interpolation: { escapeValue: false // React already escapes values }, diff --git a/apps/frontend/src/shared/i18n/locales/en/changelog.json b/apps/frontend/src/shared/i18n/locales/en/changelog.json new file mode 100644 index 000000000..4a119cf4e --- /dev/null +++ b/apps/frontend/src/shared/i18n/locales/en/changelog.json @@ -0,0 +1,17 @@ +{ + "pilot": { + "error": "Failed to read CHANGELOG.md", + "types": { + "major": "Major", + "minor": "Minor", + "patch": "Patch", + "draft": "Draft" + }, + "meta": { + "latestTitle": "Latest release", + "version": "Version", + "released": "Released", + "releases": "Releases" + } + } +} diff --git a/apps/frontend/src/shared/i18n/locales/en/navigation.json b/apps/frontend/src/shared/i18n/locales/en/navigation.json index a52707c2a..c9539a3f3 100644 --- a/apps/frontend/src/shared/i18n/locales/en/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/en/navigation.json @@ -29,7 +29,8 @@ "agentInspector": "Agent Inspector", "feedback": "Feedback", "modelUsage": "Model Usage", - "kanbanNext": "Kanban (New UI)" + "kanbanNext": "Kanban (New UI)", + "changelogNext": "Changelog (New UI)" }, "actions": { "settings": "Settings", diff --git a/apps/frontend/src/shared/i18n/locales/fr/changelog.json b/apps/frontend/src/shared/i18n/locales/fr/changelog.json new file mode 100644 index 000000000..56b6ef781 --- /dev/null +++ b/apps/frontend/src/shared/i18n/locales/fr/changelog.json @@ -0,0 +1,17 @@ +{ + "pilot": { + "error": "Échec de la lecture de CHANGELOG.md", + "types": { + "major": "Majeure", + "minor": "Mineure", + "patch": "Correctif", + "draft": "Brouillon" + }, + "meta": { + "latestTitle": "Dernière version", + "version": "Version", + "released": "Publiée", + "releases": "Versions" + } + } +} diff --git a/apps/frontend/src/shared/i18n/locales/fr/navigation.json b/apps/frontend/src/shared/i18n/locales/fr/navigation.json index ce4692c96..7f6a95fab 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/fr/navigation.json @@ -29,7 +29,8 @@ "agentInspector": "Inspecteur d'Agent", "feedback": "Retour d'information", "modelUsage": "Utilisation des Modèles", - "kanbanNext": "Kanban (nouvelle interface)" + "kanbanNext": "Kanban (nouvelle interface)", + "changelogNext": "Journal des modifications (nouvelle interface)" }, "actions": { "settings": "Paramètres", diff --git a/libs/ui/src/client/types.ts b/libs/ui/src/client/types.ts index 15f912f58..db2a67896 100644 --- a/libs/ui/src/client/types.ts +++ b/libs/ui/src/client/types.ts @@ -84,3 +84,48 @@ export interface CreateTaskInput { name: string; description: string; } + +/** Semver bump class of a release; drives the timeline dot + badge color. */ +export type UiReleaseType = 'major' | 'minor' | 'patch' | 'draft'; + +/** Canonical section kinds; drive the section icon. Unknown kinds -> 'other'. */ +export type UiReleaseSectionKind = + | 'features' + | 'fixes' + | 'breaking' + | 'docs' + | 'other'; + +export interface UiReleaseEntry { + text: string; + /** Short commit sha shown after the entry, when known. */ + sha?: string; +} + +export interface UiReleaseSection { + kind: UiReleaseSectionKind; + /** Display title, verbatim from the source (e.g. "Added", "Fixes"). */ + title: string; + entries: UiReleaseEntry[]; +} + +/** One release in the changelog feed. Adapters own all label formatting. */ +export interface UiRelease { + /** + * Unique key for React identity and selection. Version alone is not + * enough — real CHANGELOG files repeat versions across format blocks. + */ + id: string; + /** Display version including any prefix (e.g. "v2.9.0", "Unreleased"). */ + version: string; + /** Optional release name shown after the version. */ + name?: string; + type: UiReleaseType; + /** Short date label for the timeline rail (e.g. "May 22"). */ + dateLabel?: string; + /** Year group header in the timeline rail (e.g. "2026"). */ + yearLabel?: string; + /** Extra meta strings shown dot-separated in the release head. */ + meta?: readonly string[]; + sections: UiReleaseSection[]; +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index ddc19ddb2..c0692c383 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -37,6 +37,11 @@ export type { UiSubtaskStatus, UiMetaRow, UiMetaSection, + UiRelease, + UiReleaseEntry, + UiReleaseSection, + UiReleaseSectionKind, + UiReleaseType, CreateTaskInput, TaskStatus, BadgeTone, @@ -66,3 +71,6 @@ export { filterUiTasks } from './client/filtering'; export type { UiTaskFilter } from './client/filtering'; export { useBoardFilter, FILTER_IDS } from './client/useBoardFilter'; export type { UseBoardFilterResult, FilterId } from './client/useBoardFilter'; +// Changelog browser (U5 B1 pilot) +export { ChangelogView } from './screens/ChangelogView'; +export type { ChangelogViewProps } from './screens/ChangelogView'; diff --git a/libs/ui/src/screens/ChangelogView.css b/libs/ui/src/screens/ChangelogView.css new file mode 100644 index 000000000..15040bd92 --- /dev/null +++ b/libs/ui/src/screens/ChangelogView.css @@ -0,0 +1,287 @@ +/* Changelog browser ported from .lazyweb/mockups/desktop-changelog.html: + timeline rail | release feed | optional meta rail. */ + +.ac-changelog { + font-family: var(--font-sans); + color: var(--ink); + height: 100%; + display: flex; + flex-direction: column; +} + +.ac-changelog__state { + color: var(--muted); + font-size: 14px; + padding: 20px 24px; +} +.ac-changelog__state--error { + color: var(--red); +} +.ac-changelog__retry { + font-family: inherit; + margin-left: 8px; + font-size: 13px; + color: var(--blue); + background: transparent; + border: 1px solid var(--line); + border-radius: 6px; + padding: 2px 10px; + cursor: pointer; +} + +.ac-changelog__body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 200px minmax(0, 1fr) 300px; + overflow: hidden; +} +.ac-changelog__body--no-rail { + grid-template-columns: 200px minmax(0, 1fr); +} + +/* --- Timeline rail --- */ + +.ac-changelog__timeline { + border-right: 1px solid var(--line); + background: var(--panel); + overflow-y: auto; + padding: 14px 0; +} +.ac-changelog__year + .ac-changelog__year { + border-top: 1px solid var(--line); + margin-top: 10px; + padding-top: 10px; +} +.ac-changelog__year-h { + margin: 0 0 4px; + padding: 0 22px; + font-size: 10.5px; + font-weight: 720; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--quiet); +} +.ac-changelog__rel-row { + position: relative; + display: grid; + grid-template-columns: 12px minmax(0, 1fr); + gap: 12px; + align-items: center; + width: 100%; + padding: 6px 22px; + font: inherit; + text-align: left; + color: var(--muted); + background: transparent; + border: none; + cursor: pointer; +} +.ac-changelog__rel-row:hover { + color: var(--ink); +} +.ac-changelog__rel-row:focus-visible { + outline: 2px solid var(--blue); + outline-offset: -2px; +} +.ac-changelog__rel-row--on { + color: var(--ink); + font-weight: 720; +} +.ac-changelog__rel-row--on::before { + content: ''; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 3px; + background: var(--ink); +} +.ac-changelog__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--quiet); +} +.ac-changelog__dot--major { + background: var(--blue); +} +.ac-changelog__dot--minor { + background: var(--green); +} +.ac-changelog__dot--patch { + background: var(--amber); +} +.ac-changelog__rel-text { + display: flex; + flex-direction: column; +} +.ac-changelog__rel-ver { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; +} +.ac-changelog__rel-date { + font-size: 10.5px; + color: var(--quiet); +} + +/* --- Release feed --- */ + +.ac-changelog__feed { + overflow-y: auto; + padding: 22px 28px 40px; +} +.ac-changelog__release { + padding-bottom: 28px; + margin-bottom: 28px; + border-bottom: 1px solid var(--line); +} +.ac-changelog__release:last-child { + padding-bottom: 0; + margin-bottom: 0; + border-bottom: none; +} +.ac-changelog__release-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 12px; +} +.ac-changelog__release-title { + margin: 0; + font-size: 24px; + font-weight: 780; +} +.ac-changelog__release-ver { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--blue); + margin-right: 10px; +} +.ac-changelog__release-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 6px; + font-size: 12.5px; + color: var(--muted); +} +.ac-changelog__meta-item + .ac-changelog__meta-item::before { + content: '·'; + margin-right: 8px; + color: var(--quiet); +} + +.ac-changelog__section { + margin-top: 16px; +} +.ac-changelog__section-h { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 6px; + font-size: 13px; + font-weight: 780; +} +.ac-changelog__ico { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 5px; + font-size: 11px; + font-weight: 780; +} +.ac-changelog__ico--features { + color: var(--blue); + background: var(--blue-soft); +} +.ac-changelog__ico--fixes { + color: var(--amber); + background: var(--amber-soft); +} +.ac-changelog__ico--breaking { + color: var(--red); + background: var(--red-soft); +} +.ac-changelog__ico--docs { + color: var(--green); + background: var(--green-soft); +} +.ac-changelog__ico--other { + color: var(--muted); + background: var(--rail); +} +.ac-changelog__entries { + margin: 0; + padding-left: 20px; + font-size: 13px; + line-height: 1.6; +} +.ac-changelog__sha { + margin-left: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + color: var(--quiet); + background: var(--soft); + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 5px; +} + +/* --- Right meta rail (same card/kv family as the task detail rail) --- */ + +.ac-changelog__rail { + border-left: 1px solid var(--line); + background: var(--soft); + overflow-y: auto; + padding: 14px; + display: grid; + gap: 14px; + align-content: start; +} +.ac-changelog__card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px 14px; +} +.ac-changelog__card h3 { + margin: 0 0 6px; + font-size: 13px; + font-weight: 760; +} +.ac-changelog__kv { + display: grid; + grid-template-columns: 100px minmax(0, 1fr); + gap: 8px; + padding: 2px 0; + font-size: 12.5px; +} +.ac-changelog__kv span { + color: var(--quiet); + font-weight: 720; +} +.ac-changelog__kv strong { + font-weight: 720; + overflow-wrap: anywhere; +} + +@media (max-width: 1280px) { + .ac-changelog__body { + grid-template-columns: 180px minmax(0, 1fr); + } + .ac-changelog__rail { + display: none; + } +} +@media (max-width: 1080px) { + .ac-changelog__body, + .ac-changelog__body--no-rail { + grid-template-columns: minmax(0, 1fr); + } + .ac-changelog__timeline { + display: none; + } +} diff --git a/libs/ui/src/screens/ChangelogView.stories.tsx b/libs/ui/src/screens/ChangelogView.stories.tsx new file mode 100644 index 000000000..6e1ec4dea --- /dev/null +++ b/libs/ui/src/screens/ChangelogView.stories.tsx @@ -0,0 +1,155 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ChangelogView } from './ChangelogView'; + +const meta = { + title: 'Screens/ChangelogView', + component: ChangelogView, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( + <div style={{ height: '100vh' }}> + <Story /> + </div> + ), + ], + args: { + loading: false, + error: null, + releases: [ + { + id: 'v2.10.0#0', + version: 'v2.10.0', + name: 'Drafting', + type: 'draft', + dateLabel: 'Jun 8 (planned)', + yearLabel: '2026', + meta: ['7 PRs queued', '1 spec running'], + sections: [ + { + kind: 'features', + title: 'Planned features', + entries: [ + { text: 'New desktop shell: shared AppShell + collapsible sidebar' }, + { text: 'Dark theme with system preference detection and per-user override' }, + { text: 'Command palette (⌘K) with cross-cutting search' }, + ], + }, + ], + }, + { + id: 'v2.9.0#1', + version: 'v2.9.0', + name: 'The sturdy-foundation release', + type: 'minor', + dateLabel: 'May 22', + yearLabel: '2026', + meta: ['Released May 22', '18 PRs · 2 contributors'], + sections: [ + { + kind: 'features', + title: 'Features', + entries: [ + { + text: 'i18n parity enforced in CI, screenshot guard rejects raw keys', + sha: 'a4f8e92', + }, + { + text: 'Onboarding readiness flow: stable widths, unclipped header', + sha: 'f3c91d0', + }, + ], + }, + { + kind: 'fixes', + title: 'Fixes', + entries: [ + { + text: 'Sidebar no longer leaks raw navigation keys offline at boot', + sha: 'e6df2b8', + }, + { + text: 'QA fixer accepts mobile screenshots from any viewport', + sha: '7e88a01', + }, + ], + }, + { + kind: 'docs', + title: 'Docs', + entries: [{ text: 'Token usage guidelines added to CONTRIBUTING.md' }], + }, + ], + }, + { + id: 'v2.8.3#2', + version: 'v2.8.3', + type: 'patch', + dateLabel: 'May 14', + yearLabel: '2026', + sections: [ + { + kind: 'fixes', + title: 'Fixes', + entries: [ + { text: 'Windows path separator fix in the i18n parity script' }, + ], + }, + ], + }, + { + id: 'v2.0.0#3', + version: 'v2.0.0', + type: 'major', + dateLabel: 'Dec 4', + yearLabel: '2025', + sections: [ + { + kind: 'breaking', + title: 'Breaking changes', + entries: [ + { text: 'Dropped legacy .auto-claude/config.yaml — use settings.json' }, + ], + }, + ], + }, + ], + metaSections: [ + { + title: 'Latest release', + rows: [ + { label: 'Version', value: 'v2.9.0' }, + { label: 'Channel', value: 'stable' }, + { label: 'Released', value: 'May 22 · 4 days ago' }, + ], + }, + { + title: 'Suggested next', + rows: [ + { label: 'Queued', value: '7 PRs for v2.10.0' }, + { label: 'Freeze', value: 'Jun 8' }, + ], + }, + ], + }, +} satisfies Meta<typeof ChangelogView>; + +export default meta; +type Story = StoryObj<typeof meta>; + +export const ReleaseHistory: Story = {}; + +export const NoMetaRail: Story = { + args: { metaSections: undefined }, +}; + +export const Loading: Story = { + args: { releases: null, loading: true }, +}; + +export const ErrorState: Story = { + args: { releases: null, error: new Error('Failed to read CHANGELOG.md') }, +}; + +export const Empty: Story = { + args: { releases: [], metaSections: undefined }, +}; diff --git a/libs/ui/src/screens/ChangelogView.tsx b/libs/ui/src/screens/ChangelogView.tsx new file mode 100644 index 000000000..a32610a68 --- /dev/null +++ b/libs/ui/src/screens/ChangelogView.tsx @@ -0,0 +1,255 @@ +import { useRef, useState } from 'react'; +import { Badge } from '../primitives/Badge'; +import type { + BadgeTone, + UiMetaSection, + UiRelease, + UiReleaseSectionKind, + UiReleaseType, +} from '../client/types'; +import './ChangelogView.css'; + +const TYPE_LABELS: Record<UiReleaseType, string> = { + major: 'Major', + minor: 'Minor', + patch: 'Patch', + draft: 'Draft', +}; + +const TYPE_TONES: Record<UiReleaseType, BadgeTone> = { + major: 'info', + minor: 'good', + patch: 'warn', + draft: 'neutral', +}; + +const SECTION_GLYPHS: Record<UiReleaseSectionKind, string> = { + features: '+', + fixes: '!', + breaking: '⚠', + docs: 'd', + other: '·', +}; + +export interface ChangelogViewProps { + releases: UiRelease[] | null; + loading?: boolean; + error?: Error | null; + onRetry?: () => void; + /** Localized release-type badge labels; falls back to English. */ + typeLabels?: Partial<Record<UiReleaseType, string>>; + /** Right-rail meta cards (latest release, cadence, …), when available. */ + metaSections?: UiMetaSection[]; +} + +/** + * Presentational changelog browser mirroring the `.lazyweb` mockup: a + * timeline rail (releases grouped by year, semver-type dots) next to a + * scrollable feed of release articles, plus an optional right meta rail. + * Data-agnostic — pair with an adapter that parses the project's + * CHANGELOG.md (or a release API) into UiRelease[]. + */ +export function ChangelogView({ + releases, + loading = false, + error = null, + onRetry, + typeLabels, + metaSections, +}: Readonly<ChangelogViewProps>) { + return ( + <section className="ac-changelog"> + {loading && <p className="ac-changelog__state">Loading…</p>} + + {!loading && error != null && ( + <p className="ac-changelog__state ac-changelog__state--error" role="alert"> + {error.message} + {onRetry != null && ( + <button + type="button" + className="ac-changelog__retry" + onClick={onRetry} + > + Retry + </button> + )} + </p> + )} + + {!loading && error == null && (releases == null || releases.length === 0) && ( + <p className="ac-changelog__state">No releases yet.</p> + )} + + {!loading && error == null && releases != null && releases.length > 0 && ( + <ChangelogBody + releases={releases} + typeLabels={typeLabels} + metaSections={metaSections} + /> + )} + </section> + ); +} + +interface ChangelogBodyProps { + releases: UiRelease[]; + typeLabels?: Partial<Record<UiReleaseType, string>>; + metaSections?: UiMetaSection[]; +} + +interface YearGroup { + year: string; + releases: UiRelease[]; +} + +/** Group consecutive releases by year label, preserving feed order. */ +function groupByYear(releases: UiRelease[]): YearGroup[] { + const groups: YearGroup[] = []; + for (const release of releases) { + const year = release.yearLabel ?? ''; + const last = groups[groups.length - 1]; + if (last != null && last.year === year) { + last.releases.push(release); + } else { + groups.push({ year, releases: [release] }); + } + } + return groups; +} + +function ChangelogBody({ + releases, + typeLabels, + metaSections, +}: Readonly<ChangelogBodyProps>) { + const [selected, setSelected] = useState(releases[0].id); + const articleRefs = useRef(new Map<string, HTMLElement>()); + const groups = groupByYear(releases); + const sections = metaSections ?? []; + + const open = (id: string) => { + setSelected(id); + articleRefs.current + .get(id) + ?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }; + + return ( + <div + className={`ac-changelog__body${ + sections.length > 0 ? '' : ' ac-changelog__body--no-rail' + }`} + > + <nav className="ac-changelog__timeline"> + {groups.map((group) => ( + <div key={group.year || 'undated'} className="ac-changelog__year"> + {group.year !== '' && ( + <h3 className="ac-changelog__year-h">{group.year}</h3> + )} + {group.releases.map((release) => ( + <button + key={release.id} + type="button" + className={`ac-changelog__rel-row${ + release.id === selected ? ' ac-changelog__rel-row--on' : '' + }`} + aria-current={release.id === selected ? 'true' : undefined} + onClick={() => open(release.id)} + > + <span + className={`ac-changelog__dot ac-changelog__dot--${release.type}`} + /> + <span className="ac-changelog__rel-text"> + <span className="ac-changelog__rel-ver">{release.version}</span> + {release.dateLabel != null && ( + <span className="ac-changelog__rel-date"> + {release.dateLabel} + </span> + )} + </span> + </button> + ))} + </div> + ))} + </nav> + + <div className="ac-changelog__feed"> + {releases.map((release) => ( + <article + key={release.id} + ref={(node) => { + if (node != null) { + articleRefs.current.set(release.id, node); + } else { + articleRefs.current.delete(release.id); + } + }} + className="ac-changelog__release" + > + <header className="ac-changelog__release-head"> + <h2 className="ac-changelog__release-title"> + <span className="ac-changelog__release-ver"> + {release.version} + </span> + {release.name} + </h2> + <div className="ac-changelog__release-meta"> + <Badge tone={TYPE_TONES[release.type]} size="sm"> + {typeLabels?.[release.type] ?? TYPE_LABELS[release.type]} + </Badge> + {(release.meta ?? []).map((item) => ( + <span key={item} className="ac-changelog__meta-item"> + {item} + </span> + ))} + </div> + </header> + + {release.sections.map((section) => ( + <div + key={`${section.kind}:${section.title}`} + className="ac-changelog__section" + > + <h3 className="ac-changelog__section-h"> + <span + aria-hidden="true" + className={`ac-changelog__ico ac-changelog__ico--${section.kind}`} + > + {SECTION_GLYPHS[section.kind]} + </span> + {section.title} + </h3> + <ul className="ac-changelog__entries"> + {section.entries.map((entry, entryIndex) => ( + <li key={`${entryIndex}-${entry.text}`}> + {entry.text} + {entry.sha != null && ( + <code className="ac-changelog__sha">{entry.sha}</code> + )} + </li> + ))} + </ul> + </div> + ))} + </article> + ))} + </div> + + {sections.length > 0 && ( + <aside className="ac-changelog__rail"> + {sections.map((section) => ( + <div key={section.title} className="ac-changelog__card"> + <h3>{section.title}</h3> + {section.rows.map((row) => ( + <div key={row.label} className="ac-changelog__kv"> + <span>{row.label}</span> + <strong>{row.value}</strong> + </div> + ))} + </div> + ))} + </aside> + )} + </div> + ); +} From b68336da2979c1c9bac5799c9546f182916e9bc2 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov <mrobenner@gmail.com> Date: Fri, 17 Jul 2026 20:41:48 +0400 Subject: [PATCH 2/6] fix(ui): linear-time changelog parser regexes + Sonar cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the 8 SonarCloud findings on the pilot: rewrite the parser regexes without ambiguous quantifier overlap (S8786 ×5 — heading, section, entry, trailing-sha, emphasis patterns are now linear-time on adversarial input), split the emphasis alternation into per-marker patterns (S6035), and modernize groupByYear (.at(-1) + optional chain — S7755/S6582). Behavior pinned by the existing 15 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../src/renderer/lib/changelog-releases.ts | 21 ++++++++++--------- libs/ui/src/screens/ChangelogView.tsx | 4 ++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/frontend/src/renderer/lib/changelog-releases.ts b/apps/frontend/src/renderer/lib/changelog-releases.ts index 9fceb9dff..5a58959b2 100644 --- a/apps/frontend/src/renderer/lib/changelog-releases.ts +++ b/apps/frontend/src/renderer/lib/changelog-releases.ts @@ -16,14 +16,14 @@ import type { /** `## [1.2.3] …`, `## v1.2.3 …`, `## Unreleased` — suffix parsed separately. */ const RELEASE_HEADING = - /^##\s+\[?(?<version>unreleased|v?\d+\.\d+(?:\.\d+)?[^\]\s]*)\]?(?<rest>.*)$/i; + /^##\s+\[?(?<version>unreleased|v?\d+\.\d+[^\]\s]*)\]?(?<rest>.*)$/i; -const SECTION_HEADING = /^###\s+(?<title>.+?)\s*$/; +const SECTION_HEADING = /^###\s+(?<title>.+)$/; -const ENTRY_LINE = /^[-*]\s+(?<text>.+)$/; +const ENTRY_LINE = /^[-*]\s+(?<text>\S.*)$/; /** Trailing short-sha reference: "… fix the thing (a4f8e92)". */ -const TRAILING_SHA = /\s*\((?<sha>[0-9a-f]{7,40})\)\s*$/i; +const TRAILING_SHA = /\((?<sha>[0-9a-f]{7,40})\)$/i; /** Calendar dates only — anything else is a release name, not a date. */ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; @@ -49,8 +49,10 @@ export function sectionKindFromTitle(title: string): UiReleaseSectionKind { export function stripInlineMarkdown(text: string): string { return text .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') - .replace(/(\*\*|__)(.*?)\1/g, '$2') - .replace(/(\*|_)([^*_]+)\1/g, '$2') + .replace(/\*\*([^*]*)\*\*/g, '$1') + .replace(/__([^_]*)__/g, '$1') + .replace(/\*([^*]*)\*/g, '$1') + .replace(/_([^_]*)_/g, '$1') .replace(/`([^`]*)`/g, '$1') .trim(); } @@ -171,11 +173,10 @@ export function parseChangelogMarkdown( section = { kind: 'other', title: '', entries: [] }; release.sections.push(section); } - const shaMatch = TRAILING_SHA.exec(entryMatch.groups.text); + const entryText = entryMatch.groups.text.trimEnd(); + const shaMatch = TRAILING_SHA.exec(entryText); section.entries.push({ - text: stripInlineMarkdown( - entryMatch.groups.text.replace(TRAILING_SHA, ''), - ), + text: stripInlineMarkdown(entryText.replace(TRAILING_SHA, '')), sha: shaMatch?.groups?.sha, }); } diff --git a/libs/ui/src/screens/ChangelogView.tsx b/libs/ui/src/screens/ChangelogView.tsx index a32610a68..ba33ffeb3 100644 --- a/libs/ui/src/screens/ChangelogView.tsx +++ b/libs/ui/src/screens/ChangelogView.tsx @@ -107,8 +107,8 @@ function groupByYear(releases: UiRelease[]): YearGroup[] { const groups: YearGroup[] = []; for (const release of releases) { const year = release.yearLabel ?? ''; - const last = groups[groups.length - 1]; - if (last != null && last.year === year) { + const last = groups.at(-1); + if (last?.year === year) { last.releases.push(release); } else { groups.push({ year, releases: [release] }); From afd721f99cf2df2ad9d69cbd5d6c62f489b62b3c Mon Sep 17 00:00:00 2001 From: Oleg Miagkov <mrobenner@gmail.com> Date: Fri, 17 Jul 2026 20:52:57 +0400 Subject: [PATCH 3/6] fix(ui): address CodeRabbit review on the changelog pilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChangelogView: remount body on dataset change (key on first release id) so a stale selection can't survive a project switch; localized loading/retry/empty state labels via new stateLabels prop (EN defaults preserved). - Parser: reject impossible calendar dates instead of letting Date() roll them forward; classify duplicate-version blocks against the next semantically different version (both pinned by new tests — 17 passing in UTC and America/New_York). - Pilot: raw IPC/exception errors go to console.error only; the screen shows the localized changelog:pilot.error, and state labels come from new changelog:pilot.states.* keys (en+fr). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../__tests__/changelog-releases.test.ts | 29 ++++++++++++ .../components/ChangelogPilotView.tsx | 17 ++++++- .../src/renderer/lib/changelog-releases.ts | 46 +++++++++++++------ .../src/shared/i18n/locales/en/changelog.json | 5 ++ .../src/shared/i18n/locales/fr/changelog.json | 5 ++ libs/ui/src/index.ts | 2 +- libs/ui/src/screens/ChangelogView.tsx | 20 ++++++-- 7 files changed, 103 insertions(+), 21 deletions(-) diff --git a/apps/frontend/src/renderer/__tests__/changelog-releases.test.ts b/apps/frontend/src/renderer/__tests__/changelog-releases.test.ts index 88f1bb78c..16bbb2827 100644 --- a/apps/frontend/src/renderer/__tests__/changelog-releases.test.ts +++ b/apps/frontend/src/renderer/__tests__/changelog-releases.test.ts @@ -134,6 +134,35 @@ describe('parseChangelogMarkdown', () => { expect(new Set(ids).size).toBe(2); }); + it('classifies duplicate versions against the next distinct version', () => { + const duplicated = parseChangelogMarkdown( + [ + '## [3.0.0] - 2026-03-01', + '- keep-a-changelog block', + '## 3.0.0 - The big one', + '- generator block', + '## [2.9.0] - 2026-02-01', + '- previous minor', + ].join('\n'), + 'en-US', + ); + // Both 3.0.0 blocks compare against 2.9.0 — not against each other. + expect(duplicated.map((release) => release.type)).toEqual([ + 'major', + 'major', + 'minor', + ]); + }); + + it('rejects impossible calendar dates instead of rolling them forward', () => { + const impossible = parseChangelogMarkdown( + '## [1.0.1] - 2026-02-31\n- entry\n', + 'en-US', + ); + expect(impossible[0].dateLabel).toBeUndefined(); + expect(impossible[0].yearLabel).toBeUndefined(); + }); + it('parses loose heading variants and entries without section headings', () => { const loose = parseChangelogMarkdown( `## v1.2.0 (2026-01-05)\n- did a thing\n- did another (deadbeef)\n`, diff --git a/apps/frontend/src/renderer/components/ChangelogPilotView.tsx b/apps/frontend/src/renderer/components/ChangelogPilotView.tsx index cb7d56209..b02d780b2 100644 --- a/apps/frontend/src/renderer/components/ChangelogPilotView.tsx +++ b/apps/frontend/src/renderer/components/ChangelogPilotView.tsx @@ -43,10 +43,12 @@ export function ChangelogPilotView({ .then((result) => { if (!active) return; if (!result.success) { + // Log the raw IPC error; the screen shows only localized text. + console.error('[ChangelogPilotView] Failed to read changelog:', result.error); setState({ releases: null, loading: false, - error: new Error(result.error ?? t('changelog:pilot.error')), + error: new Error(t('changelog:pilot.error')), }); return; } @@ -59,10 +61,11 @@ export function ChangelogPilotView({ }) .catch((err: unknown) => { if (!active) return; + console.error('[ChangelogPilotView] Failed to read changelog:', err); setState({ releases: null, loading: false, - error: err instanceof Error ? err : new Error(String(err)), + error: new Error(t('changelog:pilot.error')), }); }); return () => { @@ -72,6 +75,15 @@ export function ChangelogPilotView({ const reload = useCallback(() => setReloadKey((key) => key + 1), []); + const stateLabels = useMemo( + () => ({ + loading: t('changelog:pilot.states.loading'), + retry: t('changelog:pilot.states.retry'), + empty: t('changelog:pilot.states.empty'), + }), + [t], + ); + const typeLabels = useMemo<Partial<Record<UiReleaseType, string>>>( () => ({ major: t('changelog:pilot.types.major'), @@ -117,6 +129,7 @@ export function ChangelogPilotView({ error={state.error} onRetry={reload} typeLabels={typeLabels} + stateLabels={stateLabels} metaSections={metaSections} /> </div> diff --git a/apps/frontend/src/renderer/lib/changelog-releases.ts b/apps/frontend/src/renderer/lib/changelog-releases.ts index 5a58959b2..309943000 100644 --- a/apps/frontend/src/renderer/lib/changelog-releases.ts +++ b/apps/frontend/src/renderer/lib/changelog-releases.ts @@ -123,9 +123,18 @@ interface DateLabels { * so local-timezone formatting would shift them a day west of UTC. */ function dateLabelsOf(dateRaw: string | undefined, locale?: string): DateLabels { - if (dateRaw == null || !ISO_DATE.test(dateRaw)) return {}; - const parsed = new Date(dateRaw); - if (Number.isNaN(parsed.getTime())) return {}; + const match = dateRaw != null ? ISO_DATE.exec(dateRaw) : null; + if (match == null) return {}; + const parsed = new Date(dateRaw as string); + if ( + Number.isNaN(parsed.getTime()) || + // Date() rolls impossible dates forward (2026-02-31 -> Mar 3) — reject. + parsed.getUTCFullYear() !== Number(match[1]) || + parsed.getUTCMonth() + 1 !== Number(match[2]) || + parsed.getUTCDate() !== Number(match[3]) + ) { + return {}; + } return { dateLabel: parsed.toLocaleDateString(locale, { month: 'short', @@ -182,16 +191,23 @@ export function parseChangelogMarkdown( } } - return raw - .filter((item) => item.sections.some((s) => s.entries.length > 0)) - .map((item, index, all) => ({ - // CHANGELOG files can repeat a version (e.g. a keep-a-changelog block - // and a generator block for the same release) — qualify by position. - id: `${item.version}#${index}`, - version: item.version, - name: item.name, - type: releaseTypeOf(item.version, all[index + 1]?.version), - ...dateLabelsOf(item.dateRaw, locale), - sections: item.sections.filter((s) => s.entries.length > 0), - })); + const withEntries = raw.filter((item) => + item.sections.some((s) => s.entries.length > 0), + ); + return withEntries.map((item, index) => ({ + // CHANGELOG files can repeat a version (e.g. a keep-a-changelog block + // and a generator block for the same release) — qualify by position. + id: `${item.version}#${index}`, + version: item.version, + name: item.name, + // Compare against the next semantically different version so duplicate + // blocks of one release don't misclassify each other as patches. + type: releaseTypeOf( + item.version, + withEntries.slice(index + 1).find((r) => r.version !== item.version) + ?.version, + ), + ...dateLabelsOf(item.dateRaw, locale), + sections: item.sections.filter((s) => s.entries.length > 0), + })); } diff --git a/apps/frontend/src/shared/i18n/locales/en/changelog.json b/apps/frontend/src/shared/i18n/locales/en/changelog.json index 4a119cf4e..329bc9e9d 100644 --- a/apps/frontend/src/shared/i18n/locales/en/changelog.json +++ b/apps/frontend/src/shared/i18n/locales/en/changelog.json @@ -12,6 +12,11 @@ "version": "Version", "released": "Released", "releases": "Releases" + }, + "states": { + "loading": "Loading releases…", + "retry": "Retry", + "empty": "No releases yet." } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/changelog.json b/apps/frontend/src/shared/i18n/locales/fr/changelog.json index 56b6ef781..75decb996 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/changelog.json +++ b/apps/frontend/src/shared/i18n/locales/fr/changelog.json @@ -12,6 +12,11 @@ "version": "Version", "released": "Publiée", "releases": "Versions" + }, + "states": { + "loading": "Chargement des versions…", + "retry": "Réessayer", + "empty": "Aucune version pour le moment." } } } diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index c0692c383..f368fa28a 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -73,4 +73,4 @@ export { useBoardFilter, FILTER_IDS } from './client/useBoardFilter'; export type { UseBoardFilterResult, FilterId } from './client/useBoardFilter'; // Changelog browser (U5 B1 pilot) export { ChangelogView } from './screens/ChangelogView'; -export type { ChangelogViewProps } from './screens/ChangelogView'; +export type { ChangelogViewProps, ChangelogViewStateLabels } from './screens/ChangelogView'; diff --git a/libs/ui/src/screens/ChangelogView.tsx b/libs/ui/src/screens/ChangelogView.tsx index ba33ffeb3..628945681 100644 --- a/libs/ui/src/screens/ChangelogView.tsx +++ b/libs/ui/src/screens/ChangelogView.tsx @@ -31,6 +31,12 @@ const SECTION_GLYPHS: Record<UiReleaseSectionKind, string> = { other: '·', }; +export interface ChangelogViewStateLabels { + loading?: string; + retry?: string; + empty?: string; +} + export interface ChangelogViewProps { releases: UiRelease[] | null; loading?: boolean; @@ -38,6 +44,8 @@ export interface ChangelogViewProps { onRetry?: () => void; /** Localized release-type badge labels; falls back to English. */ typeLabels?: Partial<Record<UiReleaseType, string>>; + /** Localized loading/retry/empty state labels; falls back to English. */ + stateLabels?: ChangelogViewStateLabels; /** Right-rail meta cards (latest release, cadence, …), when available. */ metaSections?: UiMetaSection[]; } @@ -55,11 +63,14 @@ export function ChangelogView({ error = null, onRetry, typeLabels, + stateLabels, metaSections, }: Readonly<ChangelogViewProps>) { return ( <section className="ac-changelog"> - {loading && <p className="ac-changelog__state">Loading…</p>} + {loading && ( + <p className="ac-changelog__state">{stateLabels?.loading ?? 'Loading…'}</p> + )} {!loading && error != null && ( <p className="ac-changelog__state ac-changelog__state--error" role="alert"> @@ -70,18 +81,21 @@ export function ChangelogView({ className="ac-changelog__retry" onClick={onRetry} > - Retry + {stateLabels?.retry ?? 'Retry'} </button> )} </p> )} {!loading && error == null && (releases == null || releases.length === 0) && ( - <p className="ac-changelog__state">No releases yet.</p> + <p className="ac-changelog__state"> + {stateLabels?.empty ?? 'No releases yet.'} + </p> )} {!loading && error == null && releases != null && releases.length > 0 && ( <ChangelogBody + key={releases[0].id} releases={releases} typeLabels={typeLabels} metaSections={metaSections} From 9da4c174c39e5921e15a6ccdfddadb7cf5ed8eef Mon Sep 17 00:00:00 2001 From: Oleg Miagkov <mrobenner@gmail.com> Date: Sat, 18 Jul 2026 11:24:58 +0400 Subject: [PATCH 4/6] fix(ui): finish linear-time parser regex hardening Completes b68336da: RELEASE_HEADING now matches a generic bracket-free token validated separately by VERSION_SHAPE (no overlapping quantifiers on user-controlled input), SECTION_HEADING anchors on non-space, and the link-stripping regex uses bounded character classes. Behavior pinned by the existing 17-test suite (UTC + America/New_York). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../src/renderer/lib/changelog-releases.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/renderer/lib/changelog-releases.ts b/apps/frontend/src/renderer/lib/changelog-releases.ts index 309943000..674a12780 100644 --- a/apps/frontend/src/renderer/lib/changelog-releases.ts +++ b/apps/frontend/src/renderer/lib/changelog-releases.ts @@ -14,11 +14,16 @@ import type { UiReleaseType, } from '@auto-code/ui'; -/** `## [1.2.3] …`, `## v1.2.3 …`, `## Unreleased` — suffix parsed separately. */ -const RELEASE_HEADING = - /^##\s+\[?(?<version>unreleased|v?\d+\.\d+[^\]\s]*)\]?(?<rest>.*)$/i; +/** + * `## [token] …` — the token is validated separately with VERSION_SHAPE so + * the line regex stays free of overlapping quantifiers (linear-time). + */ +const RELEASE_HEADING = /^##\s+\[?(?<version>[^\][\s]+)\]?(?<rest>.*)$/i; + +/** `Unreleased` or a semver-ish `v?MAJOR.MINOR…` token. */ +const VERSION_SHAPE = /^(?:unreleased$|v?\d+\.\d+)/i; -const SECTION_HEADING = /^###\s+(?<title>.+)$/; +const SECTION_HEADING = /^###\s+(?<title>\S.*)$/; const ENTRY_LINE = /^[-*]\s+(?<text>\S.*)$/; @@ -48,7 +53,7 @@ export function sectionKindFromTitle(title: string): UiReleaseSectionKind { */ export function stripInlineMarkdown(text: string): string { return text - .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/!?\[([^\][]*)\]\(([^()]*)\)/g, '$1') .replace(/\*\*([^*]*)\*\*/g, '$1') .replace(/__([^_]*)__/g, '$1') .replace(/\*([^*]*)\*/g, '$1') @@ -155,7 +160,10 @@ export function parseChangelogMarkdown( for (const line of content.split(/\r?\n/)) { const releaseMatch = RELEASE_HEADING.exec(line); - if (releaseMatch?.groups != null) { + if ( + releaseMatch?.groups != null && + VERSION_SHAPE.test(releaseMatch.groups.version) + ) { release = { version: releaseMatch.groups.version, ...parseHeadingSuffix(releaseMatch.groups.rest), From cf831a16f1c061e90bb1689e876400a7173f9207 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov <mrobenner@gmail.com> Date: Sat, 18 Jul 2026 11:51:32 +0400 Subject: [PATCH 5/6] fix(ui): non-overlapping release-heading regex (Sonar S8786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version token class and the trailing `.*` both matched ordinary characters, which Sonar flags as super-linear backtracking on user-controlled CHANGELOG.md input. The suffix group now must start with a character the token class excludes (`]`, `[`, or whitespace), so adjacent quantifiers never overlap; the leftover closing bracket moves to parseHeadingSuffix. Behavior unchanged — 17 tests green in UTC and America/New_York, real-file smoke parse intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/frontend/src/renderer/lib/changelog-releases.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/renderer/lib/changelog-releases.ts b/apps/frontend/src/renderer/lib/changelog-releases.ts index 674a12780..c7af568f4 100644 --- a/apps/frontend/src/renderer/lib/changelog-releases.ts +++ b/apps/frontend/src/renderer/lib/changelog-releases.ts @@ -15,10 +15,12 @@ import type { } from '@auto-code/ui'; /** - * `## [token] …` — the token is validated separately with VERSION_SHAPE so - * the line regex stays free of overlapping quantifiers (linear-time). + * `## [token] …` — the token is validated separately with VERSION_SHAPE, and + * `rest` must start with a character the token class excludes (`]`, `[`, or + * whitespace), so adjacent quantifiers never overlap (linear-time, S8786). */ -const RELEASE_HEADING = /^##\s+\[?(?<version>[^\][\s]+)\]?(?<rest>.*)$/i; +const RELEASE_HEADING = + /^##\s+\[?(?<version>[^\][\s]+)(?<rest>|[\][\s].*)$/i; /** `Unreleased` or a semver-ish `v?MAJOR.MINOR…` token. */ const VERSION_SHAPE = /^(?:unreleased$|v?\d+\.\d+)/i; @@ -72,7 +74,8 @@ interface HeadingSuffix { * release date; anything else non-empty is the release name, verbatim. */ function parseHeadingSuffix(rest: string): HeadingSuffix { - let text = rest.trim(); + // The heading regex leaves a closing `]` on the suffix side. + let text = rest.replace(/^\]/, '').trim(); // Version-link tail from generators: `[1.2.3](https://…/compare/…)`. text = text.replace(/^\((?:https?:\/\/)[^)]*\)\s*/, ''); // Leading `-` / `–` / `—` separator. From c0b6e45a5ecf5f0b9fa4eea3497c2453f47bdb58 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov <mrobenner@gmail.com> Date: Sat, 18 Jul 2026 12:13:02 +0400 Subject: [PATCH 6/6] fix(ui): optional group instead of empty alternative (Sonar S6323) The S8786 fix introduced an empty regex alternative; express the same "suffix absent or boundary-prefixed" contract as an optional non-capturing group. Still non-overlapping and linear; 17 tests green in UTC and America/New_York. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/frontend/src/renderer/lib/changelog-releases.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/renderer/lib/changelog-releases.ts b/apps/frontend/src/renderer/lib/changelog-releases.ts index c7af568f4..411082f6a 100644 --- a/apps/frontend/src/renderer/lib/changelog-releases.ts +++ b/apps/frontend/src/renderer/lib/changelog-releases.ts @@ -20,7 +20,7 @@ import type { * whitespace), so adjacent quantifiers never overlap (linear-time, S8786). */ const RELEASE_HEADING = - /^##\s+\[?(?<version>[^\][\s]+)(?<rest>|[\][\s].*)$/i; + /^##\s+\[?(?<version>[^\][\s]+)(?<rest>(?:[\][\s].*)?)$/i; /** `Unreleased` or a semver-ish `v?MAJOR.MINOR…` token. */ const VERSION_SHAPE = /^(?:unreleased$|v?\d+\.\d+)/i;