From 260e134a3b453194751a393ef61dbca64dfee1df Mon Sep 17 00:00:00 2001 From: Mateusz Paulski Date: Fri, 3 Jul 2026 18:12:57 +0200 Subject: [PATCH 1/2] feat(client): add a "what's new" release-highlights popup to the widget Curated release highlights now surface in-widget: a Radix dialog auto-shows once per new version (gated on identity, silent seed for first-time users) with a manual re-open button in the panel header. Last-seen version persists through the client settings store as a new registered whatsNewSeen entry. Closes #31. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/whats-new-popup.md | 5 + CLAUDE.md | 4 + RELEASING.md | 4 + packages/client/src/app/app.tsx | 65 +++++------ .../client/src/marker/MarkerLayer.test.tsx | 47 ++++---- .../client/src/panel/PanelDrawer.test.tsx | 41 +++---- packages/client/src/panel/PanelListView.tsx | 15 ++- packages/client/src/settings/store.test.ts | 11 +- packages/client/src/settings/store.ts | 2 + .../src/whatsnew/WhatsNewModal.test.tsx | 64 +++++++++++ .../client/src/whatsnew/WhatsNewModal.tsx | 56 ++++++++++ .../src/whatsnew/WhatsNewProvider.test.tsx | 104 ++++++++++++++++++ .../client/src/whatsnew/WhatsNewProvider.tsx | 89 +++++++++++++++ .../client/src/whatsnew/highlights.test.ts | 35 ++++++ packages/client/src/whatsnew/highlights.ts | 35 ++++++ packages/client/src/whatsnew/storage.ts | 15 +++ packages/client/src/whatsnew/version.test.ts | 60 ++++++++++ packages/client/src/whatsnew/version.ts | 36 ++++++ 18 files changed, 612 insertions(+), 76 deletions(-) create mode 100644 .changeset/whats-new-popup.md create mode 100644 packages/client/src/whatsnew/WhatsNewModal.test.tsx create mode 100644 packages/client/src/whatsnew/WhatsNewModal.tsx create mode 100644 packages/client/src/whatsnew/WhatsNewProvider.test.tsx create mode 100644 packages/client/src/whatsnew/WhatsNewProvider.tsx create mode 100644 packages/client/src/whatsnew/highlights.test.ts create mode 100644 packages/client/src/whatsnew/highlights.ts create mode 100644 packages/client/src/whatsnew/storage.ts create mode 100644 packages/client/src/whatsnew/version.test.ts create mode 100644 packages/client/src/whatsnew/version.ts diff --git a/.changeset/whats-new-popup.md b/.changeset/whats-new-popup.md new file mode 100644 index 00000000..142f1b71 --- /dev/null +++ b/.changeset/whats-new-popup.md @@ -0,0 +1,5 @@ +--- +'@airnauts/airside-client': patch +--- + +The widget now shows a "what's new" popup once per new version, with a manual re-open button in the comments panel header. diff --git a/CLAUDE.md b/CLAUDE.md index ac2cab97..4e23eb02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,10 @@ git log: describe the user-visible effect, not the internal refactor. For the per-change mechanics (file format, the pre-1.0 bump policy, which packages to include), use the `writing-changesets` skill in `.claude/skills/`. +When cutting a release with user-facing changes, also prepend a `HIGHLIGHTS` entry in +`packages/client/src/whatsnew/highlights.ts` — it feeds the widget's in-app "what's new" +popup (see `RELEASING.md`). + ## Architecture decision records `docs/adr.md` is the running log of architecture decisions for this project. Whenever an architecturally significant choice is made or changed. diff --git a/RELEASING.md b/RELEASING.md index fec01ea3..8b5d0d84 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -37,6 +37,10 @@ npm view @airnauts/airside-core version (`pnpm changeset`, then pick the bump). Pre-1.0 the bump policy is: breaking changes → **minor**, everything else → **patch** (see the `writing-changesets` skill for the mechanics and which packages to include). + - When cutting a release with **user-facing changes**, also prepend a `HIGHLIGHTS` + entry in `packages/client/src/whatsnew/highlights.ts` for the new version — it feeds + the widget's in-app "what's new" popup (reviewer-facing and hand-curated, unlike the + generated changelogs). Maintenance-only releases can skip it. 2. When ready to release, consume the pending changesets to bump versions and append to each package's `CHANGELOG.md`: ```bash diff --git a/packages/client/src/app/app.tsx b/packages/client/src/app/app.tsx index fabc3fc1..3cd2004d 100644 --- a/packages/client/src/app/app.tsx +++ b/packages/client/src/app/app.tsx @@ -13,6 +13,7 @@ import { getSetting, setSetting } from '../settings/store' import { ThreadsProvider } from '../threads/ThreadsProvider' import { LoginLauncher } from '../ui/LoginLauncher' import { ToastProvider } from '../ui/toast' +import { WhatsNewProvider } from '../whatsnew/WhatsNewProvider' import { WidgetProvider } from './providers' export type WidgetAppProps = { @@ -53,37 +54,39 @@ export function WidgetApp({ options, client: injected }: WidgetAppProps) { - - - - {identity ? ( - <> - resolvePageKey(options, url)} - provenance={options.provenance} - /> - resolvePageKey(options, url)} - client={client} - /> - - ) : ( - setModalOpen(true)} /> - )} - - - { - if (!open) resumeRef.current = null - setModalOpen(open) - }} - onSubmit={onSubmitIdentity} - /> - + + + + + {identity ? ( + <> + resolvePageKey(options, url)} + provenance={options.provenance} + /> + resolvePageKey(options, url)} + client={client} + /> + + ) : ( + setModalOpen(true)} /> + )} + + + { + if (!open) resumeRef.current = null + setModalOpen(open) + }} + onSubmit={onSubmitIdentity} + /> + + diff --git a/packages/client/src/marker/MarkerLayer.test.tsx b/packages/client/src/marker/MarkerLayer.test.tsx index 292effef..2f076a12 100644 --- a/packages/client/src/marker/MarkerLayer.test.tsx +++ b/packages/client/src/marker/MarkerLayer.test.tsx @@ -10,6 +10,7 @@ import { PanelProvider } from '../panel/PanelProvider' import { initSettings, resetSettings } from '../settings/store' import { ThreadsProvider } from '../threads/ThreadsProvider' import { ToastProvider } from '../ui/toast' +import { WhatsNewProvider } from '../whatsnew/WhatsNewProvider' import { MarkerLayer } from './MarkerLayer' // MarkerLayer now seeds its pins-hidden state from the shared settings store, which caches its @@ -358,19 +359,21 @@ function renderLayer(client: unknown) { {}}> - - - - 'x.test/here'} - /> - 'x.test/here'} client={client as never} /> - - - + + + + + 'x.test/here'} + /> + 'x.test/here'} client={client as never} /> + + + + , @@ -538,14 +541,16 @@ function renderMarkerWithPanel(c: ReturnType) { identity={{ email: 'a@b.c', name: 'A' }} requestIdentity={(resume) => resume({ email: 'a@b.c', name: 'A' })} > - - - - - 'k'} client={c as never} /> - - - + + + + + + 'k'} client={c as never} /> + + + + , diff --git a/packages/client/src/panel/PanelDrawer.test.tsx b/packages/client/src/panel/PanelDrawer.test.tsx index 6f59e2c1..16ed2e67 100644 --- a/packages/client/src/panel/PanelDrawer.test.tsx +++ b/packages/client/src/panel/PanelDrawer.test.tsx @@ -8,6 +8,7 @@ import { DraftsProvider } from '../drafts/DraftsProvider' import { IdentityProvider } from '../identity/IdentityProvider' import { ThreadsProvider } from '../threads/ThreadsProvider' import { useController, useDispatch, useThreadsState } from '../threads/useThreads' +import { WhatsNewProvider } from '../whatsnew/WhatsNewProvider' import { FOCUS_STORAGE_KEY } from './navigate' import { PanelDrawer } from './PanelDrawer' import { PanelProvider, usePanelController } from './PanelProvider' @@ -159,25 +160,27 @@ function setup(opts: { render( resume(identity)}> - - - - - - - {opts.withProbes && } - {opts.withProbes && } - {opts.detailOpenerId && ( - <> - - - - )} - {opts.deleteProbeId && } - - - - + + + + + + + + {opts.withProbes && } + {opts.withProbes && } + {opts.detailOpenerId && ( + <> + + + + )} + {opts.deleteProbeId && } + + + + + , ) diff --git a/packages/client/src/panel/PanelListView.tsx b/packages/client/src/panel/PanelListView.tsx index a3f234c1..e9208f58 100644 --- a/packages/client/src/panel/PanelListView.tsx +++ b/packages/client/src/panel/PanelListView.tsx @@ -5,6 +5,7 @@ import { cn } from '../lib/cn' import { useController, useShowResolved } from '../threads/useThreads' import { Button } from '../ui/Button' import { StatusNotice } from '../ui/StatusNotice' +import { useWhatsNew } from '../whatsnew/WhatsNewProvider' import { usePanelController, usePanelState } from './PanelProvider' import { PanelRow } from './PanelRow' import { mainListExcludingReview, type PanelFilter } from './state' @@ -25,6 +26,7 @@ export function PanelListView({ onSelect }: PanelListViewProps) { const panel = usePanelController() const threads = useController() const showResolved = useShowResolved() + const { openWhatsNew } = useWhatsNew() const mainList = mainListExcludingReview(state) const toggleResolve = (t: { id: string; status: string }) => @@ -49,11 +51,16 @@ export function PanelListView({ onSelect }: PanelListViewProps) { Comment threads across all pages - - - + + + +
diff --git a/packages/client/src/settings/store.test.ts b/packages/client/src/settings/store.test.ts index da034a4f..b2f9440a 100644 --- a/packages/client/src/settings/store.test.ts +++ b/packages/client/src/settings/store.test.ts @@ -27,13 +27,14 @@ describe('settings store', () => { const storage = fakeStorage() initSettings(storage) // One getItem per known key during hydration. - expect(storage.getItem).toHaveBeenCalledTimes(4) + expect(storage.getItem).toHaveBeenCalledTimes(5) storage.getItem.mockClear() // Reads now come from the cache — no further storage access. getSetting('activationKey') getSetting('identity') getSetting('launcherPosition') getSetting('pinsHidden') + getSetting('whatsNewSeen') expect(storage.getItem).not.toHaveBeenCalled() }) @@ -43,6 +44,7 @@ describe('settings store', () => { expect(getSetting('identity')).toBeNull() expect(getSetting('launcherPosition')).toEqual(DEFAULT_LAUNCHER_POSITION) expect(getSetting('pinsHidden')).toBe(false) + expect(getSetting('whatsNewSeen')).toBeNull() }) it('round-trips set/get for every key against localStorage', () => { @@ -51,13 +53,16 @@ describe('settings store', () => { setSetting('identity', { email: 'a@b.com', name: 'Ada' }) setSetting('launcherPosition', { edge: 'left', top: 40 }) setSetting('pinsHidden', true) + setSetting('whatsNewSeen', '0.10.2') expect(getSetting('activationKey')).toBe('dev-key') expect(getSetting('identity')).toEqual({ email: 'a@b.com', name: 'Ada' }) expect(getSetting('launcherPosition')).toEqual({ edge: 'left', top: 40 }) expect(getSetting('pinsHidden')).toBe(true) + expect(getSetting('whatsNewSeen')).toBe('0.10.2') // Writes are persisted to the bound storage under the established on-disk keys. expect(localStorage.getItem('airside:key')).toBe(JSON.stringify('dev-key')) expect(localStorage.getItem('airside:pins-hidden')).toBe(JSON.stringify(true)) + expect(localStorage.getItem('airside:whats-new-seen')).toBe(JSON.stringify('0.10.2')) }) it('falls back to the per-key default on malformed JSON', () => { @@ -67,12 +72,14 @@ describe('settings store', () => { 'airside:identity': '{not json', 'airside:launcher-position': '{not json', 'airside:pins-hidden': '{not json', + 'airside:whats-new-seen': '{not json', }), ) expect(getSetting('activationKey')).toBeNull() expect(getSetting('identity')).toBeNull() expect(getSetting('launcherPosition')).toEqual(DEFAULT_LAUNCHER_POSITION) expect(getSetting('pinsHidden')).toBe(false) + expect(getSetting('whatsNewSeen')).toBeNull() }) it('falls back to the per-key default on wrong-type values', () => { @@ -81,11 +88,13 @@ describe('settings store', () => { 'airside:key': JSON.stringify({ key: 'nope' }), 'airside:identity': JSON.stringify({ name: 'no email' }), 'airside:pins-hidden': JSON.stringify('yes'), + 'airside:whats-new-seen': JSON.stringify(123), }), ) expect(getSetting('activationKey')).toBeNull() expect(getSetting('identity')).toBeNull() expect(getSetting('pinsHidden')).toBe(false) + expect(getSetting('whatsNewSeen')).toBeNull() }) it('clamps an out-of-range launcher top to the on-screen band', () => { diff --git a/packages/client/src/settings/store.ts b/packages/client/src/settings/store.ts index 61c9ba27..edd09e55 100644 --- a/packages/client/src/settings/store.ts +++ b/packages/client/src/settings/store.ts @@ -4,6 +4,7 @@ import { activationKeySetting } from '../activation/storage' import { identitySetting } from '../identity/storage' import { launcherPositionSetting } from '../launcher/storage' import { pinsHiddenSetting } from '../marker/storage' +import { whatsNewSeenSetting } from '../whatsnew/storage' import type { SettingEntry } from './entry' /** @@ -24,6 +25,7 @@ const ENTRIES = { identity: identitySetting, launcherPosition: launcherPositionSetting, pinsHidden: pinsHiddenSetting, + whatsNewSeen: whatsNewSeenSetting, } satisfies Record> /** A known setting's name — the keys of {@link ENTRIES}, the single registration point. */ diff --git a/packages/client/src/whatsnew/WhatsNewModal.test.tsx b/packages/client/src/whatsnew/WhatsNewModal.test.tsx new file mode 100644 index 00000000..ba13414d --- /dev/null +++ b/packages/client/src/whatsnew/WhatsNewModal.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { WidgetProvider } from '../app/providers' +import type { Highlight } from './highlights' +import { WhatsNewModal } from './WhatsNewModal' + +const ENTRIES: Highlight[] = [ + { + version: '0.11.0', + date: '2026-07-10', + title: 'Faster pins', + items: ['Pins render twice as fast', 'Smoother drag'], + }, + { + version: '0.10.2', + date: '2026-07-03', + title: 'Release notes in the widget', + items: ['A what’s-new popup shows release highlights'], + }, +] + +describe('WhatsNewModal', () => { + it('renders a single entry’s title, version/date and items', () => { + render( + + {}} entries={[ENTRIES[1]]} /> + , + ) + expect(screen.getByText('Release notes in the widget')).toBeInTheDocument() + expect(screen.getByText('0.10.2 · 2026-07-03')).toBeInTheDocument() + expect(screen.getByText('A what’s-new popup shows release highlights')).toBeInTheDocument() + }) + + it('renders multiple entries stacked in one dialog', () => { + render( + + {}} entries={ENTRIES} /> + , + ) + expect(screen.getByText('Faster pins')).toBeInTheDocument() + expect(screen.getByText('Release notes in the widget')).toBeInTheDocument() + expect(screen.getByText('Smoother drag')).toBeInTheDocument() + }) + + it('dismisses via the Got it button through onOpenChange(false)', () => { + const onOpenChange = vi.fn() + render( + + + , + ) + fireEvent.click(screen.getByRole('button', { name: /got it/i })) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('renders nothing when closed', () => { + render( + + {}} entries={ENTRIES} /> + , + ) + expect(screen.queryByText('Faster pins')).not.toBeInTheDocument() + }) +}) diff --git a/packages/client/src/whatsnew/WhatsNewModal.tsx b/packages/client/src/whatsnew/WhatsNewModal.tsx new file mode 100644 index 00000000..bf556534 --- /dev/null +++ b/packages/client/src/whatsnew/WhatsNewModal.tsx @@ -0,0 +1,56 @@ +// packages/client/src/whatsnew/WhatsNewModal.tsx + +import * as Dialog from '@radix-ui/react-dialog' +import { usePortalContainer } from '../app/providers' +import { Button } from '../ui/Button' +import type { Highlight } from './highlights' + +export type WhatsNewModalProps = { + open: boolean + onOpenChange: (open: boolean) => void + /** The release highlights to show, newest first. */ + entries: Highlight[] +} + +/** Presentational "what's new" dialog: renders each release's title, date and bullet + * points, dismissed via the "Got it" button, overlay click or Escape (all route through + * `onOpenChange(false)`). State and persistence live in `WhatsNewProvider`. */ +export function WhatsNewModal({ open, onOpenChange, entries }: WhatsNewModalProps) { + const container = usePortalContainer() + + return ( + + + + + + What's new + + + Recent updates to the commenting widget. + + {entries.map((entry) => ( +
+
{entry.title}
+
+ {entry.version} · {entry.date} +
+
    + {entry.items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ))} + + + +
+
+
+ ) +} diff --git a/packages/client/src/whatsnew/WhatsNewProvider.test.tsx b/packages/client/src/whatsnew/WhatsNewProvider.test.tsx new file mode 100644 index 00000000..66e86636 --- /dev/null +++ b/packages/client/src/whatsnew/WhatsNewProvider.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it } from 'vitest' +import { WidgetProvider } from '../app/providers' +import { IdentityProvider } from '../identity/IdentityProvider' +import type { Identity } from '../identity/storage' +import { resetSettings } from '../settings/store' +import { HIGHLIGHTS } from './highlights' +import { useWhatsNew, WhatsNewProvider } from './WhatsNewProvider' + +const SEEN_KEY = 'airside:whats-new-seen' +const LATEST = HIGHLIGHTS[0].version + +function Trigger() { + const { openWhatsNew } = useWhatsNew() + return ( + + ) +} + +function harness(identity: Identity | null): ReactNode { + return ( + + {}}> + + + + + + ) +} + +const reviewer: Identity = { email: 'rev@example.com', name: 'Rev' } + +describe('WhatsNewProvider', () => { + // Standing rule (ADR-0046 read-once cache): seed localStorage, then resetSettings() + // before mounting so the store re-hydrates the seeded value, not a stale cache. + beforeEach(() => { + localStorage.clear() + resetSettings() + }) + + it('seeds silently on first-ever load: no dialog, setting persisted to latest', () => { + render(harness(reviewer)) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(localStorage.getItem(SEEN_KEY)).toBe(JSON.stringify(LATEST)) + }) + + it('auto-shows for an older stored version, then persists latest on close', () => { + localStorage.setItem(SEEN_KEY, JSON.stringify('0.0.1')) + resetSettings() + render(harness(reviewer)) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText(HIGHLIGHTS[0].title)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /got it/i })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(localStorage.getItem(SEEN_KEY)).toBe(JSON.stringify(LATEST)) + }) + + it('shows nothing when the stored version equals the latest', () => { + localStorage.setItem(SEEN_KEY, JSON.stringify(LATEST)) + resetSettings() + render(harness(reviewer)) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('does not auto-show (or seed) while no identity is present', () => { + localStorage.setItem(SEEN_KEY, JSON.stringify('0.0.1')) + resetSettings() + render(harness(null)) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(localStorage.getItem(SEEN_KEY)).toBe(JSON.stringify('0.0.1')) + }) + + it('auto-shows once identity appears, and only once', () => { + localStorage.setItem(SEEN_KEY, JSON.stringify('0.0.1')) + resetSettings() + const view = render(harness(null)) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + view.rerender(harness(reviewer)) + expect(screen.getByRole('dialog')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /got it/i })) + + // A later identity change must not re-trigger the auto-show. + view.rerender(harness({ email: 'other@example.com' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('openWhatsNew() opens the latest entry on demand, even when already seen', () => { + localStorage.setItem(SEEN_KEY, JSON.stringify(LATEST)) + resetSettings() + render(harness(reviewer)) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'show whats new' })) + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText(HIGHLIGHTS[0].title)).toBeInTheDocument() + }) +}) diff --git a/packages/client/src/whatsnew/WhatsNewProvider.tsx b/packages/client/src/whatsnew/WhatsNewProvider.tsx new file mode 100644 index 00000000..de64e4e5 --- /dev/null +++ b/packages/client/src/whatsnew/WhatsNewProvider.tsx @@ -0,0 +1,89 @@ +// packages/client/src/whatsnew/WhatsNewProvider.tsx + +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useIdentity } from '../identity/IdentityProvider' +import { getSetting, setSetting } from '../settings/store' +import { HIGHLIGHTS, type Highlight } from './highlights' +import { entriesNewerThan, latestVersion } from './version' +import { WhatsNewModal } from './WhatsNewModal' + +export type WhatsNewContextValue = { + /** Open the modal on demand with the latest release's highlights. */ + openWhatsNew: () => void + /** The latest announced release, or null when no highlights exist. */ + latest: Highlight | null +} + +const WhatsNewContext = createContext(null) + +/** + * Owns the "what's new" modal: auto-shows it once per new version (gated on an identity being + * present, so it never stacks on the login launcher/modal) and exposes {@link useWhatsNew} for + * the manual re-open trigger in the panel header. + * + * Auto-show, run once per mount when an identity is present: + * - stored last-seen is `null` (brand-new reviewer, or one predating this feature) → seed the + * setting silently to the latest version and show nothing; + * - stored last-seen is older than the latest highlight → open with every newer entry, + * newest first; + * - stored last-seen equals the latest → do nothing. + * Dismissing the modal (button / overlay / Escape) persists the latest version. + */ +export function WhatsNewProvider({ children }: { children: ReactNode }) { + const { identity } = useIdentity() + const [open, setOpen] = useState(false) + const [entries, setEntries] = useState([]) + const autoShownRef = useRef(false) + + useEffect(() => { + if (!identity || autoShownRef.current) return + autoShownRef.current = true + const lastSeen = getSetting('whatsNewSeen') + if (lastSeen === null) { + // First-ever load: seed silently so historical releases never popup. + setSetting('whatsNewSeen', latestVersion()) + return + } + const newer = entriesNewerThan(lastSeen) + if (newer.length > 0) { + setEntries(newer) + setOpen(true) + } + }, [identity]) + + const openWhatsNew = useCallback(() => { + const latest = HIGHLIGHTS[0] + setEntries(latest ? [latest] : []) + setOpen(true) + }, []) + + const value = useMemo(() => ({ openWhatsNew, latest: HIGHLIGHTS[0] ?? null }), [openWhatsNew]) + + function onOpenChange(next: boolean) { + setOpen(next) + // Any dismissal marks the latest release as seen (re-persisting is a no-op). + if (!next) setSetting('whatsNewSeen', latestVersion()) + } + + return ( + + {children} + + + ) +} + +export function useWhatsNew(): WhatsNewContextValue { + const ctx = useContext(WhatsNewContext) + if (!ctx) throw new Error('useWhatsNew must be used within a WhatsNewProvider') + return ctx +} diff --git a/packages/client/src/whatsnew/highlights.test.ts b/packages/client/src/whatsnew/highlights.test.ts new file mode 100644 index 00000000..3a607081 --- /dev/null +++ b/packages/client/src/whatsnew/highlights.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { HIGHLIGHTS } from './highlights' +import { compareVersions } from './version' + +// Invariant guard for the hand-curated content file: the provider derives the "latest +// announced version" from HIGHLIGHTS[0], so ordering and shape are load-bearing. There is +// deliberately no package.json equality check — the entry for a release is added before +// `pnpm version-packages` bumps versions, so they diverge transiently in normal dev. +describe('HIGHLIGHTS invariants', () => { + it('is non-empty', () => { + expect(HIGHLIGHTS.length).toBeGreaterThan(0) + }) + + it('has unique, strictly descending versions (newest first)', () => { + for (let i = 1; i < HIGHLIGHTS.length; i++) { + expect( + compareVersions(HIGHLIGHTS[i - 1].version, HIGHLIGHTS[i].version), + `${HIGHLIGHTS[i - 1].version} must be newer than ${HIGHLIGHTS[i].version}`, + ).toBeGreaterThan(0) + } + }) + + it('has well-formed entries', () => { + for (const entry of HIGHLIGHTS) { + expect(entry.version).toMatch(/^\d+\.\d+\.\d+$/) + expect(entry.date).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(entry.title.trim()).not.toBe('') + expect(entry.items.length).toBeGreaterThan(0) + for (const item of entry.items) { + expect(typeof item).toBe('string') + expect(item.trim()).not.toBe('') + } + } + }) +}) diff --git a/packages/client/src/whatsnew/highlights.ts b/packages/client/src/whatsnew/highlights.ts new file mode 100644 index 00000000..682af623 --- /dev/null +++ b/packages/client/src/whatsnew/highlights.ts @@ -0,0 +1,35 @@ +// packages/client/src/whatsnew/highlights.ts + +/** One release's hand-curated "what's new" entry, written for reviewers (not package + * adopters — the generated `CHANGELOG.md` files stay the adopter-facing record). */ +export type Highlight = { + /** The release version this entry announces, e.g. `'0.10.2'`. */ + version: string + /** The release date, `YYYY-MM-DD`. */ + date: string + /** A short headline for the release. */ + title: string + /** Reviewer-facing bullet points — what changed, in plain language. */ + items: string[] +} + +/** + * Editorial release highlights, **newest first**. The first entry's version doubles as the + * "latest announced version" — the array ships inside the versioned bundle, so no build-time + * version constant is needed (a running build can never contain an entry newer than itself). + * + * Release step (see `RELEASING.md`): when cutting a release with user-facing changes, prepend + * an entry here for the new version. The invariant test (`highlights.test.ts`) enforces the + * newest-first ordering and entry shape. + */ +export const HIGHLIGHTS: Highlight[] = [ + { + version: '0.10.2', + date: '2026-07-03', + title: 'What’s new, right in the widget', + items: [ + 'The widget now tells you what changed: release highlights pop up once per new version.', + 'Reopen them anytime with the ✦ button in the comments panel header.', + ], + }, +] diff --git a/packages/client/src/whatsnew/storage.ts b/packages/client/src/whatsnew/storage.ts new file mode 100644 index 00000000..c8223060 --- /dev/null +++ b/packages/client/src/whatsnew/storage.ts @@ -0,0 +1,15 @@ +// packages/client/src/whatsnew/storage.ts + +import type { SettingEntry } from '../settings/entry' + +/** + * Settings-store entry for the last "what's new" version the reviewer has seen + * (`airside:whats-new-seen`), persisted so the release-highlights modal auto-shows only once + * per new version. Owns this setting's full storage wiring — on-disk key, absent default, and + * parse guard — which the settings store registers in its `ENTRIES` list. + */ +export const whatsNewSeenSetting: SettingEntry = { + storageKey: 'airside:whats-new-seen', + fallback: null, + validate: (parsed) => (typeof parsed === 'string' ? parsed : null), +} diff --git a/packages/client/src/whatsnew/version.test.ts b/packages/client/src/whatsnew/version.test.ts new file mode 100644 index 00000000..7c77337d --- /dev/null +++ b/packages/client/src/whatsnew/version.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { Highlight } from './highlights' +import { compareVersions, entriesNewerThan, latestVersion } from './version' + +const entry = (version: string): Highlight => ({ + version, + date: '2026-07-03', + title: `Release ${version}`, + items: [`item for ${version}`], +}) + +// Newest first, matching the HIGHLIGHTS ordering invariant. +const LIST: Highlight[] = [entry('0.10.0'), entry('0.9.1'), entry('0.9.0')] + +describe('compareVersions', () => { + it('orders plain semver segments', () => { + expect(compareVersions('0.9.0', '0.9.1')).toBeLessThan(0) + expect(compareVersions('0.9.1', '0.9.0')).toBeGreaterThan(0) + expect(compareVersions('1.0.0', '0.9.9')).toBeGreaterThan(0) + expect(compareVersions('0.9.1', '0.9.1')).toBe(0) + }) + + it('compares segments numerically, not lexically (0.9.1 < 0.10.0)', () => { + expect(compareVersions('0.9.1', '0.10.0')).toBeLessThan(0) + expect(compareVersions('0.10.0', '0.9.1')).toBeGreaterThan(0) + }) + + it('treats missing segments as zero', () => { + expect(compareVersions('1.0', '1.0.0')).toBe(0) + expect(compareVersions('1.0', '1.0.1')).toBeLessThan(0) + }) +}) + +describe('entriesNewerThan', () => { + it('returns [] for a null last-seen (silent-seed path belongs to the provider)', () => { + expect(entriesNewerThan(null, LIST)).toEqual([]) + }) + + it('returns every newer entry, newest first, for an older last-seen', () => { + expect(entriesNewerThan('0.9.0', LIST)).toEqual([entry('0.10.0'), entry('0.9.1')]) + }) + + it('returns [] when last-seen equals the latest', () => { + expect(entriesNewerThan('0.10.0', LIST)).toEqual([]) + }) + + it('returns [] when last-seen is newer than every entry', () => { + expect(entriesNewerThan('0.11.0', LIST)).toEqual([]) + }) +}) + +describe('latestVersion', () => { + it('is the first (newest) entry version', () => { + expect(latestVersion(LIST)).toBe('0.10.0') + }) + + it('is null on an empty list', () => { + expect(latestVersion([])).toBeNull() + }) +}) diff --git a/packages/client/src/whatsnew/version.ts b/packages/client/src/whatsnew/version.ts new file mode 100644 index 00000000..c17c06c3 --- /dev/null +++ b/packages/client/src/whatsnew/version.ts @@ -0,0 +1,36 @@ +// packages/client/src/whatsnew/version.ts + +import { HIGHLIGHTS, type Highlight } from './highlights' + +/** + * Numeric-tuple semver compare: split on `.`, compare segments as numbers, so `0.10.0` + * orders after `0.9.1`. Returns negative when `a < b`, positive when `a > b`, `0` when equal. + * We never ship pre-release tags, so no tag handling is needed. + */ +export function compareVersions(a: string, b: string): number { + const as = a.split('.').map(Number) + const bs = b.split('.').map(Number) + const length = Math.max(as.length, bs.length) + for (let i = 0; i < length; i++) { + const diff = (as[i] ?? 0) - (bs[i] ?? 0) + if (diff !== 0) return diff + } + return 0 +} + +/** The highlights (newest-first) strictly newer than `lastSeen`. Returns `[]` when `lastSeen` + * is `null` — the never-seeded case is handled by the provider's silent-seed path, never by + * showing every historical entry. */ +export function entriesNewerThan( + lastSeen: string | null, + list: Highlight[] = HIGHLIGHTS, +): Highlight[] { + if (lastSeen === null) return [] + return list.filter((entry) => compareVersions(entry.version, lastSeen) > 0) +} + +/** The latest announced version — the first (newest) entry's version, or `null` on an + * empty list (which the highlights invariant test forbids in practice). */ +export function latestVersion(list: Highlight[] = HIGHLIGHTS): string | null { + return list[0]?.version ?? null +} From 74d4e19198cb415a10d0e1674bd1421935a494fa Mon Sep 17 00:00:00 2001 From: Mateusz Paulski Date: Fri, 3 Jul 2026 18:54:27 +0200 Subject: [PATCH 2/2] fix(review): address airside-agent review findings for #31 Merge origin/main into agent/issue-31 (icon-set unification, branding footer), replace the raw sparkle glyph with a SparkleIcon in the widget icon set, and tighten the whats-new-seen setting validate to version-shaped strings. Airside-Agent-Fix: true Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/src/panel/PanelListView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/panel/PanelListView.tsx b/packages/client/src/panel/PanelListView.tsx index 5176610c..2dcc99d1 100644 --- a/packages/client/src/panel/PanelListView.tsx +++ b/packages/client/src/panel/PanelListView.tsx @@ -4,8 +4,8 @@ import * as Dialog from '@radix-ui/react-dialog' import { cn } from '../lib/cn' import { useController, useShowResolved } from '../threads/useThreads' import { Button } from '../ui/Button' -import { PoweredBy } from '../ui/PoweredBy' import { CloseIcon, SparkleIcon } from '../ui/icons' +import { PoweredBy } from '../ui/PoweredBy' import { StatusNotice } from '../ui/StatusNotice' import { useWhatsNew } from '../whatsnew/WhatsNewProvider' import { usePanelController, usePanelState } from './PanelProvider'