Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/whats-new-popup.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 35 additions & 32 deletions packages/client/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -53,38 +54,40 @@ export function WidgetApp({ options, client: injected }: WidgetAppProps) {
<WidgetProvider>
<ToastProvider>
<IdentityProvider identity={identity} requestIdentity={requestIdentity}>
<ThreadsProvider client={client}>
<PanelProvider client={client}>
<DraftsProvider>
{identity ? (
<>
<MarkerLayer
client={client}
pageKey={pageKey}
pageUrl={pageUrl}
resolvePageKey={(url) => resolvePageKey(options, url)}
provenance={options.provenance}
/>
<PanelDrawer
resolvePageKey={(url) => resolvePageKey(options, url)}
client={client}
branding={options.features?.branding === true}
/>
</>
) : (
<LoginLauncher onLogIn={() => setModalOpen(true)} />
)}
</DraftsProvider>
</PanelProvider>
<IdentityModal
open={modalOpen}
onOpenChange={(open) => {
if (!open) resumeRef.current = null
setModalOpen(open)
}}
onSubmit={onSubmitIdentity}
/>
</ThreadsProvider>
<WhatsNewProvider>
<ThreadsProvider client={client}>
<PanelProvider client={client}>
<DraftsProvider>
{identity ? (
<>
<MarkerLayer
client={client}
pageKey={pageKey}
pageUrl={pageUrl}
resolvePageKey={(url) => resolvePageKey(options, url)}
provenance={options.provenance}
/>
<PanelDrawer
resolvePageKey={(url) => resolvePageKey(options, url)}
client={client}
branding={options.features?.branding === true}
/>
</>
) : (
<LoginLauncher onLogIn={() => setModalOpen(true)} />
)}
</DraftsProvider>
</PanelProvider>
<IdentityModal
open={modalOpen}
onOpenChange={(open) => {
if (!open) resumeRef.current = null
setModalOpen(open)
}}
onSubmit={onSubmitIdentity}
/>
</ThreadsProvider>
</WhatsNewProvider>
</IdentityProvider>
</ToastProvider>
</WidgetProvider>
Expand Down
55 changes: 30 additions & 25 deletions packages/client/src/marker/MarkerLayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -358,23 +359,25 @@ function renderLayer(client: unknown) {
<WidgetProvider>
<ToastProvider>
<IdentityProvider identity={null} requestIdentity={() => {}}>
<ThreadsProvider client={client as never}>
<PanelProvider client={client as never}>
<DraftsProvider>
<MarkerLayer
client={client as never}
pageKey="x.test/here"
pageUrl="https://x.test/here"
resolvePageKey={() => 'x.test/here'}
/>
<PanelDrawer
resolvePageKey={() => 'x.test/here'}
client={client as never}
branding={false}
/>
</DraftsProvider>
</PanelProvider>
</ThreadsProvider>
<WhatsNewProvider>
<ThreadsProvider client={client as never}>
<PanelProvider client={client as never}>
<DraftsProvider>
<MarkerLayer
client={client as never}
pageKey="x.test/here"
pageUrl="https://x.test/here"
resolvePageKey={() => 'x.test/here'}
/>
<PanelDrawer
resolvePageKey={() => 'x.test/here'}
client={client as never}
branding={false}
/>
</DraftsProvider>
</PanelProvider>
</ThreadsProvider>
</WhatsNewProvider>
</IdentityProvider>
</ToastProvider>
</WidgetProvider>,
Expand Down Expand Up @@ -542,14 +545,16 @@ function renderMarkerWithPanel(c: ReturnType<typeof client>) {
identity={{ email: 'a@b.c', name: 'A' }}
requestIdentity={(resume) => resume({ email: 'a@b.c', name: 'A' })}
>
<ThreadsProvider client={c as never}>
<PanelProvider client={c as never}>
<DraftsProvider>
<MarkerLayer client={c as never} pageKey="k" pageUrl="https://x.test/p" />
<PanelDrawer resolvePageKey={() => 'k'} client={c as never} />
</DraftsProvider>
</PanelProvider>
</ThreadsProvider>
<WhatsNewProvider>
<ThreadsProvider client={c as never}>
<PanelProvider client={c as never}>
<DraftsProvider>
<MarkerLayer client={c as never} pageKey="k" pageUrl="https://x.test/p" />
<PanelDrawer resolvePageKey={() => 'k'} client={c as never} />
</DraftsProvider>
</PanelProvider>
</ThreadsProvider>
</WhatsNewProvider>
</IdentityProvider>
</ToastProvider>
</WidgetProvider>,
Expand Down
49 changes: 26 additions & 23 deletions packages/client/src/panel/PanelDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -160,29 +161,31 @@ function setup(opts: {
render(
<WidgetProvider>
<IdentityProvider identity={identity} requestIdentity={(resume) => resume(identity)}>
<ThreadsProvider client={client as never}>
<PanelProvider client={client as never}>
<DraftsProvider>
<Opener />
<CloseProbe />
<GhostOpener />
{opts.withProbes && <StatusProbe />}
{opts.withProbes && <CreateProbe />}
{opts.detailOpenerId && (
<>
<DetailOpener id={opts.detailOpenerId} />
<FocusProbe />
</>
)}
{opts.deleteProbeId && <DeleteProbe id={opts.deleteProbeId} />}
<PanelDrawer
resolvePageKey={resolvePageKey}
client={client as never}
branding={opts.branding ?? false}
/>
</DraftsProvider>
</PanelProvider>
</ThreadsProvider>
<WhatsNewProvider>
<ThreadsProvider client={client as never}>
<PanelProvider client={client as never}>
<DraftsProvider>
<Opener />
<CloseProbe />
<GhostOpener />
{opts.withProbes && <StatusProbe />}
{opts.withProbes && <CreateProbe />}
{opts.detailOpenerId && (
<>
<DetailOpener id={opts.detailOpenerId} />
<FocusProbe />
</>
)}
{opts.deleteProbeId && <DeleteProbe id={opts.deleteProbeId} />}
<PanelDrawer
resolvePageKey={resolvePageKey}
client={client as never}
branding={opts.branding ?? false}
/>
</DraftsProvider>
</PanelProvider>
</ThreadsProvider>
</WhatsNewProvider>
</IdentityProvider>
</WidgetProvider>,
)
Expand Down
17 changes: 12 additions & 5 deletions packages/client/src/panel/PanelListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ 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 { CloseIcon, SparkleIcon } from '../ui/icons'
import { PoweredBy } from '../ui/PoweredBy'
import { CloseIcon } from '../ui/icons'
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'
Expand All @@ -29,6 +30,7 @@ export function PanelListView({ onSelect, branding = false }: PanelListViewProps
const panel = usePanelController()
const threads = useController()
const showResolved = useShowResolved()
const { openWhatsNew } = useWhatsNew()
const mainList = mainListExcludingReview(state)

const toggleResolve = (t: { id: string; status: string }) =>
Expand All @@ -53,11 +55,16 @@ export function PanelListView({ onSelect, branding = false }: PanelListViewProps
<Dialog.Description className="air:sr-only">
Comment threads across all pages
</Dialog.Description>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" aria-label="Close panel">
<CloseIcon />
<div className="air:flex air:items-center air:gap-1">
<Button variant="ghost" size="icon" aria-label="What's new" onClick={openWhatsNew}>
<SparkleIcon />
</Button>
</Dialog.Close>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" aria-label="Close panel">
<CloseIcon />
</Button>
</Dialog.Close>
</div>
</div>

<fieldset className="air:m-0 air:p-0 air:border-0 air:min-w-0">
Expand Down
16 changes: 15 additions & 1 deletion packages/client/src/settings/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -81,11 +88,18 @@ 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('rejects a non-version whatsNewSeen string (would compare as NaN)', () => {
initSettings(fakeStorage({ 'airside:whats-new-seen': JSON.stringify('garbage') }))
expect(getSetting('whatsNewSeen')).toBeNull()
})

it('clamps an out-of-range launcher top to the on-screen band', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/settings/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand All @@ -24,6 +25,7 @@ const ENTRIES = {
identity: identitySetting,
launcherPosition: launcherPositionSetting,
pinsHidden: pinsHiddenSetting,
whatsNewSeen: whatsNewSeenSetting,
} satisfies Record<string, SettingEntry<unknown>>

/** A known setting's name — the keys of {@link ENTRIES}, the single registration point. */
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/ui/icons/icons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
MoreIcon,
ReopenIcon,
resolveIcon,
SparkleIcon,
SpinnerIcon,
} from './index'

Expand All @@ -19,6 +20,7 @@ const ICONS: Array<[string, IconComponent]> = [
['AttachIcon', AttachIcon],
['ExternalLinkIcon', ExternalLinkIcon],
['ReopenIcon', ReopenIcon],
['SparkleIcon', SparkleIcon],
]

describe('icons', () => {
Expand Down
Loading
Loading