feat: add bilingual release notes

This commit is contained in:
lofyer
2026-08-11 20:59:26 +08:00
parent 6942bef567
commit 44d30b428d
26 changed files with 1344 additions and 42 deletions
+45
View File
@@ -799,6 +799,51 @@ describe('App', () => {
}
})
it('shows and acknowledges pending release notes on startup', async () => {
const acknowledge = vi.fn(async () => {})
api.releaseNotes = {
getPending: vi.fn(async () => ({
currentVersion: '0.8.18',
releases: [
{
version: '0.8.18',
releasedAt: '2026-08-11',
notes: {
'zh-CN': {
features: ['新增版本更新说明'],
fixes: ['修复重复显示']
},
'en-US': {
features: ['Added release notes'],
fixes: ['Fixed repeated display']
}
}
}
]
})),
acknowledge
}
try {
render(<App />)
expect(
await screen.findByRole('dialog', {
name: 'GoodBuddy 0.8.18 更新内容'
})
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '开始使用' }))
await waitFor(() =>
expect(acknowledge).toHaveBeenCalledWith('0.8.18')
)
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
} finally {
delete api.releaseNotes
}
})
it('does not disturb startup when updates are current or offline', async () => {
const currentResult = {
updateAvailable: false,
+35
View File
@@ -143,6 +143,8 @@ import type {
AppNotificationInput,
AppNotificationTone
} from './notifications'
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
type AppNotification = {
id: string
@@ -1340,6 +1342,9 @@ function App(): React.JSX.Element {
const voiceStartingRef = useRef(false)
const voiceDisposedRef = useRef(false)
const startupUpdateCheckStartedRef = useRef(false)
const startupReleaseNotesStartedRef = useRef(false)
const [releaseNotes, setReleaseNotes] =
useState<ReleaseNotesSnapshot>()
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
@@ -1643,6 +1648,22 @@ function App(): React.JSX.Element {
.catch(() => undefined)
}, [i18n])
useEffect(() => {
const releaseNotesApi = window.goodbuddy.releaseNotes
if (!releaseNotesApi || startupReleaseNotesStartedRef.current) {
return
}
startupReleaseNotesStartedRef.current = true
void releaseNotesApi
.getPending()
.then((snapshot) => {
if (snapshot.releases.length > 0) {
setReleaseNotes(snapshot)
}
})
.catch(() => undefined)
}, [])
useEffect(() => {
applyAppearanceTheme(resolvedAppearanceTheme)
}, [resolvedAppearanceTheme])
@@ -6544,6 +6565,20 @@ function App(): React.JSX.Element {
dispatch={notify}
notifications={notifications}
/>
{releaseNotes && (
<ReleaseNotesDialog
locale={locale}
onAcknowledge={async (version) => {
const releaseNotesApi = window.goodbuddy.releaseNotes
if (!releaseNotesApi) {
throw new Error('Release notes service is unavailable')
}
await releaseNotesApi.acknowledge(version)
}}
onClose={() => setReleaseNotes(undefined)}
snapshot={releaseNotes}
/>
)}
{imageViewerItem && (
<div
className="image-viewer-backdrop"
@@ -0,0 +1,125 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
import { changeUiLocale } from './i18n'
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
const snapshot: ReleaseNotesSnapshot = {
currentVersion: '0.8.18',
releases: [
{
version: '0.8.18',
releasedAt: '2026-08-11',
notes: {
'zh-CN': {
features: ['新增双语界面'],
fixes: ['修复开关尺寸']
},
'en-US': {
features: ['Added a bilingual interface'],
fixes: ['Fixed switch dimensions']
}
}
}
]
}
function Harness({
acknowledge,
locale = 'zh-CN'
}: {
acknowledge: (version: string) => Promise<void>
locale?: 'zh-CN' | 'en-US'
}): React.JSX.Element {
const [open, setOpen] = useState(true)
return (
<>
<div className="app-shell">
<button type="button">Background</button>
</div>
{open && (
<ReleaseNotesDialog
locale={locale}
onAcknowledge={acknowledge}
onClose={() => setOpen(false)}
snapshot={snapshot}
/>
)}
</>
)
}
afterEach(async () => {
cleanup()
await changeUiLocale('zh-CN')
})
describe('ReleaseNotesDialog', () => {
it('shows localized notes once and acknowledges before closing', async () => {
const acknowledge = vi.fn(async () => {})
const { container } = render(<Harness acknowledge={acknowledge} />)
expect(
screen.getByRole('dialog', {
name: 'GoodBuddy 0.8.18 更新内容'
})
).toBeInTheDocument()
expect(screen.getByText('新增双语界面')).toBeInTheDocument()
expect(screen.getByText('修复开关尺寸')).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(
container.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(true)
fireEvent.click(screen.getByRole('button', { name: '开始使用' }))
await waitFor(() =>
expect(acknowledge).toHaveBeenCalledWith('0.8.18')
)
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(
container.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(false)
})
it('keeps the dialog open when acknowledgement fails', async () => {
const acknowledge = vi.fn(async () => {
throw new Error('disk failed')
})
render(<Harness acknowledge={acknowledge} />)
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(
await screen.findByRole('alert')
).toHaveTextContent('无法保存已读状态,请重试。')
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('renders the approved English release notes', async () => {
await changeUiLocale('en-US')
render(<Harness acknowledge={vi.fn(async () => {})} locale="en-US" />)
expect(
screen.getByRole('dialog', {
name: "What's New in GoodBuddy 0.8.18"
})
).toBeInTheDocument()
expect(
screen.getByText('Added a bilingual interface')
).toBeInTheDocument()
expect(screen.getByText('Fixed switch dimensions')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Get Started' })
).toBeInTheDocument()
})
})
+190
View File
@@ -0,0 +1,190 @@
import { Sparkles, Wrench, X } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type {
ReleaseNote,
ReleaseNotesSnapshot
} from '../../shared/release-notes-contracts'
import { trapTabFocus } from './dialog-focus'
import type { UiLocale } from './i18n'
type ReleaseNotesDialogProps = {
locale: UiLocale
snapshot: ReleaseNotesSnapshot
onAcknowledge: (version: string) => Promise<void>
onClose: () => void
}
function ReleaseSection({
locale,
release,
showVersion
}: {
locale: UiLocale
release: ReleaseNote
showVersion: boolean
}): React.JSX.Element {
const { t } = useTranslation('app')
const notes = release.notes[locale]
const releaseHeadingId = useId()
const SectionHeading = showVersion ? 'h4' : 'h3'
return (
<section
aria-labelledby={showVersion ? releaseHeadingId : undefined}
className="release-notes-dialog__release"
>
{showVersion && (
<h3
className="release-notes-dialog__version"
id={releaseHeadingId}
>
GoodBuddy {release.version}
</h3>
)}
{notes.features.length > 0 && (
<div className="release-notes-dialog__section">
<SectionHeading>
<Sparkles aria-hidden="true" size={16} />
{t('releaseNotes.features')}
</SectionHeading>
<ul>
{notes.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
</div>
)}
{notes.fixes.length > 0 && (
<div className="release-notes-dialog__section">
<SectionHeading>
<Wrench aria-hidden="true" size={16} />
{t('releaseNotes.fixes')}
</SectionHeading>
<ul>
{notes.fixes.map((fix) => (
<li key={fix}>{fix}</li>
))}
</ul>
</div>
)}
</section>
)
}
export function ReleaseNotesDialog({
locale,
snapshot,
onAcknowledge,
onClose
}: ReleaseNotesDialogProps): React.JSX.Element {
const { t } = useTranslation('app')
const dialogRef = useRef<HTMLElement>(null)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const [closing, setClosing] = useState(false)
const [error, setError] = useState<string>()
const titleId = useId()
const descriptionId = useId()
useEffect(() => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null
const appShell = document.querySelector<HTMLElement>('.app-shell')
const wasInert = appShell?.inert ?? false
if (appShell) {
appShell.inert = true
}
return () => {
if (appShell) {
appShell.inert = wasInert
}
restoreFocusRef.current?.focus()
}
}, [])
const close = async (): Promise<void> => {
if (closing) {
return
}
setClosing(true)
setError(undefined)
try {
await onAcknowledge(snapshot.currentVersion)
onClose()
} catch {
setError(t('releaseNotes.acknowledgeFailed'))
setClosing(false)
}
}
return createPortal(
<div className="release-notes-backdrop">
<section
aria-describedby={descriptionId}
aria-labelledby={titleId}
aria-modal="true"
className="release-notes-dialog"
onKeyDown={(event) => {
if (event.key === 'Escape' && !closing) {
event.preventDefault()
void close()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header className="release-notes-dialog__header">
<div>
<span className="release-notes-dialog__eyebrow">
{t('releaseNotes.eyebrow')}
</span>
<h2 id={titleId}>
{t('releaseNotes.title', {
version: snapshot.currentVersion
})}
</h2>
<p id={descriptionId}>{t('releaseNotes.description')}</p>
</div>
<button
aria-label={t('releaseNotes.close')}
className="icon-button"
disabled={closing}
onClick={() => void close()}
type="button"
>
<X aria-hidden="true" size={16} />
</button>
</header>
<div className="release-notes-dialog__content">
{snapshot.releases.map((release) => (
<ReleaseSection
key={release.version}
locale={locale}
release={release}
showVersion={snapshot.releases.length > 1}
/>
))}
</div>
<footer className="release-notes-dialog__footer">
{error && <p role="alert">{error}</p>}
<button
autoFocus
className="primary-button"
disabled={closing}
onClick={() => void close()}
type="button"
>
{closing
? t('releaseNotes.closing')
: t('releaseNotes.start')}
</button>
</footer>
</section>
</div>,
document.body
)
}
@@ -9,6 +9,18 @@ export const app = {
close: 'Close notification',
viewport: 'App notifications'
},
releaseNotes: {
eyebrow: 'VERSION UPDATE',
title: "What's New in GoodBuddy {{version}}",
description:
'This release includes the following features and bug fixes.',
features: 'Features',
fixes: 'Bug Fixes',
close: 'Close release notes',
start: 'Get Started',
closing: 'Closing…',
acknowledgeFailed: 'Could not save the read state. Please try again.'
},
window: {
minimizeAria: 'Minimize window',
minimize: 'Minimize',
@@ -6,6 +6,17 @@ export const app = {
close: '关闭通知',
viewport: '应用通知'
},
releaseNotes: {
eyebrow: '版本更新',
title: 'GoodBuddy {{version}} 更新内容',
description: '本次版本带来了以下功能更新与问题修复。',
features: '功能更新',
fixes: '问题修复',
close: '关闭版本更新说明',
start: '开始使用',
closing: '正在关闭…',
acknowledgeFailed: '无法保存已读状态,请重试。'
},
window: {
minimizeAria: '最小化窗口',
minimize: '最小化',
+158
View File
@@ -3585,6 +3585,141 @@ button > svg * {
object-fit: contain;
}
.release-notes-backdrop {
position: fixed;
z-index: 90;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.release-notes-dialog {
display: grid;
width: min(680px, 100%);
max-height: calc(100vh - 70px);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
overflow: hidden;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr) auto;
}
.release-notes-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-6);
border-bottom: 1px solid var(--border-subtle);
gap: var(--space-4);
}
.release-notes-dialog__header > div {
min-width: 0;
}
.release-notes-dialog__eyebrow {
display: block;
margin-bottom: var(--space-2);
color: var(--accent);
font-size: var(--font-caption);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.release-notes-dialog__header h2 {
margin: 0;
color: var(--text-primary);
font-size: var(--font-page-title);
line-height: 1.25;
}
.release-notes-dialog__header p {
margin: var(--space-2) 0 0;
color: var(--text-secondary);
font-size: var(--font-body);
line-height: 1.55;
}
.release-notes-dialog__content {
min-height: 0;
padding: var(--space-6);
overflow-y: auto;
}
.release-notes-dialog__release {
display: grid;
gap: var(--space-6);
}
.release-notes-dialog__release + .release-notes-dialog__release {
padding-top: var(--space-6);
margin-top: var(--space-6);
border-top: 1px solid var(--border-default);
}
.release-notes-dialog__version {
margin: 0;
color: var(--text-primary);
font-size: var(--font-section-title);
}
.release-notes-dialog__section {
display: grid;
gap: var(--space-3);
}
.release-notes-dialog__section :is(h3, h4) {
display: flex;
align-items: center;
margin: 0;
color: var(--text-primary);
font-size: var(--font-section-title);
gap: var(--space-2);
}
.release-notes-dialog__section :is(h3, h4) svg {
color: var(--accent);
flex: 0 0 auto;
}
.release-notes-dialog__section ul {
display: grid;
padding-left: var(--space-6);
margin: 0;
color: var(--text-secondary);
font-size: var(--font-body);
gap: var(--space-2);
line-height: 1.6;
}
.release-notes-dialog__section li::marker {
color: var(--accent);
}
.release-notes-dialog__footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--border-subtle);
background: var(--surface-subtle);
gap: var(--space-4);
}
.release-notes-dialog__footer p {
margin: 0 auto 0 0;
color: var(--danger);
font-size: var(--font-caption);
}
.release-notes-dialog__footer .primary-button {
min-width: 104px;
}
.composer:focus-within {
border-color: var(--accent);
box-shadow:
@@ -9189,6 +9324,29 @@ details.settings-section > :not(summary) + :not(summary) {
}
@media (max-width: 720px) {
.release-notes-backdrop {
padding: var(--space-4);
}
.release-notes-dialog {
max-height: calc(100vh - 70px);
}
.release-notes-dialog__header,
.release-notes-dialog__content {
padding: var(--space-4);
}
.release-notes-dialog__footer {
align-items: stretch;
padding: var(--space-4);
flex-direction: column;
}
.release-notes-dialog__footer .primary-button {
width: 100%;
}
.workspace-panel-scroll {
padding: var(--page-gutter);
}