feat: improve Computer Use linking status

This commit is contained in:
Simos Mikelatos
2026-06-19 13:47:16 +00:00
parent 218e8e2e38
commit 4d70a2588c
14 changed files with 474 additions and 231 deletions

View File

@@ -79,11 +79,13 @@ jobs:
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
name: CloudCLI Internal Local Runtime (${{ github.ref_name }})
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
body: |
Internal runtime assets for CloudCLI Desktop branch builds.
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
Users should download the desktop app from the workflow artifact. The desktop app downloads these runtime bundles automatically when local mode is enabled.
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true

View File

@@ -105,9 +105,11 @@ jobs:
target_commitish: ${{ github.sha }}
name: CloudCLI Local Server Runtime (${{ steps.release.outputs.tag }})
body: |
Internal runtime assets for CloudCLI Desktop local mode.
This prerelease contains the Local mode runtime for CloudCLI Desktop.
Users should download CloudCLI Desktop from the main ${{ steps.release.outputs.tag }} release. The desktop app downloads these runtime bundles automatically when local mode is enabled.
Download CloudCLI Desktop from the main ${{ steps.release.outputs.tag }} release. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true

View File

@@ -64,11 +64,13 @@ jobs:
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
name: CloudCLI Internal Local Runtime (${{ github.ref_name }})
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
body: |
Internal runtime assets for CloudCLI Desktop branch builds.
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
Users should download the desktop app from the workflow artifact. The desktop app downloads these runtime bundles automatically when local mode is enabled.
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true

View File

@@ -3,6 +3,8 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { safeStorage } from 'electron';
const CLOUD_API_TIMEOUT_MS = 15000;
function encryptSecret(secret) {
if (!safeStorage.isEncryptionAvailable()) {
return { encrypted: false, value: secret };
@@ -151,14 +153,28 @@ export class CloudController {
throw new Error('Connect your CloudCLI account first.');
}
const response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.cloudAccount.apiKey,
...(options.headers || {}),
},
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
let response;
try {
response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
...options,
signal: options.signal || controller.signal,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.cloudAccount.apiKey,
...(options.headers || {}),
},
});
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`CloudCLI API request timed out after ${Math.round(CLOUD_API_TIMEOUT_MS / 1000)} seconds.`);
}
throw error;
} finally {
clearTimeout(timeout);
}
const body = await response.json().catch(() => ({}));
if (!response.ok) {

View File

@@ -1,45 +1,9 @@
import { BrowserView, BrowserWindow, Menu, Tray, nativeImage, nativeTheme, session } from 'electron';
import { BrowserWindow, Menu, Tray, nativeImage, nativeTheme, session } from 'electron';
import { ViewHost } from './viewHost.js';
const TITLEBAR_HEIGHT = 44;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildPlaceholderHtml(title, message, logs = []) {
const logHtml = logs.length
? `<pre>${logs.map(escapeHtml).join('\n')}</pre>`
: '<pre>Waiting for process output...</pre>';
return [
'<!doctype html><meta charset="utf-8">',
'<style>',
'html,body{margin:0;height:100%;background:#0a0a0a;color:#fafafa;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}',
'body{padding:28px;overflow:hidden}',
'.shell{height:100%;display:flex;flex-direction:column;gap:16px}',
'.box{display:flex;align-items:center;gap:10px;color:#d4d4d4;flex:0 0 auto}',
'.dot{width:8px;height:8px;border-radius:50%;background:#0b60ea;box-shadow:0 0 0 6px rgba(11,96,234,.15)}',
'pre{margin:0;flex:1;overflow:auto;border:1px solid #262626;border-radius:10px;background:#050505;color:#d4d4d4;padding:14px;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:pre-wrap;user-select:text}',
'</style>',
'<div class="shell">',
`<div class="box"><span class="dot"></span><span>${escapeHtml(message || `Opening ${title}...`)}</span></div>`,
logHtml,
'</div>',
].join('');
}
function isHttpUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) {
try {
const source = new URL(sourceUrl);
@@ -88,8 +52,14 @@ export class DesktopWindowManager {
this.settingsWindow = null;
this.tray = null;
this.launcherLoaded = false;
this.activeContentView = null;
this.tabViews = new Map();
this.viewHost = new ViewHost({
appName: this.appName,
getMainWindow: () => this.mainWindow,
getContentViewBounds: () => this.getContentViewBounds(),
getPreloadPath: this.getPreloadPath,
openExternalUrl: this.openExternalUrl,
showError: this.actions.showError,
});
}
getMainWindow() {
@@ -112,125 +82,27 @@ export class DesktopWindowManager {
};
}
configureChildWebContents(webContents) {
webContents.setWindowOpenHandler(({ url }) => {
void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
return { action: 'deny' };
});
}
detachActiveContentView() {
if (!this.mainWindow || this.mainWindow.isDestroyed() || !this.activeContentView) return;
try {
if (this.mainWindow.getBrowserViews().includes(this.activeContentView)) {
this.mainWindow.removeBrowserView(this.activeContentView);
}
} 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;
}
attachContentView(view) {
if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
if (this.activeContentView && this.activeContentView !== view) {
this.detachActiveContentView();
}
this.activeContentView = view;
try {
if (!this.mainWindow.getBrowserViews().includes(view)) {
this.mainWindow.addBrowserView(view);
}
} catch {
return;
}
view.setBounds(this.getContentViewBounds());
view.setAutoResize({ width: true, height: true });
this.viewHost.detachAll();
}
async showTabPlaceholder(target, message) {
const tabId = this.tabs.getTabIdForTarget(target);
const view = this.getOrCreateTabView(tabId);
this.attachContentView(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;
await this.viewHost.showTabPlaceholder(tabId, target, message);
}
async showLocalStartupTarget(target, logs) {
const tabId = this.tabs.getTabIdForTarget(target);
const view = this.getOrCreateTabView(tabId);
if (view.__cloudcliLoadingUrl) return;
this.attachContentView(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;
await this.viewHost.showLocalStartupTarget(tabId, target, logs);
}
async showContentTarget(target) {
if (!isHttpUrl(target.url)) {
throw new Error(`Refusing to load unsupported app URL: ${target.url}`);
}
const tabId = this.tabs.getTabIdForTarget(target);
const view = this.getOrCreateTabView(tabId);
this.attachContentView(view);
if (view.__cloudcliLoadedUrl !== target.url) {
view.__cloudcliLoadingUrl = target.url;
try {
await view.webContents.loadURL(target.url);
view.__cloudcliLoadedUrl = target.url;
view.__cloudcliStartupHtml = null;
} finally {
if (view.__cloudcliLoadingUrl === target.url) {
view.__cloudcliLoadingUrl = null;
}
}
}
await this.viewHost.showContentTarget(tabId, target);
}
destroyTabView(tabId) {
const view = this.tabViews.get(tabId);
if (!view) return;
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
try {
if (this.mainWindow.getBrowserViews().includes(view)) {
this.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);
this.viewHost.destroyTabView(tabId);
}
emitDesktopState() {
@@ -270,7 +142,6 @@ export class DesktopWindowManager {
this.settingsWindow = new BrowserWindow({
parent: this.mainWindow,
modal: true,
show: false,
frame: false,
transparent: true,
@@ -290,7 +161,7 @@ export class DesktopWindowManager {
},
});
this.syncSettingsWindowBounds();
this.configureChildWebContents(this.settingsWindow.webContents);
this.viewHost.configureChildWebContents(this.settingsWindow.webContents);
this.settingsWindow.once('ready-to-show', () => this.settingsWindow?.show());
this.settingsWindow.on('closed', () => {
this.settingsWindow = null;
@@ -326,6 +197,7 @@ export class DesktopWindowManager {
this.detachActiveContentView();
this.buildAppMenu();
this.mainWindow.setTitle(this.appName);
this.mainWindow.webContents.focus();
if (!this.launcherLoaded) {
await this.mainWindow.loadFile(this.getLauncherPath());
this.launcherLoaded = true;
@@ -757,9 +629,7 @@ export class DesktopWindowManager {
});
this.mainWindow.on('resize', () => {
if (this.activeContentView) {
this.activeContentView.setBounds(this.getContentViewBounds());
}
this.viewHost.resizeActiveView();
this.syncSettingsWindowBounds();
});
@@ -768,8 +638,7 @@ export class DesktopWindowManager {
});
this.mainWindow.on('closed', () => {
this.tabViews.clear();
this.activeContentView = null;
this.viewHost.clear();
this.settingsWindow = null;
this.mainWindow = null;
this.launcherLoaded = false;

View File

@@ -184,10 +184,16 @@ window.__MOCK_STATE__ = {
if (!computerUse.enabled) {
return { label: 'Disabled', tone: 'idle', detail: 'CloudCLI cannot use this computer.' };
}
if (computerUse.consentMode === 'auto') {
return { label: 'Unattended access', tone: 'warn', detail: 'Trusted agents can use this computer without a local approval prompt.' };
if (!computerUse.targetCount) {
return { label: 'Not linked', tone: 'warn', detail: 'No running cloud environment found for this account.' };
}
return { label: 'Ask before each session', tone: 'ok', detail: 'Agents need approval before control starts.' };
if (!computerUse.connectedCount) {
return { label: 'Connecting', tone: 'warn', detail: 'Trying to link to ' + computerUse.targetCount + ' running cloud environment' + (computerUse.targetCount === 1 ? '' : 's') + '.' };
}
if (computerUse.consentMode === 'auto') {
return { label: 'Linked', tone: 'warn', detail: 'Unattended access is on for ' + computerUse.connectedCount + ' cloud environment' + (computerUse.connectedCount === 1 ? '' : 's') + '.' };
}
return { label: 'Linked', tone: 'ok', detail: 'Approval prompts are ready for ' + computerUse.connectedCount + ' cloud environment' + (computerUse.connectedCount === 1 ? '' : 's') + '.' };
}
var CC = {
@@ -291,7 +297,7 @@ window.__MOCK_STATE__ = {
CC._status = { msg: (meta.verb || 'Opening') + ' ' + ((env && (env.name || env.subdomain)) || 'environment') + '...', tone: 'progress' };
if (env) {
var tabId = 'remote:' + env.id;
var tabs = CC.state.tabs && CC.state.tabs.length ? CC.state.tabs : [{ id: 'home', title: 'Home', kind: 'launcher', closable: false }];
var tabs = CC.state.tabs && CC.state.tabs.length ? CC.state.tabs : [{ id: 'home', title: 'Launcher', kind: 'launcher', closable: false }];
tabs = tabs.map(function (tab) {
tab.active = false;
return tab;
@@ -366,6 +372,8 @@ window.__MOCK_STATE__ = {
return CC.closeSheet();
case 'dashboard':
return CC.run('Opening CloudCLI dashboard...', function () { return bridge.openCloudDashboard(); });
case 'refresh-environments':
return CC.run('Refreshing cloud environments...', function () { return bridge.refreshEnvironments(); });
case 'env-action':
return CC.run('Opening environment...', function () { return bridge.runActiveEnvironmentAction(node.getAttribute('data-cc-env-action')); });
case 'env-menu':
@@ -537,9 +545,11 @@ window.__MOCK_STATE__ = {
CC.buildComputerUseSection = function (state) {
var computerUse = state.computerUse || {};
var status = computerUseStatus(state);
var body =
'<div class="cc-surface">' +
'<label class="cc-toggle"><input type="checkbox" data-cc-computer-enabled="true"' + (computerUse.enabled ? ' checked' : '') + '><span><b>Enable Computer Use</b><br>Let CloudCLI use the computer. Agents cannot act until you approve a session.</span></label>';
'<label class="cc-toggle"><input type="checkbox" data-cc-computer-enabled="true"' + (computerUse.enabled ? ' checked' : '') + '><span><b>Enable Computer Use</b><br>Let CloudCLI use the computer. Agents cannot act until you approve a session.</span></label>' +
'<div class="cc-row2"><span class="badge ' + CC.esc(status.tone) + '">' + CC.esc(status.label) + '</span><span class="cc-meta">' + CC.esc(status.detail) + '</span><button class="btn sm" data-cc-action="refresh-environments">' + CC.icon('refresh', 14) + 'Refresh / relink</button></div>';
if (computerUse.enabled) {
body += '<div class="cc-permissions">' + renderComputerPermissions(state) + '</div>';
body += '<div class="cc-choice-group">' +
@@ -721,11 +731,11 @@ window.__MOCK_STATE__ = {
}
function localPane(state) {
return '<div class="pane-h"><div><h2 class="pane-title">Local CloudCLI</h2><p class="pane-sub">Run the open-source app on this machine. No account required.</p></div></div>' +
return '<div class="pane-h"><div><h2 class="pane-title">Local servers</h2><p class="pane-sub">Manage Local CloudCLI on this machine. No account required.</p></div></div>' +
'<div class="card"><div class="card-head"><div><div class="card-t">Local server</div><div class="card-sub mono">' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '</div></div><div class="card-tools"><span class="dot" style="background:' + (state.localServerRunning ? 'var(--ok)' : 'var(--tx3)') + '"></span><button class="icon-btn" data-cc-action="local-settings-toggle" title="Local settings">' + CC.icon('gear', 16) + '</button></div></div>' +
'<div class="card-actions"><button class="btn pri" data-cc-action="local">' + CC.icon('play', 15) + 'Open Local CloudCLI</button><button class="btn" data-cc-action="open-web">' + CC.icon('arrow', 14) + 'Open in browser</button><button class="btn" data-cc-action="copy-web">' + CC.icon('copy', 14) + 'Copy URL</button></div></div>' +
'<div class="card"><div class="card-head"><div><div class="card-t">Computer Use</div><div class="card-sub">' + CC.esc(computerUseStatus(state).detail) + '</div></div><div class="card-tools"><span class="badge ' + CC.esc(computerUseStatus(state).tone) + '">' + CC.esc(computerUseStatus(state).label) + '</span><button class="icon-btn" data-cc-action="computer-settings-toggle" title="Computer Use settings">' + CC.icon('monitor', 16) + '</button></div></div>' +
'<div class="card-actions"><button class="btn" data-cc-action="computer-settings-toggle">' + CC.icon('settings', 14) + 'Open settings</button></div></div>';
'<div class="card-actions"><button class="btn" data-cc-action="refresh-environments">' + CC.icon('refresh', 14) + 'Refresh / relink</button><button class="btn" data-cc-action="computer-settings-toggle">' + CC.icon('settings', 14) + 'Open settings</button></div></div>';
}
function envRow(environment) {
@@ -759,9 +769,9 @@ window.__MOCK_STATE__ = {
function renderBody(state) {
var section = CC.ui.section || ((CC.connected(state) || CC.authState(state) === 'expired') ? 'cloud' : 'local');
CC.ui.section = section;
var nav = '<div class="sb"><div class="sb-grp"><div class="lbl">Workspace</div>' +
navItem('local', 'terminal', 'Local', state.localServerRunning ? 'on' : 'idle', section) +
navItem('cloud', 'cloud', 'Cloud', (state.environments || []).length, section) +
var nav = '<div class="sb"><div class="sb-grp"><div class="lbl">Launcher</div>' +
navItem('local', 'terminal', 'Local servers', state.localServerRunning ? 'on' : 'idle', section) +
navItem('cloud', 'cloud', 'Cloud environments', (state.environments || []).length, section) +
'</div></div>';
return nav + '<div class="sb-main">' + (section === 'local' ? localPane(state) : cloudPane(state)) + '</div>';
}

View File

@@ -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();

View File

@@ -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',

214
electron/viewHost.js Normal file
View File

@@ -0,0 +1,214 @@
import { BrowserView } from 'electron';
const TARGET_LOAD_TIMEOUT_MS = 20000;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildPlaceholderHtml(title, message, logs = []) {
const logHtml = logs.length
? `<pre>${logs.map(escapeHtml).join('\n')}</pre>`
: '<pre>Waiting for process output...</pre>';
return [
'<!doctype html><meta charset="utf-8">',
'<style>',
'html,body{margin:0;height:100%;background:#0a0a0a;color:#fafafa;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}',
'body{padding:28px;overflow:hidden}',
'.shell{height:100%;display:flex;flex-direction:column;gap:16px}',
'.box{display:flex;align-items:center;gap:10px;color:#d4d4d4;flex:0 0 auto}',
'.dot{width:8px;height:8px;border-radius:50%;background:#0b60ea;box-shadow:0 0 0 6px rgba(11,96,234,.15)}',
'pre{margin:0;flex:1;overflow:auto;border:1px solid #262626;border-radius:10px;background:#050505;color:#d4d4d4;padding:14px;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:pre-wrap;user-select:text}',
'</style>',
'<div class="shell">',
`<div class="box"><span class="dot"></span><span>${escapeHtml(message || `Opening ${title}...`)}</span></div>`,
logHtml,
'</div>',
].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;
}
}

View File

@@ -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,

View File

@@ -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.'
);
}

View File

@@ -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<T>(response: Response): Promise<T> {
@@ -48,10 +53,36 @@ async function readJson<T>(response: Response): Promise<T> {
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<ComputerUseStatus | null>(null);
const [sessions, setSessions] = useState<ComputerUseSession[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(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) {
<div className="flex items-center gap-2">
<MonitorCog className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Computer Use</h3>
{status && <Badge variant="outline" className="text-[11px]">{status.runtime}</Badge>}
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
{runtimeLabel}
</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{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.'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
{onShowSettings && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onShowSettings('computer')}
title="Open Computer Use settings"
aria-label="Open Computer Use settings"
>
<Settings className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="outline"
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleRefresh}
disabled={isBusy}
disabled={isRefreshing || isBusy}
title="Refresh Computer Use"
aria-label="Refresh Computer Use"
>
<RefreshCw className="h-4 w-4" />
Refresh
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[300px_minmax(0,1fr)]">
<aside className="border-b border-border/60 p-3 lg:border-b-0 lg:border-r">
{needsRuntime && (
{isCloud && (
<div className="rounded-lg border border-border/70 bg-card/40 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Cloud desktop access</div>
<div className="mt-1 text-sm font-medium text-foreground">{runtimeLabel}</div>
</div>
<Badge variant="outline" className={cn('shrink-0 text-[10px]', getRuntimeTone(status, isInstalling))}>
{desktopAgentCount > 0 ? `${desktopAgentCount} linked` : 'Not linked'}
</Badge>
</div>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{desktopAgentCount > 1
? 'More than one CloudCLI Desktop app is linked. Agents will use one available desktop.'
: desktopAgentCount === 1
? 'CloudCLI Desktop is connected. Approval prompts appear on that computer.'
: 'Open CloudCLI Desktop on the computer you want agents to use, connect the same account, and enable Computer Use.'}
</p>
</div>
)}
{needsRuntime && (
<div className={cn('rounded-lg border border-border/70 bg-card/40 p-3', isCloud && 'mt-3')}>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Desktop runtime required</div>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{status?.message || 'Install the desktop control runtime to enable Computer Use.'}

View File

@@ -264,7 +264,7 @@ function MainContent({
{shouldShowComputerTab && activeTab === 'computer' && (
<div className="h-full overflow-hidden">
<ComputerUsePanel isVisible={activeTab === 'computer'} />
<ComputerUsePanel isVisible={activeTab === 'computer'} onShowSettings={onShowSettings} />
</div>
)}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { Download, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '../../../../../shared/view/ui';
import { authenticatedFetch } from '../../../../../utils/api';
@@ -17,6 +17,7 @@ type ComputerUseStatus = {
runtime: 'cloud' | 'local';
available: boolean;
desktopAgentConnected?: boolean;
desktopAgentCount?: number;
nutInstalled: boolean;
screenshotInstalled: boolean;
installInProgress: boolean;
@@ -51,13 +52,21 @@ export default function ComputerUseSettingsTab() {
setStatus(statusData.data);
}, []);
useEffect(() => {
const refreshState = useCallback(async () => {
setIsLoading(true);
void loadState()
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Computer Use settings'))
.finally(() => setIsLoading(false));
try {
await loadState();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load Computer Use settings');
} finally {
setIsLoading(false);
}
}, [loadState]);
useEffect(() => {
void refreshState();
}, [refreshState]);
const updateSettings = async (nextSettings: Partial<ComputerUseSettings>) => {
setIsSaving(true);
setError(null);
@@ -94,9 +103,10 @@ export default function ComputerUseSettingsTab() {
const isCloud = status?.runtime === 'cloud';
const effectiveEnabled = isCloud ? status?.enabled === true : settings.enabled;
const needsRuntime = Boolean(effectiveEnabled && !isCloud && status && (!status.nutInstalled || !status.screenshotInstalled));
const desktopAgentCount = status?.desktopAgentCount ?? (status?.desktopAgentConnected ? 1 : 0);
const modeDescription = isCloud
? 'Cloud Computer Use connects a hosted agent to the CloudCLI desktop app on your machine. Agents create sessions automatically through MCP, and approval happens in the desktop app.'
: 'Local Computer Use runs on this machine. Agents create sessions automatically through MCP, but input actions require you to grant control from the Computer tab.';
? 'Let cloud agents request access to your own computer through CloudCLI Desktop.'
: 'Let local agents request access to this computer.';
return (
<div className="space-y-8">
@@ -108,14 +118,14 @@ export default function ComputerUseSettingsTab() {
<div className="flex flex-col gap-3 px-4 py-4">
<div className="rounded-md border border-amber-300/50 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
{isCloud
? 'Computer Use can control your entire desktop after you approve the request in the CloudCLI desktop app. Use Stop in the Computer tab to end the active session.'
: 'Computer Use can control your entire desktop. Agents act only while you grant control from the Computer tab, and any action stops the moment you press Stop.'}
? 'A cloud agent can use your desktop only after you approve the request in CloudCLI Desktop. Stop ends access immediately.'
: 'Agents can use your desktop only while you grant control from the Computer tab. Stop ends access immediately.'}
</div>
{effectiveEnabled && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{isCloud
? 'When a cloud agent needs desktop access, it will create a session automatically. Keep CloudCLI Desktop running and connected to this environment to receive approval prompts.'
: 'When a local agent needs desktop access, it will create a session automatically. Open the Computer tab to review the session, grant control, or stop it. On macOS, grant Accessibility and Screen Recording to CloudCLI Desktop if prompted.'}
? 'Keep CloudCLI Desktop open on the computer you want agents to use.'
: 'Open the Computer tab to review requests, grant control, or stop a session.'}
</div>
)}
</div>
@@ -123,15 +133,31 @@ export default function ComputerUseSettingsTab() {
{isCloud ? (
<SettingsRow
label="Cloud desktop access"
description="Managed by the CloudCLI desktop app. Agents can use computer tools when a desktop agent is linked to this cloud environment."
description={status?.desktopAgentConnected
? `${desktopAgentCount} ${desktopAgentCount === 1 ? 'desktop app is' : 'desktop apps are'} connected to this environment.`
: 'Not connected yet. Link happens from CloudCLI Desktop on your computer.'}
>
<div className={`rounded-md border px-2.5 py-1 text-xs font-medium ${
status?.desktopAgentConnected
? 'border-emerald-500/30 text-emerald-600 dark:text-emerald-300'
: 'border-amber-500/30 text-amber-600 dark:text-amber-300'
}`}
>
{status?.desktopAgentConnected ? 'Desktop linked' : 'Desktop not linked'}
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => void refreshState()}
disabled={isLoading}
className="h-8"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<div className={`rounded-md border px-2.5 py-1 text-xs font-medium ${
status?.desktopAgentConnected
? 'border-emerald-500/30 text-emerald-600 dark:text-emerald-300'
: 'border-amber-500/30 text-amber-600 dark:text-amber-300'
}`}
>
{status?.desktopAgentConnected
? `${desktopAgentCount} linked`
: 'Not linked'}
</div>
</div>
</SettingsRow>
) : (
@@ -150,9 +176,23 @@ export default function ComputerUseSettingsTab() {
{(needsRuntime || isCloud || error) && (
<div className="space-y-4 px-4 py-4">
{isCloud && (
{isCloud && !status?.desktopAgentConnected && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-3 text-sm text-muted-foreground">
<div className="font-medium text-foreground">To link this computer</div>
<ol className="mt-2 list-decimal space-y-1 pl-5">
<li>Open CloudCLI Desktop on the computer you want agents to use.</li>
<li>Connect the same CloudCLI account used for this cloud environment.</li>
<li>Open Desktop Settings and turn on Computer Use.</li>
<li>Keep the desktop app running. This status changes to Desktop linked automatically.</li>
</ol>
</div>
)}
{isCloud && status?.desktopAgentConnected && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{status?.message || 'Cloud Computer Use requires a linked CloudCLI Desktop Agent on the user machine.'}
{desktopAgentCount > 1
? `${desktopAgentCount} desktops are linked. Agents will use one available desktop; stop Computer Use on any desktop you do not want agents to control.`
: 'CloudCLI Desktop is linked. Approval prompts will appear there when an agent requests desktop access.'}
</div>
)}