Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .codex/environments/environment.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "clawmaster"

[setup]
script = "npm install"
1 change: 1 addition & 0 deletions packages/web/src/locales/main/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/locales/main/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}} にダウングレード",
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/locales/main/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
207 changes: 183 additions & 24 deletions packages/web/src/modules/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -1228,6 +1229,158 @@ async function fetchReleaseNotes(limit = 10): Promise<ReleaseNote[]> {
}
}

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(
<a
key={`${keyPrefix}-link-${matchIndex}`}
href={match[2]}
target="_blank"
rel="noreferrer"
className="text-primary underline decoration-primary/40 underline-offset-4 hover:decoration-primary"
>
{match[1]}
</a>,
)
} else if (match[3] !== undefined) {
nodes.push(
<code
key={`${keyPrefix}-code-${matchIndex}`}
className="rounded-md bg-muted/70 px-1 py-0.5 font-mono text-[0.92em] text-foreground"
>
{match[3]}
</code>,
)
} else if (match[4] !== undefined) {
nodes.push(
<strong key={`${keyPrefix}-strong-${matchIndex}`} className="font-semibold text-foreground">
{match[4]}
</strong>,
)
} else if (match[5] !== undefined) {
nodes.push(
<em key={`${keyPrefix}-em-${matchIndex}`} className="italic">
{match[5]}
</em>,
)
}

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(
<Tag key={`${keyPrefix}-heading-${index}`} className={className}>
{renderReleaseInlineMarkdown(headingMatch[2].trim(), `${keyPrefix}-heading-${index}`)}
</Tag>,
)
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(
<ul key={`${keyPrefix}-ul-${index}`} className="ml-4 list-disc space-y-1">
{items.map((item, itemIndex) => (
<li key={`${keyPrefix}-ul-${index}-${itemIndex}`}>
{renderReleaseInlineMarkdown(item, `${keyPrefix}-ul-${index}-${itemIndex}`)}
</li>
))}
</ul>,
)
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(
<ol key={`${keyPrefix}-ol-${index}`} className="ml-4 list-decimal space-y-1">
{items.map((item, itemIndex) => (
<li key={`${keyPrefix}-ol-${index}-${itemIndex}`}>
{renderReleaseInlineMarkdown(item, `${keyPrefix}-ol-${index}-${itemIndex}`)}
</li>
))}
</ol>,
)
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(
<p key={`${keyPrefix}-p-${index}`}>
{renderReleaseInlineMarkdown(paragraphLines.join(' '), `${keyPrefix}-p-${index}`)}
</p>,
)
}

return nodes
}

function UpdateSection({
currentVersion,
installed,
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -1310,29 +1467,31 @@ function UpdateSection({
}, [handleCheck, location.hash, state, updateTask.status])

return (
<section id="settings-update" className="surface-card">
<div className="section-heading">
<h3 className="section-title">{t('settings.update')}</h3>
</div>
<div className="space-y-3 text-sm">
{/* Current version */}
<div className="flex items-center justify-between">
<span>OpenClaw CLI</span>
<span className="text-muted-foreground font-mono">
{installed ? `v${currentVersion}` : t('common.notInstalled')}
</span>
<section id="settings-update" className="surface-card !p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<h3 className="section-title text-lg">{t('settings.update')}</h3>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<span>OpenClaw CLI</span>
<span className="break-all font-mono text-muted-foreground">
{installed ? `v${currentVersion}` : t('common.notInstalled')}
</span>
</div>
</div>

{/* Check button (idle/error state) */}
{(state === 'idle' || state === 'error') && updateTask.status === 'idle' && (
<button
onClick={handleCheck}
className="button-secondary"
className="button-secondary h-9 shrink-0 px-3 py-1.5"
>
<RefreshCw className="w-3.5 h-3.5" />
{t('settings.checkUpdate')}
</button>
)}
</div>

<div className="mt-3 space-y-2 text-sm">

{/* Checking spinner */}
{state === 'checking' && (
Expand Down Expand Up @@ -1360,9 +1519,9 @@ function UpdateSection({

{/* Update available */}
{(state === 'available' || state === 'up-to-date') && versions && updateTask.status === 'idle' && (
<div className="inline-note space-y-3">
<div className="space-y-3 rounded-xl border border-border/70 bg-muted/30 p-3">
{/* Channel selector */}
<div className="grid gap-2 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center">
<div className="grid gap-2 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-center">
<label className="text-muted-foreground">{t('settings.updateChannel')}</label>
<select
value={channel}
Expand All @@ -1374,7 +1533,7 @@ function UpdateSection({
const upToDate = currentVersion && currentVersion.includes(ver)
setState(upToDate ? 'up-to-date' : 'available')
}}
className="control-select"
className="control-select h-9 py-1.5"
>
<option value="stable">Stable</option>
<option value="beta">Beta</option>
Expand All @@ -1383,7 +1542,7 @@ function UpdateSection({
</div>

{/* Version selector */}
<div className="grid gap-2 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center">
<div className="grid gap-2 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-center">
<label className="text-muted-foreground">{t('settings.targetVersion')}</label>
<select
value={selectedVersion}
Expand All @@ -1392,7 +1551,7 @@ function UpdateSection({
const upToDate = currentVersion && currentVersion.includes(e.target.value)
setState(upToDate ? 'up-to-date' : 'available')
}}
className="control-select w-full font-mono"
className="control-select h-9 w-full py-1.5 font-mono"
>
{recentVersions.map((v) => (
<option key={v} value={v}>
Expand All @@ -1406,7 +1565,7 @@ function UpdateSection({
{!isUpToDate && selectedVersion && (
<button
onClick={handleUpdate}
className="button-primary text-sm"
className="button-primary h-9 px-3 py-1.5 text-sm"
>
{currentVersion && selectedVersion < currentVersion
? t('settings.downgrade', { version: selectedVersion })
Expand All @@ -1415,9 +1574,9 @@ function UpdateSection({
)}

{/* Dist tags info */}
<div className="text-xs text-muted-foreground">
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{Object.entries(versions.distTags).map(([tag, ver]) => (
<span key={tag} className="mr-3">
<span key={tag}>
<span className="font-medium">{tag}</span>: <span className="font-mono">{ver}</span>
</span>
))}
Expand Down Expand Up @@ -1452,9 +1611,9 @@ function UpdateSection({
</a>
)}
</div>
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-sans leading-relaxed">
{r.body.length > 500 ? r.body.slice(0, 500) + '...' : r.body}
</pre>
<div className="space-y-2 text-xs leading-6 text-muted-foreground">
{renderReleaseMarkdown(r.body, `release-${r.version}`)}
</div>
</div>
))}
</div>
Expand Down
52 changes: 52 additions & 0 deletions packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? ''}`,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down
Loading