Workspace
' +
- navItem('local', 'terminal', 'Local', state.localServerRunning ? 'on' : 'idle', section) +
- navItem('cloud', 'cloud', 'Cloud', (state.environments || []).length, section) +
+ var nav = '
Launcher
' +
+ navItem('local', 'terminal', 'Local servers', state.localServerRunning ? 'on' : 'idle', section) +
+ navItem('cloud', 'cloud', 'Cloud environments', (state.environments || []).length, section) +
'
';
return nav + '
' + (section === 'local' ? localPane(state) : cloudPane(state)) + '
';
}
diff --git a/electron/main.js b/electron/main.js
index 6e1ad15f..08e2209f 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -430,7 +430,18 @@ async function updateDesktopSetting(key, value) {
}
async function showEnvironmentPicker() {
- const environments = await refreshCloudEnvironments({ showErrors: true });
+ let environments = cloud.getEnvironments();
+ let refreshError = null;
+
+ if (cloud.getAccount()?.apiKey) {
+ try {
+ environments = await refreshCloudEnvironments({ showErrors: false });
+ } catch (error) {
+ refreshError = error;
+ console.warn('[Cloud] Could not refresh environments before showing picker:', error?.message || error);
+ }
+ }
+
const choices = ['Local CloudCLI', ...environments.map((environment) => {
const status = environment.status === 'running' ? '' : ` (${environment.status})`;
return `${environment.name || environment.subdomain}${status}`;
@@ -443,6 +454,7 @@ async function showEnvironmentPicker() {
cancelId: choices.length,
title: 'Switch CloudCLI Environment',
message: 'Choose where this desktop window should connect.',
+ detail: refreshError ? `Cloud environments could not be refreshed. Showing cached environments.\n\n${refreshError.message || refreshError}` : undefined,
});
if (response.response === choices.length) return getDesktopState();
diff --git a/electron/tabs.js b/electron/tabs.js
index 16bb4c75..b55a64bb 100644
--- a/electron/tabs.js
+++ b/electron/tabs.js
@@ -4,7 +4,7 @@ export class TabsController {
this.tabs = [
{
id: 'home',
- title: 'Home',
+ title: 'Launcher',
kind: 'launcher',
closable: false,
},
@@ -22,7 +22,7 @@ export class TabsController {
const existingTab = this.tabs.find((tab) => tab.id === tabId);
const nextTab = {
id: tabId,
- title: target.kind === 'launcher' ? 'Home' : target.name,
+ title: target.kind === 'launcher' ? 'Launcher' : target.name,
kind: target.kind,
target,
closable: tabId !== 'home',
diff --git a/electron/viewHost.js b/electron/viewHost.js
new file mode 100644
index 00000000..58c97a98
--- /dev/null
+++ b/electron/viewHost.js
@@ -0,0 +1,214 @@
+import { BrowserView } from 'electron';
+
+const TARGET_LOAD_TIMEOUT_MS = 20000;
+
+function escapeHtml(value) {
+ return String(value == null ? '' : value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function buildPlaceholderHtml(title, message, logs = []) {
+ const logHtml = logs.length
+ ? `
${logs.map(escapeHtml).join('\n')}`
+ : '
Waiting for process output...
';
+ return [
+ '
',
+ '',
+ '
',
+ `
${escapeHtml(message || `Opening ${title}...`)}
`,
+ logHtml,
+ '
',
+ ].join('');
+}
+
+function isHttpUrl(url) {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+async function loadUrlWithTimeout(webContents, url, timeoutMs = TARGET_LOAD_TIMEOUT_MS) {
+ let timedOut = false;
+ let timeout = null;
+ const loadPromise = webContents.loadURL(url);
+ const timeoutPromise = new Promise((_, reject) => {
+ timeout = setTimeout(() => {
+ timedOut = true;
+ try {
+ webContents.stop();
+ } catch {
+ // Ignore teardown races while reporting the original timeout.
+ }
+ reject(new Error(`Timed out loading ${url} after ${Math.round(timeoutMs / 1000)} seconds.`));
+ }, timeoutMs);
+ });
+
+ try {
+ await Promise.race([loadPromise, timeoutPromise]);
+ } catch (error) {
+ if (timedOut) {
+ loadPromise.catch(() => {});
+ }
+ throw error;
+ } finally {
+ if (timeout) clearTimeout(timeout);
+ }
+}
+
+export class ViewHost {
+ constructor({ appName, getMainWindow, getContentViewBounds, getPreloadPath, openExternalUrl, showError }) {
+ this.appName = appName;
+ this.getMainWindow = getMainWindow;
+ this.getContentViewBounds = getContentViewBounds;
+ this.getPreloadPath = getPreloadPath;
+ this.openExternalUrl = openExternalUrl;
+ this.showError = showError;
+ this.activeContentView = null;
+ this.tabViews = new Map();
+ }
+
+ configureChildWebContents(webContents) {
+ webContents.setWindowOpenHandler(({ url }) => {
+ void this.openExternalUrl(url).catch((error) => this.showError('Could not open external link', error));
+ return { action: 'deny' };
+ });
+ }
+
+ detachAll() {
+ const mainWindow = this.getMainWindow();
+ if (!mainWindow || mainWindow.isDestroyed()) return;
+ try {
+ for (const view of mainWindow.getBrowserViews()) {
+ mainWindow.removeBrowserView(view);
+ }
+ } catch {
+ // BrowserViews may already be gone during BrowserWindow teardown.
+ }
+ this.activeContentView = null;
+ }
+
+ getOrCreateTabView(tabId) {
+ let view = this.tabViews.get(tabId);
+ if (view) return view;
+
+ view = new BrowserView({
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ preload: this.getPreloadPath(),
+ },
+ });
+ this.configureChildWebContents(view.webContents);
+ this.tabViews.set(tabId, view);
+ return view;
+ }
+
+ attach(view) {
+ const mainWindow = this.getMainWindow();
+ if (!mainWindow || mainWindow.isDestroyed()) return;
+ if (this.activeContentView && this.activeContentView !== view) {
+ this.detachAll();
+ }
+ this.activeContentView = view;
+ try {
+ if (!mainWindow.getBrowserViews().includes(view)) {
+ mainWindow.addBrowserView(view);
+ }
+ } catch {
+ return;
+ }
+ view.setBounds(this.getContentViewBounds());
+ view.setAutoResize({ width: true, height: true });
+ }
+
+ resizeActiveView() {
+ if (this.activeContentView) {
+ this.activeContentView.setBounds(this.getContentViewBounds());
+ }
+ }
+
+ async showTabPlaceholder(tabId, target, message) {
+ const view = this.getOrCreateTabView(tabId);
+ this.attach(view);
+ const html = buildPlaceholderHtml(target.name || this.appName, message);
+ await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
+ view.__cloudcliStartupHtml = html;
+ view.__cloudcliLoadedUrl = null;
+ }
+
+ async showLocalStartupTarget(tabId, target, logs) {
+ const view = this.getOrCreateTabView(tabId);
+ if (view.__cloudcliLoadingUrl) return;
+ this.attach(view);
+ const html = buildPlaceholderHtml(target.name || this.appName, 'Starting Local CloudCLI...', logs);
+ if (view.__cloudcliStartupHtml === html) return;
+ await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
+ view.__cloudcliStartupHtml = html;
+ view.__cloudcliLoadedUrl = null;
+ }
+
+ async showContentTarget(tabId, target) {
+ if (!isHttpUrl(target.url)) {
+ throw new Error(`Refusing to load unsupported app URL: ${target.url}`);
+ }
+ const view = this.getOrCreateTabView(tabId);
+ this.attach(view);
+ if (view.__cloudcliLoadedUrl !== target.url) {
+ view.__cloudcliLoadingUrl = target.url;
+ try {
+ await loadUrlWithTimeout(view.webContents, target.url);
+ view.__cloudcliLoadedUrl = target.url;
+ view.__cloudcliStartupHtml = null;
+ } finally {
+ if (view.__cloudcliLoadingUrl === target.url) {
+ view.__cloudcliLoadingUrl = null;
+ }
+ }
+ }
+ }
+
+ destroyTabView(tabId) {
+ const view = this.tabViews.get(tabId);
+ if (!view) return;
+ const mainWindow = this.getMainWindow();
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ try {
+ if (mainWindow.getBrowserViews().includes(view)) {
+ mainWindow.removeBrowserView(view);
+ }
+ } catch {
+ // Ignore teardown races; Electron owns final destruction during quit.
+ }
+ }
+ if (this.activeContentView === view) {
+ this.activeContentView = null;
+ }
+ try {
+ if (!view.webContents.isDestroyed()) {
+ view.webContents.destroy();
+ }
+ } catch {
+ // The view may already be destroyed by its parent BrowserWindow.
+ }
+ this.tabViews.delete(tabId);
+ }
+
+ clear() {
+ this.tabViews.clear();
+ this.activeContentView = null;
+ }
+}
diff --git a/server/modules/computer-use/computer-use.service.ts b/server/modules/computer-use/computer-use.service.ts
index 4ffc61eb..4eb71997 100644
--- a/server/modules/computer-use/computer-use.service.ts
+++ b/server/modules/computer-use/computer-use.service.ts
@@ -3,8 +3,8 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
-import { appConfigDb } from '@/modules/database/repositories/app-config.js';
-import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
+import { appConfigDb } from '@/modules/database/index.js';
+import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js';
import {
getRuntimeReadiness as getExecutorReadiness,
@@ -126,7 +126,7 @@ function getOrCreateMcpToken(): string {
function getSetupMessage(settings: ComputerUseSettings, readiness: RuntimeReadiness): string {
if (getRuntime() === 'cloud') {
- return 'Cloud Computer Use requires a linked CloudCLI Desktop Agent on the user machine.';
+ return 'Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.';
}
if (!settings.enabled) {
return 'Computer Use is disabled in settings.';
@@ -434,7 +434,7 @@ function assertAgentToolsAvailable(): void {
}
throw new Error(
getRuntime() === 'cloud'
- ? 'No desktop agent is connected. Open the CloudCLI desktop app with Computer Use enabled.'
+ ? 'No desktop is linked. Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.'
: 'Computer Use agent tools are disabled.'
);
}
@@ -474,6 +474,7 @@ export const computerUseService = {
runtime: getRuntime(),
available,
desktopAgentConnected,
+ desktopAgentCount: desktopAgentRelay.connectedCount(),
nutInstalled: readiness.nutInstalled,
screenshotInstalled: readiness.screenshotInstalled,
installInProgress: readiness.installInProgress,
diff --git a/server/modules/computer-use/desktop-agent-relay.service.ts b/server/modules/computer-use/desktop-agent-relay.service.ts
index 54303e4b..fc3c62cd 100644
--- a/server/modules/computer-use/desktop-agent-relay.service.ts
+++ b/server/modules/computer-use/desktop-agent-relay.service.ts
@@ -106,7 +106,7 @@ export const desktopAgentRelay = {
const agent = pickAgent();
if (!agent) {
throw new Error(
- 'No desktop agent connected. Open the CloudCLI desktop app with Computer Use enabled to control this machine.'
+ 'No desktop is linked. Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.'
);
}
diff --git a/src/components/computer-use/view/ComputerUsePanel.tsx b/src/components/computer-use/view/ComputerUsePanel.tsx
index ec9b5554..bad65fd6 100644
--- a/src/components/computer-use/view/ComputerUsePanel.tsx
+++ b/src/components/computer-use/view/ComputerUsePanel.tsx
@@ -1,13 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react';
-import { Bot, Camera, Download, Expand, Loader2, MonitorCog, RefreshCw, ShieldCheck, Square, Trash2, X } from 'lucide-react';
+import { Bot, Camera, Download, Expand, Loader2, MonitorCog, RefreshCw, Settings, ShieldCheck, Square, Trash2, X } from 'lucide-react';
+import { cn } from '../../../lib/utils';
import { Badge, Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api';
+import type { SettingsMainTab } from '../../settings/types/types';
type ComputerUseStatus = {
enabled: boolean;
runtime: 'cloud' | 'local';
available: boolean;
+ desktopAgentConnected?: boolean;
+ desktopAgentCount?: number;
nutInstalled: boolean;
screenshotInstalled: boolean;
installInProgress: boolean;
@@ -38,6 +42,7 @@ type ComputerUseSession = {
type ComputerUsePanelProps = {
isVisible: boolean;
+ onShowSettings?: (tab?: SettingsMainTab) => void;
};
async function readJson
(response: Response): Promise {
@@ -48,10 +53,36 @@ async function readJson(response: Response): Promise {
return data as T;
}
-export default function ComputerUsePanel({ isVisible }: ComputerUsePanelProps) {
+function getRuntimeTone(status: ComputerUseStatus | null, installing: boolean): string {
+ if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
+ if (status.runtime === 'cloud') {
+ return status.desktopAgentConnected
+ ? 'border-primary/30 bg-primary/5 text-foreground'
+ : 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
+ }
+ if (status.available) return 'border-primary/30 bg-primary/5 text-foreground';
+ if (status.installInProgress || installing) return 'border-primary/30 bg-primary/5 text-foreground';
+ return 'border-border bg-background text-muted-foreground';
+}
+
+function getRuntimeLabel(status: ComputerUseStatus | null, installing: boolean): string {
+ if (!status?.enabled) return 'Disabled';
+ if (status.runtime === 'cloud') {
+ const count = status.desktopAgentCount ?? (status.desktopAgentConnected ? 1 : 0);
+ if (count > 1) return `${count} desktops linked`;
+ if (count === 1) return 'Desktop linked';
+ return 'Desktop not linked';
+ }
+ if (status.available) return 'Ready';
+ if (status.installInProgress || installing) return 'Installing';
+ return 'Setup required';
+}
+
+export default function ComputerUsePanel({ isVisible, onShowSettings }: ComputerUsePanelProps) {
const [status, setStatus] = useState(null);
const [sessions, setSessions] = useState([]);
const [selectedSessionId, setSelectedSessionId] = useState(null);
+ const [isRefreshing, setIsRefreshing] = useState(false);
const [isBusy, setIsBusy] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -64,20 +95,25 @@ export default function ComputerUsePanel({ isVisible }: ComputerUsePanelProps) {
);
const refresh = useCallback(async () => {
- setError(null);
- const [statusResponse, sessionsResponse] = await Promise.all([
- authenticatedFetch('/api/computer-use/status'),
- authenticatedFetch('/api/computer-use/sessions'),
- ]);
- const statusData = await readJson<{ data: ComputerUseStatus }>(statusResponse);
- const sessionsData = await readJson<{ data: { sessions: ComputerUseSession[] } }>(sessionsResponse);
- setStatus(statusData.data);
- setSessions(sessionsData.data.sessions);
- setSelectedSessionId((current) => (
- current && sessionsData.data.sessions.some((session) => session.id === current)
- ? current
- : sessionsData.data.sessions[0]?.id || null
- ));
+ setIsRefreshing(true);
+ try {
+ const [statusResponse, sessionsResponse] = await Promise.all([
+ authenticatedFetch('/api/computer-use/status'),
+ authenticatedFetch('/api/computer-use/sessions'),
+ ]);
+ const statusData = await readJson<{ data: ComputerUseStatus }>(statusResponse);
+ const sessionsData = await readJson<{ data: { sessions: ComputerUseSession[] } }>(sessionsResponse);
+ setStatus(statusData.data);
+ setSessions(sessionsData.data.sessions);
+ setSelectedSessionId((current) => (
+ current && sessionsData.data.sessions.some((session) => session.id === current)
+ ? current
+ : sessionsData.data.sessions[0]?.id || null
+ ));
+ setError(null);
+ } finally {
+ setIsRefreshing(false);
+ }
}, []);
useEffect(() => {
@@ -207,6 +243,8 @@ export default function ComputerUsePanel({ isVisible }: ComputerUsePanelProps) {
const needsRuntime = Boolean(status?.enabled && status.runtime === 'local' && (!status.nutInstalled || !status.screenshotInstalled));
const isCloud = status?.runtime === 'cloud';
+ const desktopAgentCount = status?.desktopAgentCount ?? (status?.desktopAgentConnected ? 1 : 0);
+ const runtimeLabel = getRuntimeLabel(status, isInstalling);
const cursorStyle = selectedSession?.cursor && selectedSession.displaySize
? {
@@ -262,31 +300,68 @@ export default function ComputerUsePanel({ isVisible }: ComputerUsePanelProps) {
Computer Use
- {status && {status.runtime}}
+
+ {runtimeLabel}
+
{isCloud
- ? 'Monitor cloud agent desktop sessions and stop access when needed.'
+ ? 'Monitor cloud agent desktop sessions and linked desktops.'
: 'Monitor local desktop sessions and grant control only when an agent needs it.'}