From 4d70a2588c425a9fa4c29432045e77a091a0cc20 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Fri, 19 Jun 2026 13:47:16 +0000 Subject: [PATCH] feat: improve Computer Use linking status --- .../workflows/desktop-macos-branch-build.yml | 8 +- .github/workflows/desktop-macos-release.yml | 6 +- .../desktop-windows-branch-build.yml | 8 +- electron/cloud.js | 32 ++- electron/desktopWindow.js | 171 ++------------ electron/launcher/launcher.js | 30 ++- electron/main.js | 14 +- electron/tabs.js | 4 +- electron/viewHost.js | 214 ++++++++++++++++++ .../computer-use/computer-use.service.ts | 9 +- .../desktop-agent-relay.service.ts | 2 +- .../computer-use/view/ComputerUsePanel.tsx | 123 ++++++++-- .../main-content/view/MainContent.tsx | 2 +- .../ComputerUseSettingsTab.tsx | 82 +++++-- 14 files changed, 474 insertions(+), 231 deletions(-) create mode 100644 electron/viewHost.js diff --git a/.github/workflows/desktop-macos-branch-build.yml b/.github/workflows/desktop-macos-branch-build.yml index f924f03e..fe29e65d 100644 --- a/.github/workflows/desktop-macos-branch-build.yml +++ b/.github/workflows/desktop-macos-branch-build.yml @@ -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 diff --git a/.github/workflows/desktop-macos-release.yml b/.github/workflows/desktop-macos-release.yml index 7060fc4c..d52f20db 100644 --- a/.github/workflows/desktop-macos-release.yml +++ b/.github/workflows/desktop-macos-release.yml @@ -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 diff --git a/.github/workflows/desktop-windows-branch-build.yml b/.github/workflows/desktop-windows-branch-build.yml index 6802fc9f..f3af31f0 100644 --- a/.github/workflows/desktop-windows-branch-build.yml +++ b/.github/workflows/desktop-windows-branch-build.yml @@ -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 diff --git a/electron/cloud.js b/electron/cloud.js index 5cdfbc59..5c6b1794 100644 --- a/electron/cloud.js +++ b/electron/cloud.js @@ -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) { diff --git a/electron/desktopWindow.js b/electron/desktopWindow.js index cfc96de7..856899fb 100644 --- a/electron/desktopWindow.js +++ b/electron/desktopWindow.js @@ -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, '>') - .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; - } -} - 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; diff --git a/electron/launcher/launcher.js b/electron/launcher/launcher.js index 3b399119..f4b02d87 100644 --- a/electron/launcher/launcher.js +++ b/electron/launcher/launcher.js @@ -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 = '
' + - ''; + '' + + '
' + CC.esc(status.label) + '' + CC.esc(status.detail) + '
'; if (computerUse.enabled) { body += '
' + renderComputerPermissions(state) + '
'; body += '
' + @@ -721,11 +731,11 @@ window.__MOCK_STATE__ = { } function localPane(state) { - return '

Local CloudCLI

Run the open-source app on this machine. No account required.

' + + return '

Local servers

Manage Local CloudCLI on this machine. No account required.

' + '
Local server
' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '
' + '
' + '
Computer Use
' + CC.esc(computerUseStatus(state).detail) + '
' + CC.esc(computerUseStatus(state).label) + '
' + - '
'; + '
'; } 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 = '
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.'}

-
+
+ {onShowSettings && ( + + )}