feat: add bilingual release notes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: '最小化',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user