diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..294c297 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,6 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "clawmaster" + +[setup] +script = "npm install" diff --git a/packages/web/src/locales/main/en.ts b/packages/web/src/locales/main/en.ts index 5bb965f..d214b57 100644 --- a/packages/web/src/locales/main/en.ts +++ b/packages/web/src/locales/main/en.ts @@ -581,6 +581,7 @@ export default { "settings.checkUpdate": "Check for updates", "settings.checking": "Checking for updates...", "settings.upToDate": "You're on the latest version", + "settings.updateBackendUnavailable": "Cannot reach the ClawMaster backend. Start the app with npm run dev:web or open the desktop app, then try again.", "settings.targetVersion": "Target version:", "settings.updateTo": "Update to {{version}}", "settings.downgrade": "Downgrade to {{version}}", diff --git a/packages/web/src/locales/main/ja.ts b/packages/web/src/locales/main/ja.ts index fc2f683..2eeff7d 100644 --- a/packages/web/src/locales/main/ja.ts +++ b/packages/web/src/locales/main/ja.ts @@ -581,6 +581,7 @@ export default { "settings.checkUpdate": "アップデートを確認", "settings.checking": "アップデートを確認中...", "settings.upToDate": "最新バージョンです", + "settings.updateBackendUnavailable": "ClawMaster バックエンドに接続できません。npm run dev:web で起動するか、デスクトップアプリを開いてから再試行してください。", "settings.targetVersion": "ターゲットバージョン:", "settings.updateTo": "{{version}} にアップデート", "settings.downgrade": "{{version}} にダウングレード", diff --git a/packages/web/src/locales/main/zh.ts b/packages/web/src/locales/main/zh.ts index f10c968..1d19770 100644 --- a/packages/web/src/locales/main/zh.ts +++ b/packages/web/src/locales/main/zh.ts @@ -581,6 +581,7 @@ export default { "settings.checkUpdate": "检查更新", "settings.checking": "正在检查更新...", "settings.upToDate": "已是最新版本", + "settings.updateBackendUnavailable": "无法连接 ClawMaster 后端。请使用 npm run dev:web 启动,或打开桌面应用后重试。", "settings.targetVersion": "目标版本:", "settings.updateTo": "更新到 {{version}}", "settings.downgrade": "降级到 {{version}}", diff --git a/packages/web/src/modules/settings/SettingsPage.tsx b/packages/web/src/modules/settings/SettingsPage.tsx index 30c25a6..ad69fcb 100644 --- a/packages/web/src/modules/settings/SettingsPage.tsx +++ b/packages/web/src/modules/settings/SettingsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, useCallback } from 'react' +import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { Link, useLocation } from 'react-router-dom' import { platform } from '@/adapters' @@ -1228,6 +1229,158 @@ async function fetchReleaseNotes(limit = 10): Promise { } } +function isBrowserFetchFailure(error?: string | null): boolean { + return ( + error === 'Failed to fetch' || + error === 'Load failed' || + error === 'NetworkError when attempting to fetch resource.' + ) +} + +function renderReleaseInlineMarkdown(text: string, keyPrefix: string): ReactNode[] { + const nodes: ReactNode[] = [] + const pattern = /\[([^\]]+)]\((https?:\/\/[^)\s]+)\)|`([^`]+)`|\*\*([^*]+)\*\*|\*([^*]+)\*/g + let lastIndex = 0 + let matchIndex = 0 + + for (const match of text.matchAll(pattern)) { + const start = match.index ?? 0 + if (start > lastIndex) { + nodes.push(text.slice(lastIndex, start)) + } + + if (match[1] !== undefined && match[2] !== undefined) { + nodes.push( + + {match[1]} + , + ) + } else if (match[3] !== undefined) { + nodes.push( + + {match[3]} + , + ) + } else if (match[4] !== undefined) { + nodes.push( + + {match[4]} + , + ) + } else if (match[5] !== undefined) { + nodes.push( + + {match[5]} + , + ) + } + + lastIndex = start + match[0].length + matchIndex += 1 + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)) + } + + return nodes.length ? nodes : [text] +} + +function renderReleaseMarkdown(markdown: string, keyPrefix: string): ReactNode[] { + const lines = markdown.replace(/\r/g, '').split('\n') + const nodes: ReactNode[] = [] + let index = 0 + + while (index < lines.length) { + const line = lines[index] ?? '' + const trimmed = line.trim() + + if (!trimmed) { + index += 1 + continue + } + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/) + if (headingMatch) { + const level = headingMatch[1].length + const Tag = `h${Math.min(level + 2, 6)}` as keyof JSX.IntrinsicElements + const className = + level <= 2 + ? 'mt-3 text-sm font-semibold text-foreground' + : 'mt-3 text-xs font-semibold uppercase text-foreground' + nodes.push( + + {renderReleaseInlineMarkdown(headingMatch[2].trim(), `${keyPrefix}-heading-${index}`)} + , + ) + index += 1 + continue + } + + if (/^[-*+]\s+/.test(trimmed)) { + const items: string[] = [] + while (index < lines.length && /^[-*+]\s+/.test((lines[index] ?? '').trim())) { + items.push((lines[index] ?? '').trim().replace(/^[-*+]\s+/, '')) + index += 1 + } + nodes.push( + , + ) + continue + } + + if (/^\d+\.\s+/.test(trimmed)) { + const items: string[] = [] + while (index < lines.length && /^\d+\.\s+/.test((lines[index] ?? '').trim())) { + items.push((lines[index] ?? '').trim().replace(/^\d+\.\s+/, '')) + index += 1 + } + nodes.push( +
    + {items.map((item, itemIndex) => ( +
  1. + {renderReleaseInlineMarkdown(item, `${keyPrefix}-ol-${index}-${itemIndex}`)} +
  2. + ))} +
, + ) + continue + } + + const paragraphLines = [trimmed] + index += 1 + while (index < lines.length) { + const next = (lines[index] ?? '').trim() + if (!next || /^(#{1,6})\s+/.test(next) || /^[-*+]\s+/.test(next) || /^\d+\.\s+/.test(next)) break + paragraphLines.push(next) + index += 1 + } + + nodes.push( +

+ {renderReleaseInlineMarkdown(paragraphLines.join(' '), `${keyPrefix}-p-${index}`)} +

, + ) + } + + return nodes +} + function UpdateSection({ currentVersion, installed, @@ -1270,7 +1423,11 @@ function UpdateSection({ const upToDate = currentVersion && currentVersion.includes(latest) setState(upToDate ? 'up-to-date' : 'available') } else { - setError(result.error ?? t('common.unknownError')) + setError( + isBrowserFetchFailure(result.error) + ? t('settings.updateBackendUnavailable') + : result.error ?? t('common.unknownError') + ) setState('error') } }, [currentVersion, t]) @@ -1310,29 +1467,31 @@ function UpdateSection({ }, [handleCheck, location.hash, state, updateTask.status]) return ( -
-
-

{t('settings.update')}

-
-
- {/* Current version */} -
- OpenClaw CLI - - {installed ? `v${currentVersion}` : t('common.notInstalled')} - +
+
+
+

{t('settings.update')}

+
+ OpenClaw CLI + + {installed ? `v${currentVersion}` : t('common.notInstalled')} + +
{/* Check button (idle/error state) */} {(state === 'idle' || state === 'error') && updateTask.status === 'idle' && ( )} +
+ +
{/* Checking spinner */} {state === 'checking' && ( @@ -1360,9 +1519,9 @@ function UpdateSection({ {/* Update available */} {(state === 'available' || state === 'up-to-date') && versions && updateTask.status === 'idle' && ( -
+
{/* Channel selector */} -
+
{recentVersions.map((v) => (
diff --git a/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx b/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx index 9d2e86f..604cb57 100644 --- a/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx +++ b/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx @@ -26,6 +26,7 @@ vi.mock('react-i18next', () => ({ 'settings.checkUpdate': 'Check for updates', 'settings.checking': 'Checking...', 'settings.upToDate': 'Up to date', + 'settings.updateBackendUnavailable': 'Cannot reach the ClawMaster backend. Start the app with npm run dev:web or open the desktop app, then try again.', 'settings.updateChannel': 'Channel:', 'settings.targetVersion': 'Version:', 'settings.updateTo': `Update to ${opts?.version ?? ''}`, @@ -816,6 +817,23 @@ describe('UpdateSection', () => { }) }) + it('explains generic browser fetch failures during update checks', async () => { + mockListVersions.mockResolvedValue({ + success: false, + error: 'Failed to fetch', + }) + renderSettings() + await waitFor(() => screen.getByText('Check for updates')) + fireEvent.click(screen.getByText('Check for updates')) + await waitFor(() => { + expect( + screen.getByText( + 'Cannot reach the ClawMaster backend. Start the app with npm run dev:web or open the desktop app, then try again.' + ) + ).toBeInTheDocument() + }) + }) + it('calls reinstallOpenclawGlobal when update clicked', async () => { mockListVersions.mockResolvedValue({ success: true, @@ -879,6 +897,40 @@ describe('UpdateSection', () => { }) }) + it('renders release note markdown instead of raw markdown text', async () => { + mockListVersions.mockResolvedValue({ + success: true, + data: { + versions: ['2026.4.1', '2026.3.28'], + distTags: { latest: '2026.4.1' }, + }, + }) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + tag_name: 'v2026.4.1', + name: 'v2026.4.1', + body: '## 2026.4.1\n\n### Highlights\n\n- Voice replies get `tts latest`\n- Plugin startup paths move faster', + published_at: '2026-04-27T00:00:00.000Z', + html_url: 'https://example.com/releases/2026.4.1', + }, + ]), + { status: 200 }, + ), + ) + + renderSettings() + await waitFor(() => screen.getByText('Check for updates')) + fireEvent.click(screen.getByText('Check for updates')) + await waitFor(() => screen.getByText('Release Notes')) + fireEvent.click(screen.getByText('Release Notes')) + + expect(screen.getByRole('heading', { name: 'Highlights' })).toBeInTheDocument() + expect(screen.getByText('tts latest')).toBeInTheDocument() + expect(screen.queryByText('### Highlights')).not.toBeInTheDocument() + }) + it('saves a named OpenClaw profile from settings', async () => { renderSettings()