feat: add browser use runtime setup settings

This commit is contained in:
Simos Mikelatos
2026-06-15 17:52:27 +00:00
parent 828d1a2302
commit 260070bae0
11 changed files with 450 additions and 54 deletions

View File

@@ -22,8 +22,54 @@ function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || '';
}
router.get('/status', (_req, res) => {
res.json({ success: true, data: browserUseService.getStatus() });
router.get('/status', async (_req, res) => {
try {
res.json({ success: true, data: await browserUseService.getStatus() });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser Use status.',
});
}
});
router.get('/settings', async (_req, res) => {
try {
res.json({ success: true, data: { settings: await browserUseService.getSettings() } });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser Use settings.',
});
}
});
router.put('/settings', async (req, res) => {
try {
const settings = await browserUseService.updateSettings(req.body || {});
res.json({ success: true, data: { settings } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to save Browser Use settings.',
});
}
});
router.post('/runtime/install', async (_req, res) => {
try {
const result = await browserUseService.installRuntime();
res.status(result.success ? 200 : 500).json({
success: result.success,
data: result,
error: result.success ? undefined : result.message,
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to install Browser Use runtime.',
});
}
});
router.get('/sessions', async (req: AuthenticatedRequest, res) => {

View File

@@ -1,13 +1,19 @@
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import dns from 'node:dns/promises';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
const require = createRequire(import.meta.url);
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
const ALLOW_PRIVATE_NETWORKS = process.env.CLOUDCLI_BROWSER_USE_ALLOW_PRIVATE_NETWORKS === '1';
const SETTINGS_PATH = path.join(os.homedir(), '.cloudcli', 'browser-use-settings.json');
type BrowserUseRuntime = 'cloud' | 'local';
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
@@ -37,23 +43,71 @@ type BrowserUseOwner = {
id: string | number;
};
type BrowserUseSettings = {
enabled: boolean;
};
type RuntimeReadiness = {
playwright: any | null;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
chromiumExecutablePath: string | null;
installInProgress: boolean;
installMessage: string | null;
};
const sessions = new Map<string, BrowserUseSession>();
const handles = new Map<string, RuntimeHandle>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null;
const DEFAULT_SETTINGS: BrowserUseSettings = {
enabled: true,
};
function getRuntime(): BrowserUseRuntime {
return IS_PLATFORM ? 'cloud' : 'local';
}
function isBrowserUseEnabled(): boolean {
return process.env.CLOUDCLI_BROWSER_USE_ENABLED === '1';
async function readSettings(): Promise<BrowserUseSettings> {
try {
const raw = await fsPromises.readFile(SETTINGS_PATH, 'utf8');
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return {
enabled: parsed.enabled !== false,
};
} catch (error: any) {
if (error?.code !== 'ENOENT') {
console.warn('[Browser Use] Failed to read settings:', error?.message || error);
}
return DEFAULT_SETTINGS;
}
}
function getSetupMessage(): string {
if (!isBrowserUseEnabled()) {
return 'Browser Use is disabled. Set CLOUDCLI_BROWSER_USE_ENABLED=1 after provisioning a Playwright/Chromium runtime.';
async function writeSettings(settings: BrowserUseSettings): Promise<BrowserUseSettings> {
const normalized = {
enabled: settings.enabled !== false,
};
await fsPromises.mkdir(path.dirname(SETTINGS_PATH), { recursive: true });
await fsPromises.writeFile(SETTINGS_PATH, JSON.stringify(normalized, null, 2), 'utf8');
return normalized;
}
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) {
return 'Browser Use is disabled in settings.';
}
return 'Playwright is not available in this runtime. Install/provision Playwright or point CloudCLI at a managed browser worker.';
if (!readiness.playwrightInstalled) {
return 'Install Playwright and Chromium to use browser sessions.';
}
if (!readiness.chromiumInstalled) {
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
}
return readiness.installMessage || 'Browser Use runtime is not ready.';
}
function getPlaywright(): any | null {
@@ -64,6 +118,85 @@ function getPlaywright(): any | null {
}
}
function getRuntimeReadiness(): RuntimeReadiness {
const playwright = getPlaywright();
const readiness: RuntimeReadiness = {
playwright,
playwrightInstalled: Boolean(playwright),
chromiumInstalled: false,
chromiumExecutablePath: null,
installInProgress: Boolean(installPromise),
installMessage: lastInstallMessage,
};
if (!playwright) {
return readiness;
}
try {
const executablePath = playwright.chromium.executablePath();
readiness.chromiumExecutablePath = executablePath;
readiness.chromiumInstalled = Boolean(executablePath && fs.existsSync(executablePath));
} catch {
readiness.chromiumInstalled = false;
}
return readiness;
}
function runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
const output: string[] = [];
child.stdout.on('data', (chunk) => output.push(String(chunk)));
child.stderr.on('data', (chunk) => output.push(String(chunk)));
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
});
});
}
async function installRuntime(): Promise<{ success: boolean; message: string }> {
if (installPromise) {
return installPromise;
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
installPromise = (async () => {
try {
lastInstallMessage = 'Installing Playwright package...';
await runCommand(npmCommand, ['install', '--no-save', '--no-package-lock', 'playwright']);
lastInstallMessage = 'Installing Chromium runtime...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
lastInstallMessage = 'Browser Use runtime installed.';
return { success: true, message: lastInstallMessage };
} catch (error) {
lastInstallMessage = error instanceof Error ? error.message : 'Failed to install Browser Use runtime.';
return { success: false, message: lastInstallMessage };
}
})();
try {
return await installPromise;
} finally {
installPromise = null;
}
}
function getOwnerId(owner: BrowserUseOwner): string {
if (owner.id === undefined || owner.id === null || String(owner.id).trim() === '') {
throw new Error('Authenticated user is required.');
@@ -218,19 +351,43 @@ async function captureSession(session: BrowserUseSession, page: any): Promise<vo
}
export const browserUseService = {
getStatus() {
const playwright = getPlaywright();
const enabled = isBrowserUseEnabled() && Boolean(playwright);
async getSettings() {
return readSettings();
},
async updateSettings(settings: Partial<BrowserUseSettings>) {
const current = await readSettings();
return writeSettings({
...current,
enabled: settings.enabled ?? current.enabled,
});
},
async getStatus() {
const settings = await readSettings();
const readiness = getRuntimeReadiness();
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
return {
enabled,
enabled: settings.enabled,
runtime: getRuntime(),
available: enabled,
available,
playwrightInstalled: readiness.playwrightInstalled,
chromiumInstalled: readiness.chromiumInstalled,
installInProgress: readiness.installInProgress,
sessionCount: sessions.size,
mcpRecommended: true,
message: enabled
message: available
? 'Browser Use runtime is available.'
: getSetupMessage(),
: getSetupMessage(settings, readiness),
};
},
async installRuntime() {
const result = await installRuntime();
return {
...result,
status: await this.getStatus(),
};
},
@@ -264,14 +421,15 @@ export const browserUseService = {
throw new Error(`Browser Use is limited to ${MAX_SESSIONS_PER_OWNER} active sessions per user.`);
}
const playwright = getPlaywright();
if (!isBrowserUseEnabled() || !playwright) {
session.message = getSetupMessage();
const settings = await readSettings();
const readiness = getRuntimeReadiness();
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
session.message = getSetupMessage(settings, readiness);
sessions.set(session.id, session);
return publicSession(session);
}
const browser = await playwright.chromium.launch({
const browser = await readiness.playwright.chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage'],
});

View File

@@ -15,27 +15,16 @@ test('browser use blocks private and local network addresses by default', () =>
});
test('browser use sessions are listed only for their owner', async () => {
const originalEnabled = process.env.CLOUDCLI_BROWSER_USE_ENABLED;
process.env.CLOUDCLI_BROWSER_USE_ENABLED = '0';
const ownerA = { id: `owner-a-${Date.now()}-${Math.random()}` };
const ownerB = { id: `owner-b-${Date.now()}-${Math.random()}` };
try {
const ownerASession = await browserUseService.createSession(ownerA);
await browserUseService.createSession(ownerB);
const ownerASession = await browserUseService.createSession(ownerA);
await browserUseService.createSession(ownerB);
const ownerASessions = await browserUseService.listSessions(ownerA);
const ownerBSessions = await browserUseService.listSessions(ownerB);
const ownerASessions = await browserUseService.listSessions(ownerA);
const ownerBSessions = await browserUseService.listSessions(ownerB);
assert.equal(ownerASessions.some((session) => session.id === ownerASession.id), true);
assert.equal(ownerBSessions.some((session) => session.id === ownerASession.id), false);
assert.equal(Object.hasOwn(ownerASession, 'ownerId'), false);
} finally {
if (originalEnabled === undefined) {
delete process.env.CLOUDCLI_BROWSER_USE_ENABLED;
} else {
process.env.CLOUDCLI_BROWSER_USE_ENABLED = originalEnabled;
}
}
assert.equal(ownerASessions.some((session) => session.id === ownerASession.id), true);
assert.equal(ownerBSessions.some((session) => session.id === ownerASession.id), false);
assert.equal(Object.hasOwn(ownerASession, 'ownerId'), false);
});

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ExternalLink, Globe, MonitorPlay, Navigation, Pause, RefreshCw, Square } from 'lucide-react';
import { Download, ExternalLink, Globe, Loader2, MonitorPlay, Navigation, Pause, RefreshCw, Square } from 'lucide-react';
import { Badge, Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api';
@@ -8,6 +8,9 @@ type BrowserUseStatus = {
enabled: boolean;
available: boolean;
runtime: 'cloud' | 'local';
playwrightInstalled: boolean;
chromiumInstalled: boolean;
installInProgress: boolean;
sessionCount: number;
mcpRecommended: boolean;
message: string;
@@ -44,6 +47,7 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [targetUrl, setTargetUrl] = useState('https://example.com');
const [isBusy, setIsBusy] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedSession = useMemo(
@@ -108,6 +112,18 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
await readJson(response);
});
const installRuntime = () => runAction(async () => {
setIsInstalling(true);
try {
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
await readJson(response);
} finally {
setIsInstalling(false);
}
});
const canInstallRuntime = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
@@ -130,7 +146,7 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
<Button size="sm" onClick={createSession} disabled={isBusy}>
<Button size="sm" onClick={createSession} disabled={isBusy || !status?.available}>
<Globe className="h-4 w-4" />
New Session
</Button>
@@ -143,6 +159,22 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Runtime</div>
<div className="mt-2 text-sm text-foreground">{status?.available ? 'Available' : 'Setup required'}</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{status?.message || 'Loading Browser Use status...'}</p>
{canInstallRuntime && (
<Button
type="button"
size="sm"
className="mt-3 w-full"
onClick={installRuntime}
disabled={isBusy || isInstalling || status?.installInProgress}
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Install Runtime
</Button>
)}
</div>
<div className="mt-3 space-y-2">
@@ -219,7 +251,7 @@ export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
{selectedSession?.message || 'Create a browser session to start.'}
</div>
<p className="mt-2 text-xs leading-relaxed text-neutral-400">
This panel shows captured browser screenshots. Interactive agent control should use the guarded Browser Use API.
Install the Browser Use runtime from this panel or enable it from Settings.
</p>
</div>
)}

View File

@@ -6,6 +6,7 @@ import {
Info,
KeyRound,
ListChecks,
MonitorPlay,
Palette,
Plug,
} from 'lucide-react';
@@ -32,6 +33,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch },
{ id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound },
{ id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks },
{ id: 'browser', label: 'Browser Use', keywords: 'browser use playwright chromium automation', icon: MonitorPlay },
{ id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell },
{ id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug },
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },

View File

@@ -54,7 +54,7 @@ type NotificationPreferencesResponse = {
type ActiveLoginProvider = AgentProvider | '';
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins'];
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'notifications', 'plugins', 'about'];
const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools".

View File

@@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react';
import type { LLMProvider } from '../../../types/app';
import type { ProviderAuthStatus } from '../../provider-auth/types';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = LLMProvider;
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';

View File

@@ -7,6 +7,7 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
@@ -139,17 +140,19 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
{activeTab === 'tasks' && <TasksSettingsTab />}
{activeTab === 'notifications' && (
<NotificationsSettingsTab
notificationPreferences={notificationPreferences}
onNotificationPreferencesChange={setNotificationPreferences}
pushPermission={pushPermission}
isPushSubscribed={isPushSubscribed}
isPushLoading={isPushLoading}
onEnablePush={handleEnablePush}
onDisablePush={handleDisablePush}
/>
)}
{activeTab === 'browser' && <BrowserUseSettingsTab />}
{activeTab === 'notifications' && (
<NotificationsSettingsTab
notificationPreferences={notificationPreferences}
onNotificationPreferencesChange={setNotificationPreferences}
pushPermission={pushPermission}
isPushSubscribed={isPushSubscribed}
isPushLoading={isPushLoading}
onEnablePush={handleEnablePush}
onDisablePush={handleDisablePush}
/>
)}
{activeTab === 'api' && <CredentialsSettingsTab />}

View File

@@ -1,4 +1,4 @@
import { Bell, Bot, GitBranch, Info, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
import { Bell, Bot, GitBranch, Info, Key, ListChecks, MonitorPlay, Palette, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import { PillBar, Pill } from '../../../shared/view/ui';
@@ -21,6 +21,7 @@ const NAV_ITEMS: NavItem[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
{ id: 'browser', labelKey: 'mainTabs.browser', icon: MonitorPlay },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
{ id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },
{ id: 'about', labelKey: 'mainTabs.about', icon: Info },

View File

@@ -0,0 +1,164 @@
import { useCallback, useEffect, useState } from 'react';
import { Download, Loader2, MonitorPlay, RefreshCw } from 'lucide-react';
import { Button } from '../../../../../shared/view/ui';
import { authenticatedFetch } from '../../../../../utils/api';
import SettingsCard from '../../SettingsCard';
import SettingsRow from '../../SettingsRow';
import SettingsSection from '../../SettingsSection';
import SettingsToggle from '../../SettingsToggle';
type BrowserUseSettings = {
enabled: boolean;
};
type BrowserUseStatus = {
enabled: boolean;
available: boolean;
runtime: 'cloud' | 'local';
playwrightInstalled: boolean;
chromiumInstalled: boolean;
installInProgress: boolean;
message: string;
};
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || data.details || `Request failed (${response.status})`);
}
return data as T;
}
export default function BrowserUseSettingsTab() {
const [settings, setSettings] = useState<BrowserUseSettings>({ enabled: true });
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadState = useCallback(async () => {
setError(null);
const [settingsResponse, statusResponse] = await Promise.all([
authenticatedFetch('/api/browser-use/settings'),
authenticatedFetch('/api/browser-use/status'),
]);
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
setSettings(settingsData.data.settings);
setStatus(statusData.data);
}, []);
useEffect(() => {
setIsLoading(true);
void loadState()
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser Use settings'))
.finally(() => setIsLoading(false));
}, [loadState]);
const updateEnabled = async (enabled: boolean) => {
setIsSaving(true);
setError(null);
try {
const response = await authenticatedFetch('/api/browser-use/settings', {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
setSettings(data.data.settings);
await loadState();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save Browser Use settings');
} finally {
setIsSaving(false);
}
};
const installRuntime = async () => {
setIsInstalling(true);
setError(null);
try {
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
await readJson(response);
await loadState();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to install Browser Use runtime');
} finally {
setIsInstalling(false);
}
};
const needsRuntime = Boolean(settings.enabled && status && (!status.playwrightInstalled || !status.chromiumInstalled));
return (
<div className="space-y-8">
<SettingsSection
title="Browser Use"
description="Manage local Playwright browser sessions used for captured browser screenshots and guarded navigation."
>
<SettingsCard divided>
<SettingsRow
label="Enable Browser Use"
description="Allow CloudCLI to create owner-scoped Playwright browser sessions."
>
<SettingsToggle
checked={settings.enabled}
onChange={(value) => void updateEnabled(value)}
ariaLabel="Enable Browser Use"
disabled={isLoading || isSaving}
/>
</SettingsRow>
<div className="space-y-4 px-4 py-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">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<MonitorPlay className="h-4 w-4 text-primary" />
Runtime
</div>
<p className="text-sm text-muted-foreground">
{status?.message || (isLoading ? 'Checking Browser Use runtime...' : 'Runtime status unavailable.')}
</p>
{status && (
<div className="flex flex-wrap gap-2 pt-1 text-xs text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">Mode: {status.runtime}</span>
<span className="rounded-md border border-border px-2 py-1">
Playwright: {status.playwrightInstalled ? 'installed' : 'missing'}
</span>
<span className="rounded-md border border-border px-2 py-1">
Chromium: {status.chromiumInstalled ? 'installed' : 'missing'}
</span>
</div>
)}
</div>
<div className="flex flex-shrink-0 gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void loadState()} disabled={isLoading || isInstalling}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
{needsRuntime && (
<Button type="button" size="sm" onClick={() => void installRuntime()} disabled={isInstalling || status?.installInProgress}>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Install Runtime
</Button>
)}
</div>
</div>
{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
)}
</div>
</SettingsCard>
</SettingsSection>
</div>
);
}

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API & Tokens",
"tasks": "Tasks",
"browser": "Browser Use",
"notifications": "Notifications",
"plugins": "Plugins",
"about": "About"