mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 02:22:55 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8dfb2cbb6 | ||
|
|
1e16f1f085 | ||
|
|
7eb7348d50 | ||
|
|
44aecbab68 | ||
|
|
18e98a780d | ||
|
|
2ebe64f218 | ||
|
|
b6cf33308d | ||
|
|
6761f31a56 |
92
.github/workflows/release.yml
vendored
92
.github/workflows/release.yml
vendored
@@ -20,82 +20,7 @@ permissions:
|
|||||||
# to immutable commit SHAs. The trailing comments keep the original major tag
|
# to immutable commit SHAs. The trailing comments keep the original major tag
|
||||||
# visible for maintenance context.
|
# visible for maintenance context.
|
||||||
jobs:
|
jobs:
|
||||||
build-macos-semantic-helper:
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- runs_on: macos-15
|
|
||||||
target_dir: darwin-arm64
|
|
||||||
- runs_on: macos-15-intel
|
|
||||||
target_dir: darwin-x64
|
|
||||||
runs-on: ${{ matrix.runs_on }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
- name: Build macOS semantic helper
|
|
||||||
run: node scripts/build-computer-semantics.mjs
|
|
||||||
env:
|
|
||||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
|
||||||
- name: Verify macOS semantic helper target
|
|
||||||
run: test -x "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics"
|
|
||||||
- name: Stage macOS semantic helper artifact
|
|
||||||
run: |
|
|
||||||
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
|
||||||
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
|
||||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
||||||
with:
|
|
||||||
name: semantic-helper-${{ matrix.target_dir }}
|
|
||||||
path: semantic-helper-artifact/*
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-windows-semantic-helper:
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- runs_on: windows-2025
|
|
||||||
target_dir: win32-x64
|
|
||||||
- runs_on: windows-11-arm
|
|
||||||
target_dir: win32-arm64
|
|
||||||
runs-on: ${{ matrix.runs_on }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
- name: Build Windows semantic helper
|
|
||||||
run: node scripts/build-computer-semantics.mjs
|
|
||||||
env:
|
|
||||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
|
||||||
- name: Verify Windows semantic helper target
|
|
||||||
shell: bash
|
|
||||||
run: test -f "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe"
|
|
||||||
- name: Stage Windows semantic helper artifact
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
|
||||||
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
|
||||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
||||||
with:
|
|
||||||
name: semantic-helper-${{ matrix.target_dir }}
|
|
||||||
path: semantic-helper-artifact/*
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs:
|
|
||||||
- build-macos-semantic-helper
|
|
||||||
- build-windows-semantic-helper
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -118,23 +43,6 @@ jobs:
|
|||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
|
||||||
with:
|
|
||||||
pattern: semantic-helper-*
|
|
||||||
path: server/modules/computer-use/semantics/bin
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Restore semantic helper permissions
|
|
||||||
run: find server/modules/computer-use/semantics/bin -path '*/darwin-*/CloudCLISemantics' -type f -exec chmod 755 {} +
|
|
||||||
|
|
||||||
- name: Verify bundled semantic helpers
|
|
||||||
run: |
|
|
||||||
test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics
|
|
||||||
test -x server/modules/computer-use/semantics/bin/darwin-x64/CloudCLISemantics
|
|
||||||
test -f server/modules/computer-use/semantics/bin/win32-x64/CloudCLISemantics.exe
|
|
||||||
test -f server/modules/computer-use/semantics/bin/win32-arm64/CloudCLISemantics.exe
|
|
||||||
find server/modules/computer-use/semantics/bin -maxdepth 2 -type f -print
|
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
run: |
|
run: |
|
||||||
ARGS="--ci --increment=${{ inputs.increment }}"
|
ARGS="--ci --increment=${{ inputs.increment }}"
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -3,6 +3,18 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.35.1](https://github.com/siteboon/claudecodeui/compare/v1.35.0...v1.35.1) (2026-07-01)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* preview video on new tab ([#933](https://github.com/siteboon/claudecodeui/issues/933)) ([2ebe64f](https://github.com/siteboon/claudecodeui/commit/2ebe64f21874f45f6c8747310be874ae7342c61c))
|
||||||
|
* remove obsolete semantic helper release jobs ([1e16f1f](https://github.com/siteboon/claudecodeui/commit/1e16f1f0854e347aa333434638d64f2b167d9a9d))
|
||||||
|
* resolve mobile shell issues ([#923](https://github.com/siteboon/claudecodeui/issues/923)) ([b6cf333](https://github.com/siteboon/claudecodeui/commit/b6cf33308da996f8169580a4b5b74e3c5f38e447))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* remove computer use ([6761f31](https://github.com/siteboon/claudecodeui/commit/6761f31a56fe82d82c7e0c079b4891e7d5a81817))
|
||||||
|
|
||||||
## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29)
|
## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -74,12 +74,6 @@ The fastest way to get started — no local setup required. Get a fully managed,
|
|||||||
|
|
||||||
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
||||||
|
|
||||||
### Desktop App
|
|
||||||
|
|
||||||
Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page.
|
|
||||||
|
|
||||||
Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
|
||||||
|
|
||||||
### Self-Hosted (Open source)
|
### Self-Hosted (Open source)
|
||||||
|
|
||||||
#### npm
|
#### npm
|
||||||
@@ -111,6 +105,16 @@ npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
|||||||
|
|
||||||
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
|
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
|
||||||
|
|
||||||
|
### Desktop Companion App
|
||||||
|
|
||||||
|
CloudCLI Desktop is an optional native companion for CloudCLI Cloud and Local CloudCLI. It ships from this repository's GitHub Releases and keeps CloudCLI available from your menu bar or tray.
|
||||||
|
|
||||||
|
- **[macOS](https://cloudcli.ai/download/macos)**
|
||||||
|
- **[Windows](https://cloudcli.ai/download/windows)**
|
||||||
|
- **[Download page](https://cloudcli.ai/download)** · **[GitHub Releases and checksums](https://github.com/siteboon/claudecodeui/releases)**
|
||||||
|
|
||||||
|
Use it to open CloudCLI Cloud environments, switch between local and remote workspaces, and copy mobile/browser URLs. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,7 +129,8 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
|
|||||||
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
|
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
|
||||||
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
||||||
| **Machine needs to stay on** | Yes | Yes | No |
|
| **Machine needs to stay on** | Yes | Yes | No |
|
||||||
| **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
|
| **Mobile access** | Any browser on your network | Any browser on your network | Any device |
|
||||||
|
| **Desktop companion** | Optional. Choose Local CloudCLI | Optional. Choose Local CloudCLI | Optional. Opens cloud environments |
|
||||||
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||||
| **File explorer and Git** | Yes | Yes | Yes |
|
| **File explorer and Git** | Yes | Yes | Yes |
|
||||||
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const IPC_PREFIX = '@@CUAGENT@@';
|
|
||||||
const TARGET_STATUS_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
function getDesktopPath() {
|
|
||||||
const currentPath = process.env.PATH || '';
|
|
||||||
const commonPaths = process.platform === 'win32'
|
|
||||||
? []
|
|
||||||
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
|
||||||
return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNodeRuntime(isPackaged) {
|
|
||||||
if (isPackaged && process.versions.electron) {
|
|
||||||
return { command: process.execPath, env: { ELECTRON_RUN_AS_NODE: '1' } };
|
|
||||||
}
|
|
||||||
if (process.env.npm_node_execpath) {
|
|
||||||
return { command: process.env.npm_node_execpath, env: {} };
|
|
||||||
}
|
|
||||||
return { command: 'node', env: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAgentWsUrl(httpUrl) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(httpUrl);
|
|
||||||
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
|
|
||||||
parsed.pathname = '/desktop-agent';
|
|
||||||
parsed.search = '';
|
|
||||||
parsed.hash = '';
|
|
||||||
return parsed.toString();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isComputerUseEnabledTarget(httpUrl, apiKey) {
|
|
||||||
let statusUrl;
|
|
||||||
try {
|
|
||||||
statusUrl = new URL('/api/computer-use/status', httpUrl).toString();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), TARGET_STATUS_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
const response = await fetch(statusUrl, {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: apiKey ? { 'X-API-Key': apiKey } : undefined,
|
|
||||||
});
|
|
||||||
const body = await response.json().catch(() => null);
|
|
||||||
return response.ok && body?.success !== false && body?.data?.enabled === true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function filterEnabledComputerUseTargets(targets, apiKey) {
|
|
||||||
const checks = await Promise.all(targets.map(async (target) => ({
|
|
||||||
target,
|
|
||||||
enabled: await isComputerUseEnabledTarget(target, apiKey),
|
|
||||||
})));
|
|
||||||
return checks.filter((item) => item.enabled).map((item) => item.target);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps a Computer Use desktop agent connected to running cloud environments
|
|
||||||
* while desktop access is enabled.
|
|
||||||
*/
|
|
||||||
export class ComputerAgentController {
|
|
||||||
constructor({ appRoot, settingsPath, isPackaged = false, getRunningEnvironmentUrls, getApiKey, promptConsent, onChange }) {
|
|
||||||
this.appRoot = appRoot;
|
|
||||||
this.settingsPath = settingsPath;
|
|
||||||
this.isPackaged = isPackaged;
|
|
||||||
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
|
|
||||||
this.getApiKey = getApiKey;
|
|
||||||
this.promptConsent = promptConsent;
|
|
||||||
this.onChange = onChange;
|
|
||||||
this.settings = { enabled: false, consentMode: 'ask' };
|
|
||||||
this.child = null;
|
|
||||||
this.connectedUrls = new Set();
|
|
||||||
this.currentTargets = [];
|
|
||||||
this.stdoutBuffer = '';
|
|
||||||
this.lastEvent = null;
|
|
||||||
this.lastError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSettings() {
|
|
||||||
return { ...this.settings };
|
|
||||||
}
|
|
||||||
|
|
||||||
getState() {
|
|
||||||
return {
|
|
||||||
enabled: this.settings.enabled,
|
|
||||||
consentMode: this.settings.consentMode,
|
|
||||||
running: Boolean(this.child),
|
|
||||||
connectedCount: this.connectedUrls.size,
|
|
||||||
targetCount: this.currentTargets.length,
|
|
||||||
targetUrls: [...this.currentTargets],
|
|
||||||
lastEvent: this.lastEvent,
|
|
||||||
lastError: this.lastError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSettings() {
|
|
||||||
try {
|
|
||||||
const raw = await fs.readFile(this.settingsPath, 'utf8');
|
|
||||||
const stored = JSON.parse(raw);
|
|
||||||
this.settings = {
|
|
||||||
enabled: Boolean(stored.enabled),
|
|
||||||
consentMode: stored.consentMode === 'auto' ? 'auto' : 'ask',
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
this.settings = { enabled: false, consentMode: 'ask' };
|
|
||||||
}
|
|
||||||
return this.settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveSettings(next) {
|
|
||||||
this.settings = {
|
|
||||||
enabled: Boolean(next.enabled),
|
|
||||||
consentMode: next.consentMode === 'auto' ? 'auto' : 'ask',
|
|
||||||
};
|
|
||||||
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
|
|
||||||
await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8');
|
|
||||||
await this.sync();
|
|
||||||
this.onChange?.();
|
|
||||||
return this.settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sync() {
|
|
||||||
const targets = this.settings.enabled ? (this.getRunningEnvironmentUrls?.() || []) : [];
|
|
||||||
const enabledTargets = this.settings.enabled ? await filterEnabledComputerUseTargets(targets, this.getApiKey?.() || '') : [];
|
|
||||||
const wsTargets = enabledTargets.map(toAgentWsUrl).filter(Boolean);
|
|
||||||
|
|
||||||
const sameTargets =
|
|
||||||
wsTargets.length === this.currentTargets.length &&
|
|
||||||
wsTargets.every((url) => this.currentTargets.includes(url));
|
|
||||||
|
|
||||||
if (!this.settings.enabled || wsTargets.length === 0) {
|
|
||||||
this.stop();
|
|
||||||
this.currentTargets = [];
|
|
||||||
this.lastEvent = this.settings.enabled ? 'no-targets' : 'disabled';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.child && sameTargets) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentTargets = wsTargets;
|
|
||||||
this.lastEvent = 'restarting';
|
|
||||||
this.lastError = null;
|
|
||||||
this.restart(wsTargets);
|
|
||||||
}
|
|
||||||
|
|
||||||
restart(wsTargets) {
|
|
||||||
this.stop();
|
|
||||||
|
|
||||||
const agentEntry = process.env.CLOUDCLI_COMPUTER_AGENT_ENTRY
|
|
||||||
|| path.join(this.appRoot, 'dist-server', 'server', 'computer-use-agent.js');
|
|
||||||
const runtime = getNodeRuntime(this.isPackaged);
|
|
||||||
|
|
||||||
this.child = spawn(runtime.command, [agentEntry], {
|
|
||||||
cwd: this.appRoot,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
...runtime.env,
|
|
||||||
PATH: getDesktopPath(),
|
|
||||||
CLOUDCLI_DESKTOP_AGENT_URLS: wsTargets.join(','),
|
|
||||||
CLOUDCLI_DESKTOP_AGENT_API_KEY: this.getApiKey?.() || '',
|
|
||||||
CLOUDCLI_COMPUTER_USE_CONSENT_MODE: this.settings.consentMode,
|
|
||||||
},
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.connectedUrls = new Set();
|
|
||||||
|
|
||||||
this.child.once('error', (error) => {
|
|
||||||
console.error('[ComputerAgent] failed to start:', error.message);
|
|
||||||
this.lastEvent = 'start-error';
|
|
||||||
this.lastError = error.message;
|
|
||||||
this.child = null;
|
|
||||||
this.onChange?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.child.stdout?.on('data', (chunk) => this.handleStdout(String(chunk)));
|
|
||||||
this.child.stderr?.on('data', (chunk) => {
|
|
||||||
for (const line of String(chunk).split(/\r?\n/)) {
|
|
||||||
if (line.trim()) {
|
|
||||||
this.lastError = line.trim();
|
|
||||||
console.error('[ComputerAgent]', line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.child.once('exit', (code) => {
|
|
||||||
console.log(`[ComputerAgent] exited (code ${code ?? 'null'})`);
|
|
||||||
this.lastEvent = `exit:${code ?? 'null'}`;
|
|
||||||
this.child = null;
|
|
||||||
this.connectedUrls = new Set();
|
|
||||||
this.onChange?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.onChange?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleStdout(chunk) {
|
|
||||||
this.stdoutBuffer += chunk;
|
|
||||||
const lines = this.stdoutBuffer.split('\n');
|
|
||||||
this.stdoutBuffer = lines.pop() || '';
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed.startsWith(IPC_PREFIX)) {
|
|
||||||
if (trimmed) console.log('[ComputerAgent]', trimmed);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(trimmed.slice(IPC_PREFIX.length).trim());
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
void this.handleAgentEvent(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleAgentEvent(payload) {
|
|
||||||
switch (payload.type) {
|
|
||||||
case 'connected':
|
|
||||||
this.connectedUrls.add(payload.url);
|
|
||||||
this.lastEvent = 'connected';
|
|
||||||
this.lastError = null;
|
|
||||||
this.onChange?.();
|
|
||||||
break;
|
|
||||||
case 'disconnected':
|
|
||||||
this.connectedUrls.delete(payload.url);
|
|
||||||
this.lastEvent = 'disconnected';
|
|
||||||
this.onChange?.();
|
|
||||||
if (payload.reason && /computer use.*disabled/i.test(payload.reason)) {
|
|
||||||
void this.sync().catch((error) => {
|
|
||||||
this.lastError = error instanceof Error ? error.message : 'Failed to sync Computer Use targets.';
|
|
||||||
this.onChange?.();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'starting':
|
|
||||||
this.lastEvent = 'starting';
|
|
||||||
this.lastError = null;
|
|
||||||
this.onChange?.();
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
this.lastEvent = 'error';
|
|
||||||
this.lastError = payload.message || 'Computer agent error.';
|
|
||||||
this.onChange?.();
|
|
||||||
break;
|
|
||||||
case 'consent-request': {
|
|
||||||
const allow = await this.promptConsent?.(payload.sessionId);
|
|
||||||
this.sendToChild({ type: 'consent-response', sessionId: payload.sessionId, allow: Boolean(allow) });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendToChild(message) {
|
|
||||||
if (this.child?.stdin?.writable) {
|
|
||||||
this.child.stdin.write(`${IPC_PREFIX} ${JSON.stringify(message)}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
revokeSession(sessionId) {
|
|
||||||
this.sendToChild({ type: 'revoke-session', sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
if (!this.child) return;
|
|
||||||
const child = this.child;
|
|
||||||
this.child = null;
|
|
||||||
this.connectedUrls = new Set();
|
|
||||||
try { child.kill('SIGTERM'); } catch { /* noop */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,6 @@ import { ViewHost } from './viewHost.js';
|
|||||||
|
|
||||||
const TITLEBAR_HEIGHT = 44;
|
const TITLEBAR_HEIGHT = 44;
|
||||||
const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
|
const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
|
||||||
// TODO: Re-enable Computer Use menus after fixing the MCP server connection
|
|
||||||
// between the desktop app and the web UI.
|
|
||||||
const COMPUTER_USE_MENUS_ENABLED = false;
|
|
||||||
|
|
||||||
function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) {
|
function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) {
|
||||||
try {
|
try {
|
||||||
const source = new URL(sourceUrl);
|
const source = new URL(sourceUrl);
|
||||||
@@ -437,17 +433,6 @@ export class DesktopWindowManager {
|
|||||||
accelerator: 'CmdOrCtrl+Shift+E',
|
accelerator: 'CmdOrCtrl+Shift+E',
|
||||||
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
|
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Services',
|
|
||||||
visible: COMPUTER_USE_MENUS_ENABLED,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Computer Use',
|
|
||||||
click: () => void this.showDesktopSettings(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Diagnostics',
|
label: 'Diagnostics',
|
||||||
submenu: [
|
submenu: [
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ window.__MOCK_STATE__ = {
|
|||||||
shareableWebUrl: 'http://localhost:3001',
|
shareableWebUrl: 'http://localhost:3001',
|
||||||
localServerRunning: false,
|
localServerRunning: false,
|
||||||
localStartupLogs: [],
|
localStartupLogs: [],
|
||||||
computerUse: { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 },
|
|
||||||
computerUsePermissions: {
|
|
||||||
platform: 'darwin',
|
|
||||||
supported: true,
|
|
||||||
accessibility: 'not_granted',
|
|
||||||
screenRecording: 'not_determined',
|
|
||||||
message: 'macOS requires Accessibility and Screen Recording for Computer Use.',
|
|
||||||
},
|
|
||||||
environments: [
|
environments: [
|
||||||
{ id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' },
|
{ id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' },
|
||||||
{ id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' },
|
{ id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' },
|
||||||
@@ -62,7 +54,6 @@ window.__MOCK_STATE__ = {
|
|||||||
refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
|
refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
|
||||||
refreshActiveTab: function () { return Promise.resolve(clone(mockState)); },
|
refreshActiveTab: function () { return Promise.resolve(clone(mockState)); },
|
||||||
copyDiagnostics: function () { return Promise.resolve(clone(mockState)); },
|
copyDiagnostics: function () { return Promise.resolve(clone(mockState)); },
|
||||||
showComputerAccess: function () { return Promise.resolve(clone(mockState)); },
|
|
||||||
showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); },
|
showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); },
|
||||||
showLauncher: function () { return Promise.resolve(clone(mockState)); },
|
showLauncher: function () { return Promise.resolve(clone(mockState)); },
|
||||||
showLocalSettings: function () { return Promise.resolve(clone(mockState)); },
|
showLocalSettings: function () { return Promise.resolve(clone(mockState)); },
|
||||||
@@ -82,23 +73,6 @@ window.__MOCK_STATE__ = {
|
|||||||
mockState.desktopSettings[key] = key === 'themeMode' ? value : !!value;
|
mockState.desktopSettings[key] = key === 'themeMode' ? value : !!value;
|
||||||
return Promise.resolve(clone(mockState));
|
return Promise.resolve(clone(mockState));
|
||||||
},
|
},
|
||||||
updateComputerUse: function (settings) {
|
|
||||||
mockState.computerUse = mockState.computerUse || { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 };
|
|
||||||
if (typeof settings.enabled === 'boolean') mockState.computerUse.enabled = settings.enabled;
|
|
||||||
if (settings.consentMode === 'auto' || settings.consentMode === 'ask') mockState.computerUse.consentMode = settings.consentMode;
|
|
||||||
mockState.computerUse.running = mockState.computerUse.enabled;
|
|
||||||
return Promise.resolve(clone(mockState));
|
|
||||||
},
|
|
||||||
requestComputerUsePermission: function (permission) {
|
|
||||||
mockState.computerUsePermissions = mockState.computerUsePermissions || {};
|
|
||||||
if (permission === 'accessibility') mockState.computerUsePermissions.accessibility = 'granted';
|
|
||||||
if (permission === 'screen') mockState.computerUsePermissions.screenRecording = 'granted';
|
|
||||||
if (permission === 'all') {
|
|
||||||
mockState.computerUsePermissions.accessibility = 'granted';
|
|
||||||
mockState.computerUsePermissions.screenRecording = 'granted';
|
|
||||||
}
|
|
||||||
return Promise.resolve(clone(mockState));
|
|
||||||
},
|
|
||||||
openEnvironment: function (id) {
|
openEnvironment: function (id) {
|
||||||
var env = (mockState.environments || []).filter(function (item) { return item.id === id; })[0];
|
var env = (mockState.environments || []).filter(function (item) { return item.id === id; })[0];
|
||||||
if (env) {
|
if (env) {
|
||||||
@@ -189,22 +163,6 @@ window.__MOCK_STATE__ = {
|
|||||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
function computerUseStatus(state) {
|
|
||||||
var computerUse = state && state.computerUse ? state.computerUse : {};
|
|
||||||
var connectedCount = computerUse.connectedCount || 0;
|
|
||||||
var environmentLabel = connectedCount + ' environment' + (connectedCount === 1 ? '' : 's');
|
|
||||||
if (!computerUse.enabled) {
|
|
||||||
return { label: 'Disabled', tone: 'idle', detail: 'CloudCLI cannot use this computer.' };
|
|
||||||
}
|
|
||||||
if (!connectedCount) {
|
|
||||||
return { label: 'Not connected', tone: 'warn', detail: 'No environment connected.' };
|
|
||||||
}
|
|
||||||
if (computerUse.consentMode === 'auto') {
|
|
||||||
return { label: 'Connected', tone: 'warn', detail: environmentLabel + ' connected. Unattended access is on.' };
|
|
||||||
}
|
|
||||||
return { label: 'Connected', tone: 'ok', detail: environmentLabel + ' connected.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
var CC = {
|
var CC = {
|
||||||
icon: icon,
|
icon: icon,
|
||||||
esc: esc,
|
esc: esc,
|
||||||
@@ -214,7 +172,6 @@ window.__MOCK_STATE__ = {
|
|||||||
accountLabel: accountLabel,
|
accountLabel: accountLabel,
|
||||||
localUrl: localUrl,
|
localUrl: localUrl,
|
||||||
envCount: envCount,
|
envCount: envCount,
|
||||||
computerUseStatus: computerUseStatus,
|
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
logoUrl: LOGO_URL,
|
logoUrl: LOGO_URL,
|
||||||
platform: 'win',
|
platform: 'win',
|
||||||
@@ -352,42 +309,12 @@ window.__MOCK_STATE__ = {
|
|||||||
return CC.run('Saved', function () { return bridge.updateSetting(node.key, node.value); });
|
return CC.run('Saved', function () { return bridge.updateSetting(node.key, node.value); });
|
||||||
case 'set-theme-mode':
|
case 'set-theme-mode':
|
||||||
return CC.run('Saved', function () { return bridge.updateSetting('themeMode', node.value); });
|
return CC.run('Saved', function () { return bridge.updateSetting('themeMode', node.value); });
|
||||||
case 'set-computer-mode':
|
|
||||||
CC.state.computerUse = {
|
|
||||||
...((CC.state && CC.state.computerUse) || {}),
|
|
||||||
enabled: true,
|
|
||||||
consentMode: node.value === 'auto' ? 'auto' : 'ask',
|
|
||||||
};
|
|
||||||
return CC.run('Saved', function () {
|
|
||||||
return bridge.updateComputerUse({
|
|
||||||
enabled: true,
|
|
||||||
consentMode: node.value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
case 'set-computer-enabled':
|
|
||||||
CC.state.computerUse = {
|
|
||||||
...((CC.state && CC.state.computerUse) || {}),
|
|
||||||
enabled: !!node.value,
|
|
||||||
};
|
|
||||||
return CC.run('Saved', function () {
|
|
||||||
var current = (CC.state && CC.state.computerUse) || { consentMode: 'ask' };
|
|
||||||
return bridge.updateComputerUse({
|
|
||||||
enabled: !!node.value,
|
|
||||||
consentMode: current.consentMode === 'auto' ? 'auto' : 'ask',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
case 'computer-permission':
|
|
||||||
return CC.run('Opening permission settings...', function () {
|
|
||||||
return bridge.requestComputerUsePermission(node.getAttribute('data-cc-computer-permission'));
|
|
||||||
});
|
|
||||||
case 'settings-toggle':
|
case 'settings-toggle':
|
||||||
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
||||||
case 'desktop-settings-toggle':
|
case 'desktop-settings-toggle':
|
||||||
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
||||||
case 'local-settings-toggle':
|
case 'local-settings-toggle':
|
||||||
return CC.run('Opening local settings...', function () { return bridge.showLocalSettings(); });
|
return CC.run('Opening local settings...', function () { return bridge.showLocalSettings(); });
|
||||||
case 'computer-settings-toggle':
|
|
||||||
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
|
||||||
case 'settings-close':
|
case 'settings-close':
|
||||||
return CC.closeSheet();
|
return CC.closeSheet();
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
@@ -541,62 +468,6 @@ window.__MOCK_STATE__ = {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function permissionLabel(value) {
|
|
||||||
if (value === 'granted') return 'Granted';
|
|
||||||
if (value === 'denied' || value === 'restricted') return 'Needs attention';
|
|
||||||
if (value === 'not_applicable') return 'Not required';
|
|
||||||
return 'Not granted';
|
|
||||||
}
|
|
||||||
|
|
||||||
function permissionTone(value) {
|
|
||||||
if (value === 'granted' || value === 'not_applicable') return 'ok';
|
|
||||||
if (value === 'denied' || value === 'restricted') return 'warn';
|
|
||||||
return 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Re-enable Computer Use menus after fixing the MCP server connection
|
|
||||||
// between the desktop app and the web UI.
|
|
||||||
var COMPUTER_USE_MENUS_ENABLED = false;
|
|
||||||
|
|
||||||
function renderComputerPermissionRow(key, label, detail, status) {
|
|
||||||
return '<div class="cc-permission-row">' +
|
|
||||||
'<div><div class="cc-permission-title">' + CC.esc(label) + '</div><div class="cc-permission-detail">' + CC.esc(detail) + '</div></div>' +
|
|
||||||
'<div class="cc-permission-actions"><span class="badge ' + permissionTone(status) + '">' + CC.esc(permissionLabel(status)) + '</span>' +
|
|
||||||
(status === 'granted' || status === 'not_applicable'
|
|
||||||
? ''
|
|
||||||
: '<button class="btn sm" data-cc-action="computer-permission" data-cc-computer-permission="' + CC.esc(key) + '">Open settings</button>') +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderComputerPermissions(state) {
|
|
||||||
var permissions = state.computerUsePermissions || {};
|
|
||||||
if (!permissions.supported) {
|
|
||||||
return '<div class="cc-note">' + CC.esc(permissions.message || 'No additional OS permission setup is required from CloudCLI on this platform.') + '</div>';
|
|
||||||
}
|
|
||||||
return '<div class="cc-note">' + CC.esc(permissions.message || 'Grant the required OS permissions before approving agent control.') + '</div>' +
|
|
||||||
renderComputerPermissionRow('accessibility', 'Accessibility', 'Allows CloudCLI to click, type, and use accessibility actions.', permissions.accessibility) +
|
|
||||||
renderComputerPermissionRow('screen', 'Screen Recording', 'Allows CloudCLI to capture screenshots for agent observation.', permissions.screenRecording);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>' +
|
|
||||||
'<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">' +
|
|
||||||
CC.renderRadioOption('computer-access-mode', 'ask', computerUse.consentMode !== 'auto', 'Ask before each session', 'Agents can request control, but you approve every session.') +
|
|
||||||
CC.renderRadioOption('computer-access-mode', 'auto', computerUse.consentMode === 'auto', 'Unattended access', 'Trusted agents can use this computer without a local approval prompt.') +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
body += '</div>';
|
|
||||||
return CC.renderSection('COMPUTER USE', 'Control how agents can use this computer', body);
|
|
||||||
};
|
|
||||||
|
|
||||||
CC.renderLocalSettings = function () {
|
CC.renderLocalSettings = function () {
|
||||||
var state = CC.state || {};
|
var state = CC.state || {};
|
||||||
var sections = [
|
var sections = [
|
||||||
@@ -612,13 +483,9 @@ window.__MOCK_STATE__ = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
CC.renderDesktopSettings = function () {
|
CC.renderDesktopSettings = function () {
|
||||||
var state = CC.state || {};
|
|
||||||
var sections = [
|
var sections = [
|
||||||
CC.buildThemeSection(state),
|
CC.buildThemeSection(CC.state || {}),
|
||||||
];
|
];
|
||||||
if (COMPUTER_USE_MENUS_ENABLED) {
|
|
||||||
sections.push(CC.buildComputerUseSection(state));
|
|
||||||
}
|
|
||||||
CC.renderSheet('Desktop Settings', 'Manage the desktop app appearance.', sections);
|
CC.renderSheet('Desktop Settings', 'Manage the desktop app appearance.', sections);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -681,15 +548,6 @@ window.__MOCK_STATE__ = {
|
|||||||
CC.act('set-theme-mode', { value: theme.value });
|
CC.act('set-theme-mode', { value: theme.value });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var computerMode = event.target.closest('[name="computer-access-mode"]');
|
|
||||||
if (computerMode) {
|
|
||||||
CC.act('set-computer-mode', { value: computerMode.value });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var computerEnabled = event.target.closest('[data-cc-computer-enabled]');
|
|
||||||
if (computerEnabled) {
|
|
||||||
CC.act('set-computer-enabled', { value: computerEnabled.checked });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', function (event) {
|
document.addEventListener('keydown', function (event) {
|
||||||
|
|||||||
118
electron/main.js
118
electron/main.js
@@ -1,10 +1,9 @@
|
|||||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, session, shell, systemPreferences } from 'electron';
|
import { app, BrowserWindow, clipboard, dialog, ipcMain, session, shell } from 'electron';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { CloudController } from './cloud.js';
|
import { CloudController } from './cloud.js';
|
||||||
import { ComputerAgentController } from './computerAgent.js';
|
|
||||||
import { DesktopWindowManager } from './desktopWindow.js';
|
import { DesktopWindowManager } from './desktopWindow.js';
|
||||||
import { DesktopNotificationsController } from './desktopNotifications.js';
|
import { DesktopNotificationsController } from './desktopNotifications.js';
|
||||||
import { LocalServerController } from './localServer.js';
|
import { LocalServerController } from './localServer.js';
|
||||||
@@ -30,7 +29,6 @@ let activeTarget = { kind: 'launcher', name: APP_NAME, url: null };
|
|||||||
let desktopWindow = null;
|
let desktopWindow = null;
|
||||||
let localServer = null;
|
let localServer = null;
|
||||||
let cloud = null;
|
let cloud = null;
|
||||||
let computerAgent = null;
|
|
||||||
let desktopNotifications = null;
|
let desktopNotifications = null;
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
let isRefreshingCloud = false;
|
let isRefreshingCloud = false;
|
||||||
@@ -63,10 +61,6 @@ function getSettingsPath() {
|
|||||||
return path.join(app.getPath('userData'), 'desktop-settings.json');
|
return path.join(app.getPath('userData'), 'desktop-settings.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getComputerUseSettingsPath() {
|
|
||||||
return path.join(app.getPath('userData'), 'computer-use-settings.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDesktopNotificationsSettingsPath() {
|
function getDesktopNotificationsSettingsPath() {
|
||||||
return path.join(app.getPath('userData'), 'desktop-notifications-settings.json');
|
return path.join(app.getPath('userData'), 'desktop-notifications-settings.json');
|
||||||
}
|
}
|
||||||
@@ -78,23 +72,6 @@ function getRunningEnvironmentUrls() {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promptComputerUseConsent(sessionId) {
|
|
||||||
const { response } = await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
|
||||||
type: 'warning',
|
|
||||||
buttons: ['Allow this session', 'Deny'],
|
|
||||||
defaultId: 0,
|
|
||||||
cancelId: 1,
|
|
||||||
title: 'Computer Use request',
|
|
||||||
message: 'An agent wants to control this computer',
|
|
||||||
detail: [
|
|
||||||
'A cloud agent is requesting control of your mouse, keyboard, and screen for this session.',
|
|
||||||
'Approval lasts for this session only. You can stop it any time from the Computer panel.',
|
|
||||||
sessionId ? `\nSession: ${sessionId}` : '',
|
|
||||||
].join('\n'),
|
|
||||||
});
|
|
||||||
return response === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayTargetName() {
|
function getDisplayTargetName() {
|
||||||
return activeTarget?.name || APP_NAME;
|
return activeTarget?.name || APP_NAME;
|
||||||
}
|
}
|
||||||
@@ -151,66 +128,10 @@ function getDesktopState() {
|
|||||||
tabs: tabs.getSerializableTabs(),
|
tabs: tabs.getSerializableTabs(),
|
||||||
activeTabId: tabs.activeTabId,
|
activeTabId: tabs.activeTabId,
|
||||||
environments: cloud.getEnvironments().map(serializeEnvironment),
|
environments: cloud.getEnvironments().map(serializeEnvironment),
|
||||||
computerUse: computerAgent?.getState() || { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 },
|
|
||||||
desktopNotifications: desktopNotifications?.getState() || { enabled: false, supported: false, connectedCount: 0, targetCount: 0 },
|
desktopNotifications: desktopNotifications?.getState() || { enabled: false, supported: false, connectedCount: 0, targetCount: 0 },
|
||||||
computerUsePermissions: getComputerUsePermissions(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getComputerUsePermissions() {
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
return {
|
|
||||||
platform: process.platform,
|
|
||||||
supported: false,
|
|
||||||
accessibility: 'not_applicable',
|
|
||||||
screenRecording: 'not_applicable',
|
|
||||||
message: 'No OS permission onboarding is required from CloudCLI on this platform.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let accessibility;
|
|
||||||
let screenRecording;
|
|
||||||
try {
|
|
||||||
accessibility = systemPreferences.isTrustedAccessibilityClient(false) ? 'granted' : 'not_granted';
|
|
||||||
} catch {
|
|
||||||
accessibility = 'unknown';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
screenRecording = systemPreferences.getMediaAccessStatus('screen');
|
|
||||||
} catch {
|
|
||||||
screenRecording = 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
platform: 'darwin',
|
|
||||||
supported: true,
|
|
||||||
accessibility,
|
|
||||||
screenRecording,
|
|
||||||
message: accessibility === 'granted' && screenRecording === 'granted'
|
|
||||||
? 'macOS permissions are granted.'
|
|
||||||
: 'macOS requires Accessibility and Screen Recording for Computer Use.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestComputerUsePermission(permission) {
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
return getDesktopState();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (permission === 'accessibility') {
|
|
||||||
systemPreferences.isTrustedAccessibilityClient(true);
|
|
||||||
} else if (permission === 'screen') {
|
|
||||||
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
|
|
||||||
} else if (permission === 'all') {
|
|
||||||
systemPreferences.isTrustedAccessibilityClient(true);
|
|
||||||
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unknown Computer Use permission: ${permission}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getDesktopState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openExternalUrl(url) {
|
async function openExternalUrl(url) {
|
||||||
if (String(url).startsWith(CALLBACK_PROTOCOL + "://")) {
|
if (String(url).startsWith(CALLBACK_PROTOCOL + "://")) {
|
||||||
await handleDeepLink(url);
|
await handleDeepLink(url);
|
||||||
@@ -316,8 +237,6 @@ function getDiagnosticsText() {
|
|||||||
cloudEnvironmentCount: cloud.getEnvironments().length,
|
cloudEnvironmentCount: cloud.getEnvironments().length,
|
||||||
cloudRunningEnvironmentCount: getRunningEnvironmentUrls().length,
|
cloudRunningEnvironmentCount: getRunningEnvironmentUrls().length,
|
||||||
cloudAuthState: cloud.getAuthState(),
|
cloudAuthState: cloud.getAuthState(),
|
||||||
computerUse: computerAgent?.getState() || null,
|
|
||||||
computerUseSettingsPath: getComputerUseSettingsPath(),
|
|
||||||
cloudAccountPath: getStorePath(),
|
cloudAccountPath: getStorePath(),
|
||||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||||
}, null, 2);
|
}, null, 2);
|
||||||
@@ -332,22 +251,6 @@ async function copyDiagnostics() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showComputerAccess() {
|
|
||||||
await desktopWindow?.showDesktopSettings();
|
|
||||||
return getDesktopState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateComputerUse(settings) {
|
|
||||||
const current = computerAgent?.getSettings() || { enabled: false, consentMode: 'ask' };
|
|
||||||
const next = {
|
|
||||||
enabled: typeof settings?.enabled === 'boolean' ? settings.enabled : current.enabled,
|
|
||||||
consentMode: settings?.consentMode === 'auto' ? 'auto' : 'ask',
|
|
||||||
};
|
|
||||||
await computerAgent?.saveSettings(next);
|
|
||||||
syncDesktopState();
|
|
||||||
return getDesktopState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
||||||
isRefreshingCloud = true;
|
isRefreshingCloud = true;
|
||||||
syncDesktopState();
|
syncDesktopState();
|
||||||
@@ -370,7 +273,6 @@ async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
|||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshingCloud = false;
|
isRefreshingCloud = false;
|
||||||
void computerAgent?.sync().catch((error) => console.error('[ComputerAgent] sync failed:', error?.message || error));
|
|
||||||
void desktopNotifications?.sync().catch((error) => console.error('[DesktopNotifications] sync failed:', error?.message || error));
|
void desktopNotifications?.sync().catch((error) => console.error('[DesktopNotifications] sync failed:', error?.message || error));
|
||||||
syncDesktopState();
|
syncDesktopState();
|
||||||
}
|
}
|
||||||
@@ -852,16 +754,10 @@ function registerIpcHandlers() {
|
|||||||
await desktopWindow.showLauncher();
|
await desktopWindow.showLauncher();
|
||||||
return getDesktopState();
|
return getDesktopState();
|
||||||
});
|
});
|
||||||
ipcMain.handle('cloudcli-desktop:show-computer-access', async () => {
|
|
||||||
await showComputerAccess();
|
|
||||||
return getDesktopState();
|
|
||||||
});
|
|
||||||
ipcMain.handle('cloudcli-desktop:update-computer-use', async (_event, settings) => updateComputerUse(settings));
|
|
||||||
ipcMain.handle('cloudcli-desktop:update-desktop-notifications', async (_event, settings) => {
|
ipcMain.handle('cloudcli-desktop:update-desktop-notifications', async (_event, settings) => {
|
||||||
await desktopNotifications?.saveSettings(settings);
|
await desktopNotifications?.saveSettings(settings);
|
||||||
return getDesktopState();
|
return getDesktopState();
|
||||||
});
|
});
|
||||||
ipcMain.handle('cloudcli-desktop:request-computer-use-permission', async (_event, permission) => requestComputerUsePermission(permission));
|
|
||||||
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
|
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
|
||||||
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
|
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
|
||||||
ipcMain.handle('cloudcli-desktop:close-settings-window', async () => {
|
ipcMain.handle('cloudcli-desktop:close-settings-window', async () => {
|
||||||
@@ -899,7 +795,6 @@ function registerAppEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
computerAgent?.stop();
|
|
||||||
desktopNotifications?.stop();
|
desktopNotifications?.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -951,7 +846,6 @@ async function createDesktopWindow() {
|
|||||||
openCloudDashboard,
|
openCloudDashboard,
|
||||||
refreshCloudEnvironments: () => refreshCloudEnvironments({ showErrors: true }),
|
refreshCloudEnvironments: () => refreshCloudEnvironments({ showErrors: true }),
|
||||||
setActiveTarget,
|
setActiveTarget,
|
||||||
showComputerAccess,
|
|
||||||
showEnvironmentPicker,
|
showEnvironmentPicker,
|
||||||
showError,
|
showError,
|
||||||
startEnvironment,
|
startEnvironment,
|
||||||
@@ -1017,15 +911,6 @@ async function bootstrap() {
|
|||||||
callbackUrl: CALLBACK_URL,
|
callbackUrl: CALLBACK_URL,
|
||||||
onChange: syncDesktopState,
|
onChange: syncDesktopState,
|
||||||
});
|
});
|
||||||
computerAgent = new ComputerAgentController({
|
|
||||||
appRoot: getAppRoot(),
|
|
||||||
settingsPath: getComputerUseSettingsPath(),
|
|
||||||
isPackaged: app.isPackaged,
|
|
||||||
getRunningEnvironmentUrls,
|
|
||||||
getApiKey: () => cloud.getAccount()?.apiKey || '',
|
|
||||||
promptConsent: promptComputerUseConsent,
|
|
||||||
onChange: syncDesktopState,
|
|
||||||
});
|
|
||||||
desktopNotifications = new DesktopNotificationsController({
|
desktopNotifications = new DesktopNotificationsController({
|
||||||
settingsPath: getDesktopNotificationsSettingsPath(),
|
settingsPath: getDesktopNotificationsSettingsPath(),
|
||||||
appVersion: app.getVersion(),
|
appVersion: app.getVersion(),
|
||||||
@@ -1042,7 +927,6 @@ async function bootstrap() {
|
|||||||
|
|
||||||
await localServer.loadDesktopSettings();
|
await localServer.loadDesktopSettings();
|
||||||
await cloud.loadCloudAccount();
|
await cloud.loadCloudAccount();
|
||||||
await computerAgent.loadSettings();
|
|
||||||
await desktopNotifications.loadSettings();
|
await desktopNotifications.loadSettings();
|
||||||
|
|
||||||
registerProtocolHandler();
|
registerProtocolHandler();
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ if (window.location.protocol === 'file:') {
|
|||||||
refreshActiveTab: () => ipcRenderer.invoke('cloudcli-desktop:reload-active-tab'),
|
refreshActiveTab: () => ipcRenderer.invoke('cloudcli-desktop:reload-active-tab'),
|
||||||
showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'),
|
showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'),
|
||||||
showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'),
|
showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'),
|
||||||
showComputerAccess: () => ipcRenderer.invoke('cloudcli-desktop:show-computer-access'),
|
|
||||||
showLocalSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-local-settings'),
|
showLocalSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-local-settings'),
|
||||||
updateComputerUse: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-computer-use', settings),
|
|
||||||
requestComputerUsePermission: (permission) => ipcRenderer.invoke('cloudcli-desktop:request-computer-use-permission', permission),
|
|
||||||
showDesktopSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-settings'),
|
showDesktopSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-settings'),
|
||||||
closeSettingsWindow: () => ipcRenderer.invoke('cloudcli-desktop:close-settings-window'),
|
closeSettingsWindow: () => ipcRenderer.invoke('cloudcli-desktop:close-settings-window'),
|
||||||
showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
|
showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
|
||||||
|
|||||||
10
index.html
10
index.html
@@ -4,9 +4,17 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<title>CloudCLI UI</title>
|
<title>CloudCLI UI</title>
|
||||||
|
|
||||||
|
<!-- Fonts: Encode Sans (UI) + Merriweather (chat) -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Encode+Sans:wght@400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400;1,700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.35.0",
|
"version": "1.35.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.35.0",
|
"version": "1.35.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.35.0",
|
"version": "1.35.1",
|
||||||
"productName": "CloudCLI",
|
"productName": "CloudCLI",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -29,13 +29,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently --kill-others \"npm run server:dev\" \"npm run client\"",
|
"dev": "concurrently --kill-others \"npm run server:dev\" \"npm run client\"",
|
||||||
"server": "node dist-server/server/index.js",
|
"server": "node dist-server/server/index.js",
|
||||||
"preserver:dev": "npm run build:semantics",
|
|
||||||
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
|
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
|
||||||
"preserver:dev-watch": "npm run build:semantics",
|
|
||||||
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
|
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
|
||||||
"client": "vite",
|
"client": "vite",
|
||||||
"desktop": "electron electron/main.js",
|
"desktop": "electron electron/main.js",
|
||||||
"predesktop:dev": "npm run build:semantics",
|
|
||||||
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
|
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
|
||||||
"desktop:stage": "node scripts/release/prepare-desktop-app.js",
|
"desktop:stage": "node scripts/release/prepare-desktop-app.js",
|
||||||
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
|
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
|
||||||
@@ -43,12 +40,10 @@
|
|||||||
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis",
|
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis",
|
||||||
"server:bundle": "npm run build && node scripts/release/build-server-bundle.js",
|
"server:bundle": "npm run build && node scripts/release/build-server-bundle.js",
|
||||||
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
|
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
|
||||||
"build": "npm run build:semantics && npm run build:client && npm run build:server",
|
"build": "npm run build:client && npm run build:server",
|
||||||
"build:client": "vite build",
|
"build:client": "vite build",
|
||||||
"build:semantics": "node scripts/build-computer-semantics.mjs",
|
|
||||||
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
||||||
"build:server": "tsc -p server/tsconfig.json && tsc-alias -p server/tsconfig.json",
|
"build:server": "tsc -p server/tsconfig.json && tsc-alias -p server/tsconfig.json",
|
||||||
"postbuild:server": "node scripts/copy-computer-semantics-bin.mjs",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p server/tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p server/tsconfig.json",
|
||||||
"lint": "eslint src/ server/",
|
"lint": "eslint src/ server/",
|
||||||
@@ -56,7 +51,7 @@
|
|||||||
"start": "npm run build && npm run server",
|
"start": "npm run build && npm run server",
|
||||||
"release": "./release.sh",
|
"release": "./release.sh",
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build",
|
||||||
"postinstall": "node scripts/fix-node-pty.js && npm run build:semantics",
|
"postinstall": "node scripts/fix-node-pty.js",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"update:platform": "./update-platform.sh"
|
"update:platform": "./update-platform.sh"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const rootDir = path.resolve(__dirname, '..');
|
|
||||||
const platform = process.env.CLOUDCLI_SEMANTICS_PLATFORM || process.platform;
|
|
||||||
const arch = process.env.CLOUDCLI_SEMANTICS_ARCH || process.arch;
|
|
||||||
const platformArch = `${platform}-${arch}`;
|
|
||||||
const semanticsRoot = path.join(rootDir, 'server', 'modules', 'computer-use', 'semantics');
|
|
||||||
const outDir = path.join(semanticsRoot, 'bin', platformArch);
|
|
||||||
const requireBuild = process.env.CLOUDCLI_SEMANTICS_BUILD_REQUIRED === '1';
|
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(command, args, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
shell: process.platform === 'win32',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
child.once('error', reject);
|
|
||||||
child.once('exit', (code) => {
|
|
||||||
if (code === 0) resolve();
|
|
||||||
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function commandExists(command) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const child = spawn(command, ['--version'], {
|
|
||||||
stdio: 'ignore',
|
|
||||||
shell: process.platform === 'win32',
|
|
||||||
});
|
|
||||||
child.once('error', () => resolve(false));
|
|
||||||
child.once('exit', (code) => resolve(code === 0));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathExists(filePath) {
|
|
||||||
try {
|
|
||||||
await fs.access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isUpToDate(output, inputs) {
|
|
||||||
if (!(await pathExists(output))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputStat = await fs.stat(output);
|
|
||||||
for (const input of inputs) {
|
|
||||||
const inputStat = await fs.stat(input);
|
|
||||||
if (inputStat.mtimeMs > outputStat.mtimeMs) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureCommand(command, helpText) {
|
|
||||||
if (await commandExists(command)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = `${command} was not found. ${helpText}`;
|
|
||||||
if (requireBuild) {
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
console.log(`Skipping semantic helper build: ${message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
const source = path.join(semanticsRoot, 'helpers', 'macos', 'CloudCLISemantics.swift');
|
|
||||||
const output = path.join(outDir, 'CloudCLISemantics');
|
|
||||||
|
|
||||||
if (!(await ensureCommand('swiftc', 'Install Xcode Command Line Tools to compile the macOS helper.'))) {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
if (await isUpToDate(output, [source])) {
|
|
||||||
console.log(`Semantic helper is up to date: ${path.relative(rootDir, output)}`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.mkdir(outDir, { recursive: true });
|
|
||||||
await run('swiftc', [
|
|
||||||
source,
|
|
||||||
'-o',
|
|
||||||
output,
|
|
||||||
'-framework',
|
|
||||||
'AppKit',
|
|
||||||
'-framework',
|
|
||||||
'ApplicationServices',
|
|
||||||
]);
|
|
||||||
await fs.chmod(output, 0o755);
|
|
||||||
console.log(`Built ${path.relative(rootDir, output)}`);
|
|
||||||
} else if (platform === 'win32') {
|
|
||||||
const project = path.join(semanticsRoot, 'helpers', 'windows', 'CloudCLISemantics.csproj');
|
|
||||||
const source = path.join(semanticsRoot, 'helpers', 'windows', 'Program.cs');
|
|
||||||
const output = path.join(outDir, 'CloudCLISemantics.exe');
|
|
||||||
|
|
||||||
if (!(await ensureCommand('dotnet', '.NET SDK is required to compile the Windows helper.'))) {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
if (await isUpToDate(output, [project, source])) {
|
|
||||||
console.log(`Semantic helper is up to date: ${path.relative(rootDir, output)}`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.mkdir(outDir, { recursive: true });
|
|
||||||
await run('dotnet', [
|
|
||||||
'publish',
|
|
||||||
project,
|
|
||||||
'-c',
|
|
||||||
'Release',
|
|
||||||
'-r',
|
|
||||||
arch === 'arm64' ? 'win-arm64' : 'win-x64',
|
|
||||||
'--self-contained',
|
|
||||||
'false',
|
|
||||||
'-p:PublishSingleFile=true',
|
|
||||||
'-o',
|
|
||||||
outDir,
|
|
||||||
]);
|
|
||||||
console.log(`Built ${path.relative(rootDir, output)}`);
|
|
||||||
} else {
|
|
||||||
console.log(`Semantic helper build is not supported for ${platform}-${arch}.`);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const rootDir = path.resolve(__dirname, '..');
|
|
||||||
const sourceDir = path.join(rootDir, 'server', 'modules', 'computer-use', 'semantics', 'bin');
|
|
||||||
const targetDir = path.join(rootDir, 'dist-server', 'server', 'modules', 'computer-use', 'semantics', 'bin');
|
|
||||||
|
|
||||||
async function pathExists(filePath) {
|
|
||||||
try {
|
|
||||||
await fs.access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await pathExists(sourceDir)) {
|
|
||||||
await fs.mkdir(path.dirname(targetDir), { recursive: true });
|
|
||||||
await fs.cp(sourceDir, targetDir, { recursive: true });
|
|
||||||
console.log(`Copied Computer Use semantic helpers to ${path.relative(rootDir, targetDir)}`);
|
|
||||||
}
|
|
||||||
@@ -113,12 +113,6 @@ await copyRequired('electron');
|
|||||||
await copyRequired('dist');
|
await copyRequired('dist');
|
||||||
await copyRequired('public');
|
await copyRequired('public');
|
||||||
|
|
||||||
// The desktop app still ships the standalone Computer Use desktop agent, but
|
|
||||||
// not the full local server. Local CloudCLI is downloaded on demand.
|
|
||||||
await copyRequired('dist-server/server/computer-use-agent.js');
|
|
||||||
await copyIfExists('dist-server/server/computer-use-agent.js.map');
|
|
||||||
await copyRequired('dist-server/server/modules/computer-use');
|
|
||||||
|
|
||||||
const copiedRuntimeDependencies = [];
|
const copiedRuntimeDependencies = [];
|
||||||
if (await copyNodeModule('ws')) {
|
if (await copyNodeModule('ws')) {
|
||||||
copiedRuntimeDependencies.push('ws');
|
copiedRuntimeDependencies.push('ws');
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* CloudCLI Computer Use — Desktop Agent.
|
|
||||||
*
|
|
||||||
* Standalone executor for the cloud relay. The Electron desktop app spawns this
|
|
||||||
* process (via ELECTRON_RUN_AS_NODE) whenever Computer Use is enabled and the
|
|
||||||
* user has running cloud environments. It opens an outbound websocket to each
|
|
||||||
* environment's `/desktop-agent` endpoint and executes the `computer_*` actions
|
|
||||||
* the hosted server relays, returning a fresh screenshot each time.
|
|
||||||
*
|
|
||||||
* It is fully self-contained: it reuses the shared nut-js executor module and
|
|
||||||
* does NOT depend on the local CloudCLI server. Consent is enforced here (the
|
|
||||||
* controlled machine is the authority): in `ask` mode the agent asks the parent
|
|
||||||
* Electron process for a per-session decision before the first action runs.
|
|
||||||
*/
|
|
||||||
import readline from 'node:readline';
|
|
||||||
|
|
||||||
import { WebSocket } from 'ws';
|
|
||||||
|
|
||||||
import {
|
|
||||||
getRuntimeReadiness,
|
|
||||||
type Point,
|
|
||||||
type ClickButton,
|
|
||||||
type ScrollDirection,
|
|
||||||
} from './modules/computer-use/computer-executor.js';
|
|
||||||
import { runRawComputerAction } from './modules/computer-use/actions/raw-action-dispatcher.js';
|
|
||||||
import type { RawActionTarget, RawComputerAction } from './modules/computer-use/actions/raw-action-types.js';
|
|
||||||
import { computerSemanticsService } from './modules/computer-use/computer-semantics.service.js';
|
|
||||||
|
|
||||||
type ConsentMode = 'ask' | 'auto';
|
|
||||||
|
|
||||||
type RelayMessage = {
|
|
||||||
kind?: string;
|
|
||||||
type?: string;
|
|
||||||
id?: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const IPC_PREFIX = '@@CUAGENT@@';
|
|
||||||
const RECONNECT_BASE_MS = 2000;
|
|
||||||
const RECONNECT_MAX_MS = 30_000;
|
|
||||||
|
|
||||||
const consentMode: ConsentMode = process.env.CLOUDCLI_COMPUTER_USE_CONSENT_MODE === 'auto' ? 'auto' : 'ask';
|
|
||||||
const agentLabel = process.env.CLOUDCLI_DESKTOP_AGENT_LABEL || 'cloudcli-desktop';
|
|
||||||
const desktopAgentApiKey = process.env.CLOUDCLI_DESKTOP_AGENT_API_KEY || '';
|
|
||||||
|
|
||||||
function parseTargets(): string[] {
|
|
||||||
const raw =
|
|
||||||
process.env.CLOUDCLI_DESKTOP_AGENT_URLS ||
|
|
||||||
process.env.CLOUDCLI_DESKTOP_AGENT_URL ||
|
|
||||||
'';
|
|
||||||
return raw
|
|
||||||
.split(',')
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Parent (Electron) IPC over stdout/stdin -------------------------------
|
|
||||||
|
|
||||||
function emitToParent(message: Record<string, unknown>): void {
|
|
||||||
process.stdout.write(`${IPC_PREFIX} ${JSON.stringify(message)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-session consent decisions, and resolvers awaiting a parent reply. */
|
|
||||||
const sessionConsent = new Map<string, 'granted' | 'denied'>();
|
|
||||||
const pendingConsent = new Map<string, Array<(allow: boolean) => void>>();
|
|
||||||
|
|
||||||
const stdinReader = readline.createInterface({ input: process.stdin });
|
|
||||||
stdinReader.on('line', (line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed.startsWith(IPC_PREFIX)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(trimmed.slice(IPC_PREFIX.length).trim()) as Record<string, unknown>;
|
|
||||||
if (payload.type === 'consent-response' && typeof payload.sessionId === 'string') {
|
|
||||||
const allow = payload.allow === true;
|
|
||||||
sessionConsent.set(payload.sessionId, allow ? 'granted' : 'denied');
|
|
||||||
const waiters = pendingConsent.get(payload.sessionId) || [];
|
|
||||||
pendingConsent.delete(payload.sessionId);
|
|
||||||
for (const resolve of waiters) {
|
|
||||||
resolve(allow);
|
|
||||||
}
|
|
||||||
} else if (payload.type === 'revoke-session' && typeof payload.sessionId === 'string') {
|
|
||||||
sessionConsent.delete(payload.sessionId);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore malformed control lines
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function ensureConsent(sessionId: string): Promise<boolean> {
|
|
||||||
if (consentMode === 'auto') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const existing = sessionConsent.get(sessionId);
|
|
||||||
if (existing === 'granted') return true;
|
|
||||||
if (existing === 'denied') return false;
|
|
||||||
|
|
||||||
// Ask the parent (Electron) to prompt the user, and wait for the decision.
|
|
||||||
return new Promise<boolean>((resolve) => {
|
|
||||||
const waiters = pendingConsent.get(sessionId) || [];
|
|
||||||
waiters.push(resolve);
|
|
||||||
pendingConsent.set(sessionId, waiters);
|
|
||||||
emitToParent({ type: 'consent-request', sessionId });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Action execution ------------------------------------------------------
|
|
||||||
|
|
||||||
function asPoint(value: unknown): Point | undefined {
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
const point = value as Record<string, unknown>;
|
|
||||||
if (typeof point.x === 'number' && typeof point.y === 'number') {
|
|
||||||
return { x: point.x, y: point.y };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rawActionFromRelay(type: string, params: Record<string, unknown>): RawComputerAction {
|
|
||||||
const point = asPoint(params.point);
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'screenshot':
|
|
||||||
return { type: 'screenshot' };
|
|
||||||
case 'cursor_position':
|
|
||||||
return { type: 'cursor_position' };
|
|
||||||
case 'mouse_move':
|
|
||||||
if (!point) {
|
|
||||||
throw new Error('mouse_move requires a valid point.');
|
|
||||||
}
|
|
||||||
return { type: 'mouse_move', point };
|
|
||||||
case 'click':
|
|
||||||
return {
|
|
||||||
type: 'click',
|
|
||||||
button: (params.button as ClickButton) || 'left',
|
|
||||||
point,
|
|
||||||
double: params.double === true,
|
|
||||||
};
|
|
||||||
case 'drag': {
|
|
||||||
const from = asPoint(params.from);
|
|
||||||
const to = asPoint(params.to);
|
|
||||||
if (!from || !to) {
|
|
||||||
throw new Error('drag requires valid from and to points.');
|
|
||||||
}
|
|
||||||
return { type: 'drag', from, to, button: (params.button as ClickButton) || 'left' };
|
|
||||||
}
|
|
||||||
case 'type':
|
|
||||||
return { type: 'type', text: String(params.text ?? '') };
|
|
||||||
case 'key':
|
|
||||||
return { type: 'key', key: String(params.key ?? '') };
|
|
||||||
case 'scroll':
|
|
||||||
return {
|
|
||||||
type: 'scroll',
|
|
||||||
direction: (params.direction as ScrollDirection) || 'down',
|
|
||||||
amount: typeof params.amount === 'number' ? params.amount : 3,
|
|
||||||
point,
|
|
||||||
};
|
|
||||||
case 'wait':
|
|
||||||
return { type: 'wait', ms: typeof params.ms === 'number' ? params.ms : undefined };
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported computer action: ${type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAction(type: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
||||||
if (type === 'semantic_tool') {
|
|
||||||
const toolName = typeof params.toolName === 'string' ? params.toolName : '';
|
|
||||||
const args = params.arguments && typeof params.arguments === 'object'
|
|
||||||
? params.arguments as Record<string, unknown>
|
|
||||||
: {};
|
|
||||||
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : 'default';
|
|
||||||
if (!toolName) {
|
|
||||||
throw new Error('semantic_tool requires toolName.');
|
|
||||||
}
|
|
||||||
return await computerSemanticsService.callTool(toolName, { ...args, sessionId }) as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const readiness = getRuntimeReadiness();
|
|
||||||
if (!readiness.nutInstalled || !readiness.screenshotInstalled) {
|
|
||||||
throw new Error('Computer Use runtime is not installed on the desktop agent.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const target: RawActionTarget = {
|
|
||||||
displaySize: (params.displaySize as RawActionTarget['displaySize']) ?? null,
|
|
||||||
};
|
|
||||||
return await runRawComputerAction(rawActionFromRelay(type, params), target) as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Relay connection ------------------------------------------------------
|
|
||||||
|
|
||||||
function connect(url: string): void {
|
|
||||||
let reconnectMs = RECONNECT_BASE_MS;
|
|
||||||
let socket: WebSocket | null = null;
|
|
||||||
|
|
||||||
const open = () => {
|
|
||||||
socket = new WebSocket(url, {
|
|
||||||
headers: desktopAgentApiKey ? { 'X-API-Key': desktopAgentApiKey } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('open', () => {
|
|
||||||
reconnectMs = RECONNECT_BASE_MS;
|
|
||||||
emitToParent({ type: 'connected', url });
|
|
||||||
socket?.send(JSON.stringify({ kind: 'register', label: agentLabel, consentMode }));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('message', async (raw) => {
|
|
||||||
let message: RelayMessage;
|
|
||||||
try {
|
|
||||||
message = JSON.parse(String(raw)) as RelayMessage;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const kind = message.kind || message.type;
|
|
||||||
if (kind !== 'computer_relay' || typeof message.id !== 'string') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = message.id;
|
|
||||||
const type = String(message.type || (message.params?.type as string) || '');
|
|
||||||
const params = message.params || {};
|
|
||||||
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : 'default';
|
|
||||||
|
|
||||||
if (type === 'stop_session') {
|
|
||||||
sessionConsent.delete(sessionId);
|
|
||||||
socket?.send(JSON.stringify({ kind: 'computer_relay_result', id, result: { ok: true } }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const allowed = await ensureConsent(sessionId);
|
|
||||||
if (!allowed) {
|
|
||||||
socket?.send(JSON.stringify({ kind: 'computer_relay_result', id, error: 'The user denied desktop control for this session.' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await runAction(type, params);
|
|
||||||
socket?.send(JSON.stringify({ kind: 'computer_relay_result', id, result }));
|
|
||||||
} catch (error) {
|
|
||||||
socket?.send(JSON.stringify({
|
|
||||||
kind: 'computer_relay_result',
|
|
||||||
id,
|
|
||||||
error: error instanceof Error ? error.message : 'Desktop agent action failed.',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const scheduleReconnect = (code?: number, reason?: Buffer) => {
|
|
||||||
const reasonText = reason?.toString() || '';
|
|
||||||
emitToParent({ type: 'disconnected', url, code, reason: reasonText });
|
|
||||||
if (code === 1008 && /computer use.*disabled/i.test(reasonText)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(open, reconnectMs);
|
|
||||||
reconnectMs = Math.min(reconnectMs * 2, RECONNECT_MAX_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('close', scheduleReconnect);
|
|
||||||
socket.on('error', () => {
|
|
||||||
try { socket?.close(); } catch { /* noop */ }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
|
|
||||||
function main(): void {
|
|
||||||
const targets = parseTargets();
|
|
||||||
if (targets.length === 0) {
|
|
||||||
emitToParent({ type: 'error', message: 'No desktop-agent target URLs provided.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
emitToParent({ type: 'starting', targets, consentMode });
|
|
||||||
for (const url of targets) {
|
|
||||||
connect(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,574 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import './load-env.js';
|
|
||||||
|
|
||||||
type JsonRpcRequest = {
|
|
||||||
jsonrpc: '2.0';
|
|
||||||
id?: string | number | null;
|
|
||||||
method: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToolDefinition = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
inputSchema: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const readString = (value: unknown, name: string): string => {
|
|
||||||
if (typeof value !== 'string' || value.trim() === '') {
|
|
||||||
throw new Error(`${name} is required.`);
|
|
||||||
}
|
|
||||||
return value.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
const readOptionalString = (value: unknown): string | undefined =>
|
|
||||||
typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
||||||
|
|
||||||
const readNumber = (value: unknown): number | undefined =>
|
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
||||||
|
|
||||||
const readMouseButton = (value: unknown): 'left' | 'right' | 'middle' =>
|
|
||||||
value === 'right' || value === 'middle' ? value : 'left';
|
|
||||||
|
|
||||||
const apiUrl = (process.env.CLOUDCLI_COMPUTER_USE_API_URL || 'http://127.0.0.1:3001/api/computer-use-mcp').replace(/\/$/, '');
|
|
||||||
const apiToken = process.env.CLOUDCLI_COMPUTER_USE_MCP_TOKEN || '';
|
|
||||||
|
|
||||||
const computerUseInstructions = `
|
|
||||||
CloudCLI Computer Use lets you operate the user's real desktop through guarded sessions. Use it deliberately: observe first, act second, then verify.
|
|
||||||
|
|
||||||
Recommended app workflow:
|
|
||||||
1. If you do not know the target app name, call computer_list_apps.
|
|
||||||
2. Call computer_get_app_state for the target app before app-scoped actions. It returns a screenshot, accessibility elements, and a stateId.
|
|
||||||
3. Prefer semantic element actions using stateId + element_index from the latest computer_get_app_state result. Do not guess element indexes or reuse them after large UI changes without refreshing state.
|
|
||||||
4. Use x/y coordinates from the returned screenshot only when no suitable element_index is available.
|
|
||||||
5. After every action, inspect the returned screenshot/state before deciding the next action.
|
|
||||||
|
|
||||||
Use app-scoped tools when the target app is known: computer_list_apps, computer_get_app_state, computer_click_element, computer_perform_secondary_action, computer_set_value, computer_type_text, computer_press_key, computer_scroll_element, and computer_app_drag.
|
|
||||||
|
|
||||||
Use raw desktop tools only when you need full-screen coordinate control, cursor position, or current-focus input: computer_screenshot, computer_cursor_position, computer_mouse_move, computer_click, computer_drag, computer_type, computer_key, computer_scroll, computer_wait, and computer_close_session. Raw coordinates are screenshot pixels, so call computer_screenshot first when you need a coordinate frame.
|
|
||||||
|
|
||||||
Most tools can use or create the active agent session automatically when sessionId is omitted. In local mode, input actions require the user to grant control in the Computer tab before they work. In cloud mode, approval is handled by the linked CloudCLI desktop app.
|
|
||||||
|
|
||||||
If a tool reports missing permission, denied control, or no available desktop session, stop retrying and ask the user to fix access. For local mode, ask them to open CloudCLI Desktop, go to the Computer tab, enable Computer Use, grant the requested OS permissions, and allow the session. On macOS this usually means Accessibility and Screen Recording. For cloud mode, ask them to keep the linked CloudCLI Desktop app running and approve the cloud agent's Computer Use request there.
|
|
||||||
|
|
||||||
Ask before sending, deleting, purchasing, approving, uploading, publishing, changing account settings, or making other externally visible or destructive changes. Do not inspect unrelated private content unless the user explicitly asked for that task.
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
async function callComputerUseApi(toolName: string, input: Record<string, unknown>) {
|
|
||||||
if (!apiToken) {
|
|
||||||
throw new Error('CLOUDCLI_COMPUTER_USE_MCP_TOKEN is not configured.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${apiUrl}/tools/${encodeURIComponent(toolName)}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
|
|
||||||
if (!response.ok || data.success === false) {
|
|
||||||
throw new Error(data.error || `Computer Use API request failed (${response.status})`);
|
|
||||||
}
|
|
||||||
return data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pulls the most recent screenshot data URL out of an API result, if present. */
|
|
||||||
function findScreenshot(value: unknown): string | null {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
if (typeof record.screenshotDataUrl === 'string') {
|
|
||||||
return record.screenshotDataUrl;
|
|
||||||
}
|
|
||||||
if (record.session && typeof record.session === 'object') {
|
|
||||||
const session = record.session as Record<string, unknown>;
|
|
||||||
if (typeof session.screenshotDataUrl === 'string') {
|
|
||||||
return session.screenshotDataUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Removes the large data URL from JSON so the text block stays small. */
|
|
||||||
function stripScreenshot(value: unknown): unknown {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map(stripScreenshot);
|
|
||||||
}
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
||||||
if (key === 'screenshotDataUrl' && typeof val === 'string') {
|
|
||||||
out.screenshot = '[returned as image]';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out[key] = stripScreenshot(val);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds an MCP tool result. Screenshots are returned as an `image` content block so
|
|
||||||
* vision-capable models actually see the desktop — a JSON data-URL string would not work.
|
|
||||||
*/
|
|
||||||
function toolResult(value: unknown) {
|
|
||||||
const content: Array<Record<string, unknown>> = [
|
|
||||||
{ type: 'text', text: JSON.stringify(stripScreenshot(value), null, 2) },
|
|
||||||
];
|
|
||||||
|
|
||||||
const screenshot = findScreenshot(value);
|
|
||||||
const match = screenshot ? /^data:(image\/[a-z]+);base64,(.+)$/i.exec(screenshot) : null;
|
|
||||||
if (match) {
|
|
||||||
content.push({ type: 'image', data: match[2], mimeType: match[1] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { content };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionIdSchema = {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
sessionId: { type: 'string', description: 'Optional. Omit to use or create the active agent session automatically.' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const optionalSessionProperty = sessionIdSchema.properties.sessionId;
|
|
||||||
|
|
||||||
const withOptionalSession = (properties: Record<string, unknown> = {}) => ({
|
|
||||||
sessionId: optionalSessionProperty,
|
|
||||||
...properties,
|
|
||||||
});
|
|
||||||
|
|
||||||
const optionalSessionInput = (args: Record<string, unknown>, extra: Record<string, unknown> = {}) => ({
|
|
||||||
sessionId: readOptionalString(args.sessionId),
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stateIdProperty = {
|
|
||||||
type: 'string',
|
|
||||||
description: 'State id returned by the latest computer_get_app_state call for this app. Send it with element_index so the runtime can resolve the cached element.',
|
|
||||||
};
|
|
||||||
|
|
||||||
const elementIndexProperty = {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Element index from the latest computer_get_app_state result for this app. Use with stateId when possible.',
|
|
||||||
};
|
|
||||||
|
|
||||||
const tools: ToolDefinition[] = [
|
|
||||||
{
|
|
||||||
name: 'computer_list_apps',
|
|
||||||
description: 'Discover app names, bundle identifiers, process names, and window titles that can be used as the app target for app-scoped Computer Use tools. Call this first when you are unsure which app string to pass to computer_get_app_state.',
|
|
||||||
inputSchema: { type: 'object', properties: withOptionalSession() },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_get_app_state',
|
|
||||||
description: 'Inspect a target app and return its current screenshot, accessibility elements, and stateId. Call this before element-targeted actions, after navigation, and whenever the UI may have changed enough that old element indexes could be stale.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: withOptionalSession({
|
|
||||||
app: { type: 'string', description: 'App name, process name, bundle identifier, or window title from computer_list_apps or the user request.' },
|
|
||||||
}),
|
|
||||||
required: ['app'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_click_element',
|
|
||||||
description: 'Click a target inside an app. Prefer stateId + element_index from computer_get_app_state; use x/y screenshot coordinates only when the target is not represented in the accessibility elements.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: withOptionalSession({
|
|
||||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
|
||||||
stateId: stateIdProperty,
|
|
||||||
element_index: elementIndexProperty,
|
|
||||||
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
|
||||||
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
|
||||||
click_count: { type: 'integer', description: 'Number of clicks, usually 1. Defaults to 1 and is capped by the runtime.' },
|
|
||||||
mouse_button: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Button for the click; omitted means left.' },
|
|
||||||
}),
|
|
||||||
required: ['app'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_perform_secondary_action',
|
|
||||||
description: 'Open the secondary action for a target inside an app, typically a context menu. Prefer stateId + element_index; if native secondary actions are unavailable, the runtime falls back to a right-click at the resolved point.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: withOptionalSession({
|
|
||||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
|
||||||
stateId: stateIdProperty,
|
|
||||||
element_index: elementIndexProperty,
|
|
||||||
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
|
||||||
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
|
||||||
}),
|
|
||||||
required: ['app'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_set_value',
|
|
||||||
description: 'Set the value of a specific editable element in an app. Prefer stateId + element_index for a settable accessibility element; coordinate fallback focuses the resolved point and replaces the current value, so do not call this unless the target is resolved.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: withOptionalSession({
|
|
||||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
|
||||||
stateId: stateIdProperty,
|
|
||||||
element_index: elementIndexProperty,
|
|
||||||
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
|
||||||
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
|
||||||
value: { type: 'string', description: 'Exact value to put into the target element.' },
|
|
||||||
}),
|
|
||||||
required: ['app', 'value'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_type_text',
|
|
||||||
description: 'Type literal text into the target app using keyboard input. Use after you have focused the intended field with computer_click_element or verified the correct focus in computer_get_app_state.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: withOptionalSession({
|
|
||||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
|
||||||
text: { type: 'string', description: 'Text to enter exactly as provided.' },
|
|
||||||
}),
|
|
||||||
required: ['app', 'text'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_press_key',
|
|
||||||
description: 'Press a key or key combination in the target app. Use for navigation, shortcuts, and confirmation keys after verifying the intended app/focus.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: withOptionalSession({
|
|
||||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
|
||||||
key: { type: 'string', description: 'Key or chord, using names such as Return, Escape, Tab, ctrl+s, cmd+a, Up, or Page_Down.' },
|
|
||||||
}),
|
|
||||||
required: ['app', 'key'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_scroll_element',
|
|
||||||
description: 'Scroll a target area inside an app. Prefer stateId + element_index for scrollable elements; use x/y screenshot coordinates only when the scroll target is visible but not represented as an element.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: withOptionalSession({
|
|
||||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
|
||||||
stateId: stateIdProperty,
|
|
||||||
element_index: elementIndexProperty,
|
|
||||||
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
|
||||||
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
|
|
||||||
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Direction to scroll the target.' },
|
|
||||||
pages: { type: 'number', description: 'How far to scroll, measured in page units. Fractional values are allowed; default is 1.' },
|
|
||||||
}),
|
|
||||||
required: ['app', 'direction'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_app_drag',
|
|
||||||
description: 'Drag inside a target app from one screenshot coordinate to another. Use for sliders, selections, map/canvas gestures, or drag-and-drop when no semantic element action is available.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: withOptionalSession({
|
|
||||||
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
|
|
||||||
from_x: { type: 'number', description: 'Start X coordinate in screenshot pixels.' },
|
|
||||||
from_y: { type: 'number', description: 'Start Y coordinate in screenshot pixels.' },
|
|
||||||
to_x: { type: 'number', description: 'End X coordinate in screenshot pixels.' },
|
|
||||||
to_y: { type: 'number', description: 'End Y coordinate in screenshot pixels.' },
|
|
||||||
}),
|
|
||||||
required: ['app', 'from_x', 'from_y', 'to_x', 'to_y'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_screenshot',
|
|
||||||
description: 'Capture the full desktop screenshot and current display size. Use this before raw coordinate actions when an app-specific accessibility state is unavailable or the task spans multiple apps.',
|
|
||||||
inputSchema: sessionIdSchema,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_cursor_position',
|
|
||||||
description: 'Get the current mouse cursor position in desktop screenshot pixel coordinates. Useful after a raw action misses or when coordinating pointer-relative steps.',
|
|
||||||
inputSchema: sessionIdSchema,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_mouse_move',
|
|
||||||
description: 'Move the mouse cursor to an exact full-desktop screenshot coordinate. Call computer_screenshot first if you do not already have a current coordinate frame.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
sessionId: optionalSessionProperty,
|
|
||||||
x: { type: 'number', description: 'X coordinate in full-desktop screenshot pixels.' },
|
|
||||||
y: { type: 'number', description: 'Y coordinate in full-desktop screenshot pixels.' },
|
|
||||||
},
|
|
||||||
required: ['x', 'y'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_click',
|
|
||||||
description: 'Raw desktop click at the current cursor or at optional full-desktop screenshot coordinates. Prefer computer_click_element when the target app and element are known.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
sessionId: optionalSessionProperty,
|
|
||||||
x: { type: 'number', description: 'Optional X coordinate in full-desktop screenshot pixels.' },
|
|
||||||
y: { type: 'number', description: 'Optional Y coordinate in full-desktop screenshot pixels.' },
|
|
||||||
mouseButton: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Button for the click; omitted means left.' },
|
|
||||||
clickCount: { type: 'integer', description: 'How many times to click; omitted means 1.' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_drag',
|
|
||||||
description: 'Raw desktop drag from start coordinates to end coordinates in full-desktop screenshot pixels. Prefer computer_app_drag for app-scoped drags when the target app is known.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
sessionId: optionalSessionProperty,
|
|
||||||
startX: { type: 'number', description: 'Start X coordinate in full-desktop screenshot pixels.' },
|
|
||||||
startY: { type: 'number', description: 'Start Y coordinate in full-desktop screenshot pixels.' },
|
|
||||||
endX: { type: 'number', description: 'End X coordinate in full-desktop screenshot pixels.' },
|
|
||||||
endY: { type: 'number', description: 'End Y coordinate in full-desktop screenshot pixels.' },
|
|
||||||
mouseButton: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Button to hold during the drag; omitted means left.' },
|
|
||||||
},
|
|
||||||
required: ['startX', 'startY', 'endX', 'endY'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_type',
|
|
||||||
description: 'Type literal text at the current desktop focus. This is not app-scoped; use only after verifying the intended field is focused.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { sessionId: optionalSessionProperty, text: { type: 'string', description: 'Text to enter exactly as provided at current focus.' } },
|
|
||||||
required: ['text'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_key',
|
|
||||||
description: 'Press a key or key chord at the current desktop focus. This is not app-scoped; use computer_press_key when the target app is known.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { sessionId: optionalSessionProperty, key: { type: 'string', description: 'Key or chord, using names such as Return, Escape, Tab, ctrl+s, cmd+a, Up, or Page_Down.' } },
|
|
||||||
required: ['key'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_scroll',
|
|
||||||
description: 'Raw desktop scroll at the current cursor or optional full-desktop screenshot coordinates. Prefer computer_scroll_element when the target app/element is known.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
sessionId: optionalSessionProperty,
|
|
||||||
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Direction to scroll the desktop target.' },
|
|
||||||
amount: { type: 'number', description: 'Scroll amount in wheel/page-like units. Defaults are runtime-defined.' },
|
|
||||||
x: { type: 'number', description: 'Optional X coordinate in full-desktop screenshot pixels.' },
|
|
||||||
y: { type: 'number', description: 'Optional Y coordinate in full-desktop screenshot pixels.' },
|
|
||||||
},
|
|
||||||
required: ['direction'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_wait',
|
|
||||||
description: 'Wait briefly, up to 10000 ms, then return an updated desktop screenshot. Use after actions that trigger loading, animation, or delayed UI changes.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { sessionId: optionalSessionProperty, timeoutMs: { type: 'number', description: 'Milliseconds to wait. The runtime caps long waits.' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'computer_close_session',
|
|
||||||
description: 'Stop the active auto-created Computer Use session, or the specified session, and revoke agent input control for that session.',
|
|
||||||
inputSchema: sessionIdSchema,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function callTool(name: string, args: Record<string, unknown>) {
|
|
||||||
switch (name) {
|
|
||||||
case 'computer_app_drag':
|
|
||||||
case 'computer_click_element':
|
|
||||||
case 'computer_get_app_state':
|
|
||||||
case 'computer_list_apps':
|
|
||||||
case 'computer_perform_secondary_action':
|
|
||||||
case 'computer_press_key':
|
|
||||||
case 'computer_scroll_element':
|
|
||||||
case 'computer_set_value':
|
|
||||||
case 'computer_type_text':
|
|
||||||
return toolResult(await callComputerUseApi(name, args));
|
|
||||||
case 'computer_screenshot':
|
|
||||||
case 'computer_cursor_position':
|
|
||||||
case 'computer_close_session':
|
|
||||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args)));
|
|
||||||
case 'computer_mouse_move':
|
|
||||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
|
||||||
x: readNumber(args.x),
|
|
||||||
y: readNumber(args.y),
|
|
||||||
})));
|
|
||||||
case 'computer_click':
|
|
||||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
|
||||||
x: readNumber(args.x),
|
|
||||||
y: readNumber(args.y),
|
|
||||||
mouseButton: readMouseButton(args.mouseButton ?? args.mouse_button ?? args.button),
|
|
||||||
clickCount: readNumber(args.clickCount ?? args.click_count),
|
|
||||||
})));
|
|
||||||
case 'computer_drag':
|
|
||||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
|
||||||
startX: readNumber(args.startX),
|
|
||||||
startY: readNumber(args.startY),
|
|
||||||
endX: readNumber(args.endX),
|
|
||||||
endY: readNumber(args.endY),
|
|
||||||
mouseButton: readMouseButton(args.mouseButton ?? args.mouse_button ?? args.button),
|
|
||||||
})));
|
|
||||||
case 'computer_type':
|
|
||||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
|
||||||
text: readString(args.text, 'text'),
|
|
||||||
})));
|
|
||||||
case 'computer_key':
|
|
||||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
|
||||||
key: readString(args.key, 'key'),
|
|
||||||
})));
|
|
||||||
case 'computer_scroll':
|
|
||||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
|
||||||
direction: typeof args.direction === 'string' ? args.direction : 'up',
|
|
||||||
amount: readNumber(args.amount),
|
|
||||||
x: readNumber(args.x),
|
|
||||||
y: readNumber(args.y),
|
|
||||||
})));
|
|
||||||
case 'computer_wait':
|
|
||||||
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
|
|
||||||
timeoutMs: readNumber(args.timeoutMs),
|
|
||||||
})));
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMessage(message: JsonRpcRequest) {
|
|
||||||
if (message.method === 'initialize') {
|
|
||||||
return {
|
|
||||||
protocolVersion: '2024-11-05',
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
serverInfo: { name: 'cloudcli-computer-use', version: '1.0.0' },
|
|
||||||
instructions: computerUseInstructions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.method === 'tools/list') {
|
|
||||||
return { tools };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.method === 'tools/call') {
|
|
||||||
const params = message.params || {};
|
|
||||||
const name = readString(params.name, 'name');
|
|
||||||
const args = (params.arguments && typeof params.arguments === 'object'
|
|
||||||
? params.arguments
|
|
||||||
: {}) as Record<string, unknown>;
|
|
||||||
return callTool(name, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.method.startsWith('notifications/')) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unsupported method: ${message.method}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessageFraming = 'content-length' | 'line';
|
|
||||||
|
|
||||||
function writeMessage(message: Record<string, unknown>, framing: MessageFraming) {
|
|
||||||
const payload = JSON.stringify(message);
|
|
||||||
if (framing === 'line') {
|
|
||||||
process.stdout.write(`${payload}\n`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendResult(id: string | number | null | undefined, result: unknown, framing: MessageFraming) {
|
|
||||||
if (id === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeMessage({ jsonrpc: '2.0', id, result }, framing);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendError(id: string | number | null | undefined, error: unknown, framing: MessageFraming) {
|
|
||||||
if (id === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeMessage({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id,
|
|
||||||
error: {
|
|
||||||
code: -32000,
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
},
|
|
||||||
}, framing);
|
|
||||||
}
|
|
||||||
|
|
||||||
let buffer = Buffer.alloc(0);
|
|
||||||
|
|
||||||
function handleRawMessage(rawMessage: string, framing: MessageFraming) {
|
|
||||||
void (async () => {
|
|
||||||
let request: JsonRpcRequest | null = null;
|
|
||||||
try {
|
|
||||||
request = JSON.parse(rawMessage) as JsonRpcRequest;
|
|
||||||
const result = await handleMessage(request);
|
|
||||||
sendResult(request.id, result, framing);
|
|
||||||
} catch (error) {
|
|
||||||
sendError(request?.id ?? null, error, framing);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
function findHeaderEnd(input: Buffer): { index: number; length: number } | null {
|
|
||||||
const crlf = input.indexOf('\r\n\r\n');
|
|
||||||
if (crlf !== -1) {
|
|
||||||
return { index: crlf, length: 4 };
|
|
||||||
}
|
|
||||||
const lf = input.indexOf('\n\n');
|
|
||||||
if (lf !== -1) {
|
|
||||||
return { index: lf, length: 2 };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdin.on('data', (chunk) => {
|
|
||||||
buffer = Buffer.concat([buffer, chunk]);
|
|
||||||
while (true) {
|
|
||||||
const headerEnd = findHeaderEnd(buffer);
|
|
||||||
if (!headerEnd) {
|
|
||||||
if (/^Content-Length:/i.test(buffer.toString('utf8', 0, Math.min(buffer.length, 32)))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newline = buffer.indexOf('\n');
|
|
||||||
if (newline === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawLine = buffer.slice(0, newline).toString('utf8').trim();
|
|
||||||
buffer = buffer.slice(newline + 1);
|
|
||||||
if (!rawLine) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleRawMessage(rawLine, 'line');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = buffer.slice(0, headerEnd.index).toString('utf8');
|
|
||||||
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
|
|
||||||
if (!lengthMatch) {
|
|
||||||
buffer = buffer.slice(headerEnd.index + headerEnd.length);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const length = Number.parseInt(lengthMatch[1], 10);
|
|
||||||
const messageStart = headerEnd.index + headerEnd.length;
|
|
||||||
const messageEnd = messageStart + length;
|
|
||||||
if (buffer.length < messageEnd) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawMessage = buffer.slice(messageStart, messageEnd).toString('utf8');
|
|
||||||
buffer = buffer.slice(messageEnd);
|
|
||||||
handleRawMessage(rawMessage, 'content-length');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -66,9 +66,6 @@ import voiceRoutes from './voice-proxy.js';
|
|||||||
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
||||||
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
|
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
|
||||||
import { browserUseService } from './modules/browser-use/browser-use.service.js';
|
import { browserUseService } from './modules/browser-use/browser-use.service.js';
|
||||||
import computerUseRoutes from './modules/computer-use/computer-use.routes.js';
|
|
||||||
import computerUseMcpRoutes from './modules/computer-use/computer-use-mcp.routes.js';
|
|
||||||
import { computerUseService } from './modules/computer-use/computer-use.service.js';
|
|
||||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||||
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
||||||
import { configureWebPush } from './services/vapid-keys.js';
|
import { configureWebPush } from './services/vapid-keys.js';
|
||||||
@@ -223,12 +220,6 @@ app.use('/api/browser-use-mcp', browserUseMcpRoutes);
|
|||||||
// Browser API Routes (protected)
|
// Browser API Routes (protected)
|
||||||
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
|
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
|
||||||
|
|
||||||
// Computer Use MCP bridge API (local token protected)
|
|
||||||
app.use('/api/computer-use-mcp', computerUseMcpRoutes);
|
|
||||||
|
|
||||||
// Computer Use API Routes (protected)
|
|
||||||
app.use('/api/computer-use', authenticateToken, computerUseRoutes);
|
|
||||||
|
|
||||||
// Unified provider MCP routes (protected)
|
// Unified provider MCP routes (protected)
|
||||||
app.use('/api/providers', authenticateToken, providerRoutes);
|
app.use('/api/providers', authenticateToken, providerRoutes);
|
||||||
|
|
||||||
@@ -1785,11 +1776,6 @@ async function startServer() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Browser] Error stopping sessions during shutdown:', err?.message || err);
|
console.error('[Browser] Error stopping sessions during shutdown:', err?.message || err);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await computerUseService.stopAllSessions();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Computer Use] Error stopping sessions during shutdown:', err?.message || err);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await stopAllPlugins();
|
await stopAllPlugins();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
captureScreenshot,
|
|
||||||
executor,
|
|
||||||
type ExecutorTarget,
|
|
||||||
} from '@/modules/computer-use/computer-executor.js';
|
|
||||||
import type { RawActionResult, RawComputerAction, RawActionTarget } from '@/modules/computer-use/actions/raw-action-types.js';
|
|
||||||
|
|
||||||
const DEFAULT_WAIT_MS = 1000;
|
|
||||||
const MAX_WAIT_MS = 10_000;
|
|
||||||
|
|
||||||
function normalizeWaitMs(ms: number | undefined): number {
|
|
||||||
if (ms === undefined) {
|
|
||||||
return DEFAULT_WAIT_MS;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(ms)) {
|
|
||||||
throw new Error('Computer Use wait duration must be a finite number.');
|
|
||||||
}
|
|
||||||
return Math.trunc(Math.max(0, Math.min(ms, MAX_WAIT_MS)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function snapshot(target: RawActionTarget): Promise<RawActionResult> {
|
|
||||||
const { dataUrl, size } = await captureScreenshot();
|
|
||||||
return { screenshotDataUrl: dataUrl, displaySize: size || target.displaySize };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runRawComputerAction(
|
|
||||||
action: RawComputerAction,
|
|
||||||
target: RawActionTarget,
|
|
||||||
): Promise<RawActionResult> {
|
|
||||||
const executorTarget: ExecutorTarget = {
|
|
||||||
displaySize: target.displaySize,
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (action.type) {
|
|
||||||
case 'screenshot':
|
|
||||||
return snapshot(target);
|
|
||||||
case 'cursor_position': {
|
|
||||||
const position = await executor.cursorPosition(executorTarget);
|
|
||||||
return { ...(await snapshot(target)), position, cursor: position };
|
|
||||||
}
|
|
||||||
case 'mouse_move':
|
|
||||||
await executor.moveTo(executorTarget, action.point);
|
|
||||||
return { ...(await snapshot(target)), cursor: action.point };
|
|
||||||
case 'click':
|
|
||||||
await executor.click(executorTarget, action.button, action.point, action.double === true);
|
|
||||||
return { ...(await snapshot(target)), cursor: action.point ?? null };
|
|
||||||
case 'drag':
|
|
||||||
await executor.drag(executorTarget, action.from, action.to, action.button ?? 'left');
|
|
||||||
return { ...(await snapshot(target)), cursor: action.to };
|
|
||||||
case 'type':
|
|
||||||
await executor.type(action.text);
|
|
||||||
return snapshot(target);
|
|
||||||
case 'key':
|
|
||||||
await executor.pressChord(action.key);
|
|
||||||
return snapshot(target);
|
|
||||||
case 'scroll':
|
|
||||||
await executor.scroll(executorTarget, action.direction, action.amount ?? 3, action.point);
|
|
||||||
return { ...(await snapshot(target)), cursor: action.point ?? null };
|
|
||||||
case 'wait':
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, normalizeWaitMs(action.ms)));
|
|
||||||
return snapshot(target);
|
|
||||||
default: {
|
|
||||||
const exhaustive: never = action;
|
|
||||||
throw new Error(`Unsupported computer action: ${(exhaustive as { type?: string }).type || 'unknown'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type {
|
|
||||||
ClickButton,
|
|
||||||
DisplaySize,
|
|
||||||
Point,
|
|
||||||
ScrollDirection,
|
|
||||||
} from '@/modules/computer-use/computer-executor.js';
|
|
||||||
|
|
||||||
export type RawComputerAction =
|
|
||||||
| { type: 'screenshot' }
|
|
||||||
| { type: 'cursor_position' }
|
|
||||||
| { type: 'mouse_move'; point: Point }
|
|
||||||
| { type: 'click'; button: ClickButton; point?: Point; double?: boolean }
|
|
||||||
| { type: 'drag'; from: Point; to: Point; button?: ClickButton }
|
|
||||||
| { type: 'type'; text: string }
|
|
||||||
| { type: 'key'; key: string }
|
|
||||||
| { type: 'scroll'; direction: ScrollDirection; amount?: number; point?: Point }
|
|
||||||
| { type: 'wait'; ms?: number };
|
|
||||||
|
|
||||||
export type RawActionTarget = {
|
|
||||||
displaySize: DisplaySize | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RawActionResult = {
|
|
||||||
screenshotDataUrl?: string | null;
|
|
||||||
displaySize?: DisplaySize | null;
|
|
||||||
cursor?: Point | null;
|
|
||||||
position?: Point | null;
|
|
||||||
};
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import { createRequire } from 'node:module';
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
|
|
||||||
export type Point = { x: number; y: number };
|
|
||||||
export type ClickButton = 'left' | 'right' | 'middle';
|
|
||||||
export type ScrollDirection = 'up' | 'down' | 'left' | 'right';
|
|
||||||
export type DisplaySize = { width: number; height: number };
|
|
||||||
|
|
||||||
export type RuntimeReadiness = {
|
|
||||||
nut: any | null;
|
|
||||||
screenshot: any | null;
|
|
||||||
nutInstalled: boolean;
|
|
||||||
screenshotInstalled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Coordinate space the executor reports/accepts. The screenshot pixel space is
|
|
||||||
* the canonical space agents and users address; it is mapped to the nut-js
|
|
||||||
* logical mouse space before any action runs.
|
|
||||||
*/
|
|
||||||
export type ExecutorTarget = {
|
|
||||||
displaySize: DisplaySize | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getNut(): any | null {
|
|
||||||
try {
|
|
||||||
return require('@nut-tree-fork/nut-js');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getScreenshot(): any | null {
|
|
||||||
try {
|
|
||||||
const mod = require('screenshot-desktop');
|
|
||||||
return mod?.default || mod;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRuntimeReadiness(): RuntimeReadiness {
|
|
||||||
const nut = getNut();
|
|
||||||
const screenshot = getScreenshot();
|
|
||||||
return {
|
|
||||||
nut,
|
|
||||||
screenshot,
|
|
||||||
nutInstalled: Boolean(nut),
|
|
||||||
screenshotInstalled: typeof screenshot === 'function',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the pixel dimensions from a PNG/JPEG buffer header without decoding it. */
|
|
||||||
export function readImageSize(buffer: Buffer): DisplaySize | null {
|
|
||||||
// PNG: 8-byte signature, then IHDR chunk with width/height as big-endian uint32.
|
|
||||||
if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50) {
|
|
||||||
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
||||||
}
|
|
||||||
// JPEG: scan for a Start-Of-Frame marker (0xFFC0..0xFFCF, excluding C4/C8/CC).
|
|
||||||
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
||||||
let offset = 2;
|
|
||||||
while (offset + 9 < buffer.length) {
|
|
||||||
if (buffer[offset] !== 0xff) {
|
|
||||||
offset += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const marker = buffer[offset + 1];
|
|
||||||
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
||||||
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
|
|
||||||
}
|
|
||||||
offset += 2 + buffer.readUInt16BE(offset + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureScreenshot(): Promise<{ dataUrl: string; size: DisplaySize | null }> {
|
|
||||||
const screenshot = getScreenshot();
|
|
||||||
if (typeof screenshot !== 'function') {
|
|
||||||
throw new Error('Computer Use runtime is not available.');
|
|
||||||
}
|
|
||||||
const buffer: Buffer = await screenshot({ format: 'png' });
|
|
||||||
return {
|
|
||||||
dataUrl: `data:image/png;base64,${buffer.toString('base64')}`,
|
|
||||||
size: readImageSize(buffer),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the mouse coordinate space size (logical screen pixels). */
|
|
||||||
export async function getMouseSpaceSize(): Promise<DisplaySize> {
|
|
||||||
const nut = getNut();
|
|
||||||
if (!nut) {
|
|
||||||
throw new Error('Computer Use runtime is not available.');
|
|
||||||
}
|
|
||||||
const width = await nut.screen.width();
|
|
||||||
const height = await nut.screen.height();
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps a point from screenshot/image space to the mouse coordinate space. */
|
|
||||||
export async function toMouseSpace(target: ExecutorTarget, point: Point): Promise<Point> {
|
|
||||||
const mouseSize = await getMouseSpaceSize();
|
|
||||||
const image = target.displaySize || mouseSize;
|
|
||||||
const scaleX = image.width ? mouseSize.width / image.width : 1;
|
|
||||||
const scaleY = image.height ? mouseSize.height / image.height : 1;
|
|
||||||
return {
|
|
||||||
x: Math.round(point.x * scaleX),
|
|
||||||
y: Math.round(point.y * scaleY),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps a point from the mouse coordinate space back to screenshot/image space. */
|
|
||||||
export function toImageSpace(target: ExecutorTarget, point: Point, mouseSize: DisplaySize): Point {
|
|
||||||
const image = target.displaySize || mouseSize;
|
|
||||||
const scaleX = mouseSize.width ? image.width / mouseSize.width : 1;
|
|
||||||
const scaleY = mouseSize.height ? image.height / mouseSize.height : 1;
|
|
||||||
return {
|
|
||||||
x: Math.round(point.x * scaleX),
|
|
||||||
y: Math.round(point.y * scaleY),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function nutButton(nut: any, button: ClickButton) {
|
|
||||||
if (button === 'right') return nut.Button.RIGHT;
|
|
||||||
if (button === 'middle') return nut.Button.MIDDLE;
|
|
||||||
return nut.Button.LEFT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps a key name (xdotool-style, as Anthropic's computer tool emits) to a nut-js Key. */
|
|
||||||
function nutKey(nut: any, token: string): any {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
return: 'Enter', enter: 'Enter', esc: 'Escape', escape: 'Escape', tab: 'Tab',
|
|
||||||
space: 'Space', backspace: 'Backspace', delete: 'Delete', del: 'Delete', insert: 'Insert',
|
|
||||||
up: 'Up', down: 'Down', left: 'Left', right: 'Right',
|
|
||||||
home: 'Home', end: 'End', pageup: 'PageUp', page_up: 'PageUp', pagedown: 'PageDown', page_down: 'PageDown',
|
|
||||||
ctrl: 'LeftControl', control: 'LeftControl', alt: 'LeftAlt', shift: 'LeftShift',
|
|
||||||
meta: 'LeftSuper', super: 'LeftSuper', cmd: 'LeftSuper', win: 'LeftSuper',
|
|
||||||
capslock: 'CapsLock',
|
|
||||||
};
|
|
||||||
const lower = token.toLowerCase();
|
|
||||||
if (map[lower]) {
|
|
||||||
return nut.Key[map[lower]];
|
|
||||||
}
|
|
||||||
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(lower)) {
|
|
||||||
return nut.Key[`F${lower.slice(1)}`];
|
|
||||||
}
|
|
||||||
if (token.length === 1) {
|
|
||||||
const upper = token.toUpperCase();
|
|
||||||
if (nut.Key[upper] !== undefined) {
|
|
||||||
return nut.Key[upper];
|
|
||||||
}
|
|
||||||
if (nut.Key[`Num${token}`] !== undefined && /[0-9]/.test(token)) {
|
|
||||||
return nut.Key[`Num${token}`];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported key: ${token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The cross-platform OS executor. It is intentionally free of any server,
|
|
||||||
* database, or session dependencies so it can run both inside the local server
|
|
||||||
* process (OSS mode) and inside the standalone desktop agent (cloud relay).
|
|
||||||
*/
|
|
||||||
export const executor = {
|
|
||||||
async configure() {
|
|
||||||
const nut = getNut();
|
|
||||||
if (nut) {
|
|
||||||
// Make actions responsive; the agent loop already paces itself with screenshots.
|
|
||||||
nut.mouse.config.autoDelayMs = 2;
|
|
||||||
nut.keyboard.config.autoDelayMs = 2;
|
|
||||||
}
|
|
||||||
return nut;
|
|
||||||
},
|
|
||||||
|
|
||||||
async cursorPosition(target: ExecutorTarget): Promise<Point> {
|
|
||||||
const nut = await this.configure();
|
|
||||||
const mouseSize = await getMouseSpaceSize();
|
|
||||||
const pos = await nut.mouse.getPosition();
|
|
||||||
return toImageSpace(target, { x: pos.x, y: pos.y }, mouseSize);
|
|
||||||
},
|
|
||||||
|
|
||||||
async moveTo(target: ExecutorTarget, point: Point): Promise<void> {
|
|
||||||
const nut = await this.configure();
|
|
||||||
const dest = await toMouseSpace(target, point);
|
|
||||||
await nut.mouse.setPosition(new nut.Point(dest.x, dest.y));
|
|
||||||
},
|
|
||||||
|
|
||||||
async click(target: ExecutorTarget, button: ClickButton, point?: Point, doubleClick = false): Promise<void> {
|
|
||||||
const nut = await this.configure();
|
|
||||||
if (point) {
|
|
||||||
await this.moveTo(target, point);
|
|
||||||
}
|
|
||||||
if (doubleClick) {
|
|
||||||
await nut.mouse.doubleClick(nutButton(nut, button));
|
|
||||||
} else {
|
|
||||||
await nut.mouse.click(nutButton(nut, button));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async drag(target: ExecutorTarget, from: Point, to: Point, button: ClickButton = 'left'): Promise<void> {
|
|
||||||
const nut = await this.configure();
|
|
||||||
const start = await toMouseSpace(target, from);
|
|
||||||
const end = await toMouseSpace(target, to);
|
|
||||||
await nut.mouse.setPosition(new nut.Point(start.x, start.y));
|
|
||||||
await nut.mouse.pressButton(nutButton(nut, button));
|
|
||||||
await nut.mouse.setPosition(new nut.Point(end.x, end.y));
|
|
||||||
await nut.mouse.releaseButton(nutButton(nut, button));
|
|
||||||
},
|
|
||||||
|
|
||||||
async type(text: string): Promise<void> {
|
|
||||||
const nut = await this.configure();
|
|
||||||
await nut.keyboard.type(text);
|
|
||||||
},
|
|
||||||
|
|
||||||
async pressChord(chord: string): Promise<void> {
|
|
||||||
const nut = await this.configure();
|
|
||||||
const tokens = chord.split('+').map((token) => token.trim()).filter(Boolean);
|
|
||||||
if (tokens.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const keys = tokens.map((token) => nutKey(nut, token));
|
|
||||||
for (const key of keys) {
|
|
||||||
await nut.keyboard.pressKey(key);
|
|
||||||
}
|
|
||||||
for (const key of [...keys].reverse()) {
|
|
||||||
await nut.keyboard.releaseKey(key);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async scroll(target: ExecutorTarget, direction: ScrollDirection, amount: number, point?: Point): Promise<void> {
|
|
||||||
const nut = await this.configure();
|
|
||||||
if (point) {
|
|
||||||
await this.moveTo(target, point);
|
|
||||||
}
|
|
||||||
const steps = Math.max(1, Math.round(amount));
|
|
||||||
if (direction === 'up') await nut.mouse.scrollUp(steps);
|
|
||||||
else if (direction === 'down') await nut.mouse.scrollDown(steps);
|
|
||||||
else if (direction === 'left') await nut.mouse.scrollLeft(steps);
|
|
||||||
else await nut.mouse.scrollRight(steps);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
import { execFile } from 'node:child_process';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
|
|
||||||
import {
|
|
||||||
captureScreenshot,
|
|
||||||
executor,
|
|
||||||
type ClickButton,
|
|
||||||
type ExecutorTarget,
|
|
||||||
type Point,
|
|
||||||
type ScrollDirection,
|
|
||||||
} from '@/modules/computer-use/computer-executor.js';
|
|
||||||
import type { SemanticAdapter } from '@/modules/computer-use/semantics/adapters/semantic-adapter.js';
|
|
||||||
import { createMacOsSemanticAdapter } from '@/modules/computer-use/semantics/adapters/macos/macos-semantic-adapter.js';
|
|
||||||
import { createWindowsSemanticAdapter } from '@/modules/computer-use/semantics/adapters/windows/windows-semantic-adapter.js';
|
|
||||||
import { resolveSemanticHelper } from '@/modules/computer-use/semantics/helpers/semantic-helper-resolver.js';
|
|
||||||
import { semanticSessionStore } from '@/modules/computer-use/semantics/semantic-session-store.js';
|
|
||||||
import type { SemanticAppState, SemanticElement } from '@/modules/computer-use/semantics/semantic-types.js';
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
const MAX_APP_STATE_ELEMENTS = 250;
|
|
||||||
let helperAdapter: SemanticAdapter | null | undefined;
|
|
||||||
|
|
||||||
function readString(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireApp(input: Record<string, unknown>): string {
|
|
||||||
const app = readString(input.app);
|
|
||||||
if (!app) {
|
|
||||||
throw new Error('app is required.');
|
|
||||||
}
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readNumber(value: unknown): number | undefined {
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readButton(value: unknown): ClickButton {
|
|
||||||
return value === 'right' || value === 'middle' ? value : 'left';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readClickCount(value: unknown): number {
|
|
||||||
const count = readNumber(value);
|
|
||||||
if (count === undefined) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return Math.max(1, Math.min(5, Math.trunc(count)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDirection(value: unknown): ScrollDirection {
|
|
||||||
return value === 'up' || value === 'left' || value === 'right' ? value : 'down';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSessionId(input: Record<string, unknown>): string {
|
|
||||||
return readString(input.sessionId) || 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
function centerOf(element: SemanticElement): Point | null {
|
|
||||||
const bounds = element.bounds;
|
|
||||||
if (!bounds) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
x: Math.round(bounds.x + bounds.width / 2),
|
|
||||||
y: Math.round(bounds.y + bounds.height / 2),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCachedElement(sessionId: string, app: string, index: string, stateId?: string): SemanticElement | null {
|
|
||||||
return semanticSessionStore.getElement(sessionId, app, index, stateId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPoint(input: Record<string, unknown>, sessionId: string, app: string): Point | undefined {
|
|
||||||
const x = readNumber(input.x);
|
|
||||||
const y = readNumber(input.y);
|
|
||||||
if (x !== undefined && y !== undefined) {
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
const elementIndex = readString(input.element_index);
|
|
||||||
if (!elementIndex) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const element = getCachedElement(sessionId, app, elementIndex, readString(input.stateId) || undefined);
|
|
||||||
return element ? centerOf(element) || undefined : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHelperAdapter(): SemanticAdapter | null {
|
|
||||||
if (helperAdapter !== undefined) {
|
|
||||||
return helperAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform !== 'darwin' && process.platform !== 'win32') {
|
|
||||||
helperAdapter = null;
|
|
||||||
return helperAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolution = resolveSemanticHelper();
|
|
||||||
if (!resolution.available) {
|
|
||||||
helperAdapter = null;
|
|
||||||
return helperAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
helperAdapter = process.platform === 'darwin'
|
|
||||||
? createMacOsSemanticAdapter()
|
|
||||||
: createWindowsSemanticAdapter();
|
|
||||||
return helperAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldFallbackFromHelper(error: unknown): boolean {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return /not implemented|unavailable|not found|does not exist|timed out|not running|exited with code|failed to start/i.test(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withHelperState(
|
|
||||||
sessionId: string,
|
|
||||||
operation: (adapter: SemanticAdapter) => Promise<SemanticAppState>,
|
|
||||||
): Promise<SemanticAppState | null> {
|
|
||||||
const adapter = getHelperAdapter();
|
|
||||||
if (!adapter) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return semanticSessionStore.save(sessionId, await operation(adapter));
|
|
||||||
} catch (error) {
|
|
||||||
if (shouldFallbackFromHelper(error)) {
|
|
||||||
console.warn('[ComputerSemantics] Falling back from helper:', error instanceof Error ? error.message : String(error));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(command: string, args: string[], timeout = 5000): Promise<string> {
|
|
||||||
const { stdout } = await execFileAsync(command, args, {
|
|
||||||
timeout,
|
|
||||||
windowsHide: true,
|
|
||||||
maxBuffer: 1024 * 1024 * 4,
|
|
||||||
});
|
|
||||||
return stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listMacApps(): Promise<Array<Record<string, unknown>>> {
|
|
||||||
const script = [
|
|
||||||
'tell application "System Events"',
|
|
||||||
'set appRows to {}',
|
|
||||||
'repeat with p in (application processes whose background only is false)',
|
|
||||||
'set end of appRows to (name of p as text)',
|
|
||||||
'end repeat',
|
|
||||||
'return appRows',
|
|
||||||
'end tell',
|
|
||||||
].join('\n');
|
|
||||||
const output = await run('osascript', ['-e', script]);
|
|
||||||
return output.split(', ')
|
|
||||||
.map((name) => name.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((name) => ({ name, running: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listWindowsApps(): Promise<Array<Record<string, unknown>>> {
|
|
||||||
const script = [
|
|
||||||
'Get-Process | Where-Object { $_.MainWindowTitle } |',
|
|
||||||
'Select-Object ProcessName, Id, MainWindowTitle | ConvertTo-Json -Depth 3',
|
|
||||||
].join(' ');
|
|
||||||
const output = await run('powershell.exe', ['-NoProfile', '-Command', script]);
|
|
||||||
const parsed = JSON.parse(output || '[]');
|
|
||||||
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
||||||
return rows.map((row) => ({
|
|
||||||
name: row.ProcessName,
|
|
||||||
pid: row.Id,
|
|
||||||
windowTitle: row.MainWindowTitle,
|
|
||||||
running: true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listLinuxApps(): Promise<Array<Record<string, unknown>>> {
|
|
||||||
try {
|
|
||||||
const output = await run('wmctrl', ['-lx']);
|
|
||||||
return output.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => {
|
|
||||||
const parts = line.split(/\s+/);
|
|
||||||
return {
|
|
||||||
windowId: parts[0],
|
|
||||||
desktop: parts[1],
|
|
||||||
host: parts[2],
|
|
||||||
className: parts[3],
|
|
||||||
windowTitle: parts.slice(4).join(' '),
|
|
||||||
running: true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
const output = await run('ps', ['-eo', 'comm=']);
|
|
||||||
return [...new Set(output.split(/\r?\n/).map((name) => name.trim()).filter(Boolean))]
|
|
||||||
.slice(0, 200)
|
|
||||||
.map((name) => ({ name, running: true }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listApps(): Promise<Array<Record<string, unknown>>> {
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
return listMacApps();
|
|
||||||
}
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return listWindowsApps();
|
|
||||||
}
|
|
||||||
return listLinuxApps();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function macAccessibilityTree(app: string): Promise<SemanticElement[]> {
|
|
||||||
const escapedApp = app.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
||||||
const script = `
|
|
||||||
on safeText(v)
|
|
||||||
try
|
|
||||||
return v as text
|
|
||||||
on error
|
|
||||||
return ""
|
|
||||||
end try
|
|
||||||
end safeText
|
|
||||||
|
|
||||||
on emitElement(e, depth, maxDepth, counter)
|
|
||||||
if depth > maxDepth then return {}
|
|
||||||
set rows to {}
|
|
||||||
try
|
|
||||||
set roleText to my safeText(role of e)
|
|
||||||
on error
|
|
||||||
set roleText to "element"
|
|
||||||
end try
|
|
||||||
try
|
|
||||||
set titleText to my safeText(title of e)
|
|
||||||
on error
|
|
||||||
set titleText to ""
|
|
||||||
end try
|
|
||||||
try
|
|
||||||
set valueText to my safeText(value of e)
|
|
||||||
on error
|
|
||||||
set valueText to ""
|
|
||||||
end try
|
|
||||||
try
|
|
||||||
set posValue to position of e
|
|
||||||
set sizeValue to size of e
|
|
||||||
set boundsText to ((item 1 of posValue) as text) & "," & ((item 2 of posValue) as text) & "," & ((item 1 of sizeValue) as text) & "," & ((item 2 of sizeValue) as text)
|
|
||||||
on error
|
|
||||||
set boundsText to ""
|
|
||||||
end try
|
|
||||||
set end of rows to ((counter as text) & tab & roleText & tab & titleText & tab & valueText & tab & boundsText)
|
|
||||||
if counter > ${MAX_APP_STATE_ELEMENTS} then return rows
|
|
||||||
try
|
|
||||||
repeat with childElement in UI elements of e
|
|
||||||
set childRows to my emitElement(childElement, depth + 1, maxDepth, counter + (count of rows))
|
|
||||||
set rows to rows & childRows
|
|
||||||
if (count of rows) > ${MAX_APP_STATE_ELEMENTS} then return rows
|
|
||||||
end repeat
|
|
||||||
end try
|
|
||||||
return rows
|
|
||||||
end emitElement
|
|
||||||
|
|
||||||
tell application "System Events"
|
|
||||||
if not (exists process "${escapedApp}") then error "App is not running: ${escapedApp}"
|
|
||||||
tell process "${escapedApp}"
|
|
||||||
set rows to {}
|
|
||||||
repeat with w in windows
|
|
||||||
set rows to rows & my emitElement(w, 0, 4, (count of rows) + 1)
|
|
||||||
if (count of rows) > ${MAX_APP_STATE_ELEMENTS} then exit repeat
|
|
||||||
end repeat
|
|
||||||
return rows
|
|
||||||
end tell
|
|
||||||
end tell
|
|
||||||
`;
|
|
||||||
const output = await run('osascript', ['-e', script], 10000);
|
|
||||||
return output.split(/\r?\n|, /)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line, index) => {
|
|
||||||
const [rawIndex, role, title, value, boundsText] = line.split('\t');
|
|
||||||
const boundsParts = (boundsText || '').split(',').map((part) => Number.parseFloat(part));
|
|
||||||
const hasBounds = boundsParts.length === 4 && boundsParts.every(Number.isFinite);
|
|
||||||
return {
|
|
||||||
index: rawIndex || String(index + 1),
|
|
||||||
role: role || 'element',
|
|
||||||
title: title || undefined,
|
|
||||||
value: value || undefined,
|
|
||||||
bounds: hasBounds
|
|
||||||
? { x: boundsParts[0], y: boundsParts[1], width: boundsParts[2], height: boundsParts[3] }
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAccessibilityTree(app: string): Promise<{ elements: SemanticElement[]; message?: string }> {
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
try {
|
|
||||||
return { elements: await macAccessibilityTree(app) };
|
|
||||||
} catch (error) {
|
|
||||||
return { elements: [], message: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: [],
|
|
||||||
message: 'Native accessibility tree capture is not implemented for this platform yet.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAppState(sessionId: string, app: string): Promise<SemanticAppState> {
|
|
||||||
if (!app) {
|
|
||||||
throw new Error('app is required.');
|
|
||||||
}
|
|
||||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.getAppState({ sessionId, app }));
|
|
||||||
if (helperState) {
|
|
||||||
return helperState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const screenshot = await captureScreenshot();
|
|
||||||
const tree = await getAccessibilityTree(app);
|
|
||||||
const state: SemanticAppState = {
|
|
||||||
stateId: semanticSessionStore.createStateId(),
|
|
||||||
app,
|
|
||||||
platform: process.platform,
|
|
||||||
screenshotDataUrl: screenshot.dataUrl,
|
|
||||||
displaySize: screenshot.size,
|
|
||||||
elements: tree.elements,
|
|
||||||
accessibilityTree: tree.elements,
|
|
||||||
message: tree.message,
|
|
||||||
};
|
|
||||||
return semanticSessionStore.save(sessionId, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function targetFor(sessionId: string, app: string, stateId?: string): Promise<ExecutorTarget> {
|
|
||||||
const cached = semanticSessionStore.getState(sessionId, app, stateId);
|
|
||||||
return { displaySize: cached?.displaySize || (await captureScreenshot()).size };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const computerSemanticsService = {
|
|
||||||
async callTool(name: string, input: Record<string, unknown>): Promise<unknown> {
|
|
||||||
const sessionId = readSessionId(input);
|
|
||||||
switch (name) {
|
|
||||||
case 'list_apps': {
|
|
||||||
const adapter = getHelperAdapter();
|
|
||||||
if (adapter) {
|
|
||||||
try {
|
|
||||||
return { apps: await adapter.listApps(), platform: process.platform };
|
|
||||||
} catch (error) {
|
|
||||||
if (!shouldFallbackFromHelper(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
console.warn('[ComputerSemantics] Falling back from helper:', error instanceof Error ? error.message : String(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { apps: await listApps(), platform: process.platform };
|
|
||||||
}
|
|
||||||
case 'get_app_state':
|
|
||||||
return getAppState(sessionId, readString(input.app));
|
|
||||||
case 'click':
|
|
||||||
case 'click_element': {
|
|
||||||
const app = requireApp(input);
|
|
||||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.clickElement({ ...input, sessionId, app }));
|
|
||||||
if (helperState) {
|
|
||||||
return helperState;
|
|
||||||
}
|
|
||||||
const stateId = readString(input.stateId) || undefined;
|
|
||||||
const point = getPoint(input, sessionId, app);
|
|
||||||
if (!point) {
|
|
||||||
throw new Error('click requires x/y or an element_index from computer_get_app_state.');
|
|
||||||
}
|
|
||||||
const target = await targetFor(sessionId, app, stateId);
|
|
||||||
const button = readButton(input.mouse_button ?? input.mouseButton);
|
|
||||||
const clickCount = readClickCount(input.click_count ?? input.clickCount);
|
|
||||||
for (let index = 0; index < clickCount; index += 1) {
|
|
||||||
await executor.click(target, button, point, false);
|
|
||||||
}
|
|
||||||
return getAppState(sessionId, app);
|
|
||||||
}
|
|
||||||
case 'drag': {
|
|
||||||
const app = requireApp(input);
|
|
||||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.drag({ ...input, sessionId, app }));
|
|
||||||
if (helperState) {
|
|
||||||
return helperState;
|
|
||||||
}
|
|
||||||
const stateId = readString(input.stateId) || undefined;
|
|
||||||
const fromX = readNumber(input.from_x);
|
|
||||||
const fromY = readNumber(input.from_y);
|
|
||||||
const toX = readNumber(input.to_x);
|
|
||||||
const toY = readNumber(input.to_y);
|
|
||||||
if (fromX === undefined || fromY === undefined || toX === undefined || toY === undefined) {
|
|
||||||
throw new Error('drag requires from_x/from_y/to_x/to_y.');
|
|
||||||
}
|
|
||||||
await executor.drag(await targetFor(sessionId, app, stateId), { x: fromX, y: fromY }, { x: toX, y: toY }, readButton(input.mouse_button ?? input.mouseButton));
|
|
||||||
return getAppState(sessionId, app);
|
|
||||||
}
|
|
||||||
case 'scroll':
|
|
||||||
case 'scroll_element': {
|
|
||||||
const app = requireApp(input);
|
|
||||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.scrollElement({ ...input, sessionId, app }));
|
|
||||||
if (helperState) {
|
|
||||||
return helperState;
|
|
||||||
}
|
|
||||||
const stateId = readString(input.stateId) || undefined;
|
|
||||||
const point = getPoint(input, sessionId, app);
|
|
||||||
if (!point) {
|
|
||||||
throw new Error('scroll requires x/y or an element_index from computer_get_app_state.');
|
|
||||||
}
|
|
||||||
await executor.scroll(await targetFor(sessionId, app, stateId), readDirection(input.direction), readNumber(input.pages) ?? 1, point);
|
|
||||||
return getAppState(sessionId, app);
|
|
||||||
}
|
|
||||||
case 'type_text': {
|
|
||||||
const app = requireApp(input);
|
|
||||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.typeText({ ...input, sessionId, app }));
|
|
||||||
if (helperState) {
|
|
||||||
return helperState;
|
|
||||||
}
|
|
||||||
await executor.type(readString(input.text));
|
|
||||||
return getAppState(sessionId, app);
|
|
||||||
}
|
|
||||||
case 'press_key': {
|
|
||||||
const app = requireApp(input);
|
|
||||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.pressKey({ ...input, sessionId, app }));
|
|
||||||
if (helperState) {
|
|
||||||
return helperState;
|
|
||||||
}
|
|
||||||
await executor.pressChord(readString(input.key));
|
|
||||||
return getAppState(sessionId, app);
|
|
||||||
}
|
|
||||||
case 'set_value': {
|
|
||||||
const app = requireApp(input);
|
|
||||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.setValue({ ...input, sessionId, app }));
|
|
||||||
if (helperState) {
|
|
||||||
return helperState;
|
|
||||||
}
|
|
||||||
const stateId = readString(input.stateId) || undefined;
|
|
||||||
const point = getPoint(input, sessionId, app);
|
|
||||||
if (!point) {
|
|
||||||
throw new Error('set_value requires x/y or an element_index from computer_get_app_state.');
|
|
||||||
}
|
|
||||||
await executor.click(await targetFor(sessionId, app, stateId), 'left', point, false);
|
|
||||||
await executor.pressChord(process.platform === 'darwin' ? 'cmd+a' : 'ctrl+a');
|
|
||||||
await executor.type(readString(input.value));
|
|
||||||
return getAppState(sessionId, app);
|
|
||||||
}
|
|
||||||
case 'perform_secondary_action': {
|
|
||||||
const app = requireApp(input);
|
|
||||||
const helperState = await withHelperState(sessionId, (adapter) => adapter.performSecondaryAction({ ...input, sessionId, app }));
|
|
||||||
if (helperState) {
|
|
||||||
return helperState;
|
|
||||||
}
|
|
||||||
const stateId = readString(input.stateId) || undefined;
|
|
||||||
const point = getPoint(input, sessionId, app);
|
|
||||||
if (!point) {
|
|
||||||
throw new Error('perform_secondary_action requires x/y or an element_index from computer_get_app_state.');
|
|
||||||
}
|
|
||||||
await executor.click(await targetFor(sessionId, app, stateId), 'right', point, false);
|
|
||||||
return getAppState(sessionId, app);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown semantic Computer Use tool: ${name}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
|
|
||||||
import { computerUseService } from '@/modules/computer-use/computer-use.service.js';
|
|
||||||
import { semanticOperationForMcpTool } from '@/modules/computer-use/semantics/semantic-tool-dispatcher.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
function readBearerToken(header: unknown): string | null {
|
|
||||||
if (typeof header !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trimmed = header.trim();
|
|
||||||
const scheme = 'Bearer';
|
|
||||||
if (trimmed.slice(0, scheme.length).toLowerCase() !== scheme.toLowerCase()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const separator = trimmed[scheme.length];
|
|
||||||
if (separator !== ' ' && separator !== '\t') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed.slice(scheme.length + 1).trimStart() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toButton(value: unknown): 'left' | 'right' | 'middle' {
|
|
||||||
return value === 'right' || value === 'middle' ? value : 'left';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toScrollDirection(value: unknown): 'up' | 'down' | 'left' | 'right' {
|
|
||||||
return value === 'down' || value === 'left' || value === 'right' ? value : 'up';
|
|
||||||
}
|
|
||||||
|
|
||||||
function point(input: Record<string, unknown>): { x: number; y: number } | undefined {
|
|
||||||
return typeof input.x === 'number' && typeof input.y === 'number'
|
|
||||||
? { x: input.x, y: input.y }
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireNumber(input: Record<string, unknown>, name: string): number {
|
|
||||||
const value = input[name];
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
||||||
throw new Error(`${name} is required and must be a finite number.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requirePoint(input: Record<string, unknown>): { x: number; y: number } {
|
|
||||||
return { x: requireNumber(input, 'x'), y: requireNumber(input, 'y') };
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireNamedPoint(input: Record<string, unknown>, xName: string, yName: string): { x: number; y: number } {
|
|
||||||
return { x: requireNumber(input, xName), y: requireNumber(input, yName) };
|
|
||||||
}
|
|
||||||
|
|
||||||
router.use((req, res, next) => {
|
|
||||||
const expected = computerUseService.getMcpToken();
|
|
||||||
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-computer-use-mcp-token'] || '');
|
|
||||||
if (!token || token !== expected) {
|
|
||||||
res.status(401).json({ success: false, error: 'Invalid Computer Use MCP token.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/tools/:toolName', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>;
|
|
||||||
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : undefined;
|
|
||||||
const toolName = req.params.toolName;
|
|
||||||
const semanticOperation = semanticOperationForMcpTool(toolName);
|
|
||||||
let result: unknown;
|
|
||||||
|
|
||||||
if (semanticOperation) {
|
|
||||||
result = await computerUseService.callSemanticTool(semanticOperation, input);
|
|
||||||
res.json({ success: true, data: result });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (toolName) {
|
|
||||||
case 'computer_screenshot':
|
|
||||||
result = await computerUseService.agentScreenshot(sessionId);
|
|
||||||
break;
|
|
||||||
case 'computer_cursor_position':
|
|
||||||
result = await computerUseService.agentCursorPosition(sessionId);
|
|
||||||
break;
|
|
||||||
case 'computer_mouse_move':
|
|
||||||
result = await computerUseService.agentMouseMove(sessionId, requirePoint(input));
|
|
||||||
break;
|
|
||||||
case 'computer_click':
|
|
||||||
result = await computerUseService.agentUnifiedClick(sessionId, {
|
|
||||||
button: toButton(input.mouseButton ?? input.mouse_button ?? input.button),
|
|
||||||
point: point(input),
|
|
||||||
clickCount: typeof input.clickCount === 'number'
|
|
||||||
? input.clickCount
|
|
||||||
: typeof input.click_count === 'number'
|
|
||||||
? input.click_count
|
|
||||||
: 1,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'computer_drag': {
|
|
||||||
const from = requireNamedPoint(input, 'startX', 'startY');
|
|
||||||
const to = requireNamedPoint(input, 'endX', 'endY');
|
|
||||||
result = await computerUseService.agentDrag(sessionId, from, to, toButton(input.mouseButton ?? input.mouse_button ?? input.button));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'computer_type':
|
|
||||||
result = await computerUseService.agentType(sessionId, String(input.text || ''));
|
|
||||||
break;
|
|
||||||
case 'computer_key':
|
|
||||||
result = await computerUseService.agentKey(sessionId, String(input.key || ''));
|
|
||||||
break;
|
|
||||||
case 'computer_scroll':
|
|
||||||
result = await computerUseService.agentScroll(sessionId, {
|
|
||||||
direction: toScrollDirection(input.direction),
|
|
||||||
amount: typeof input.amount === 'number' ? input.amount : undefined,
|
|
||||||
x: typeof input.x === 'number' ? input.x : undefined,
|
|
||||||
y: typeof input.y === 'number' ? input.y : undefined,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'computer_wait':
|
|
||||||
result = await computerUseService.agentWait(sessionId, typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined);
|
|
||||||
break;
|
|
||||||
case 'computer_close_session':
|
|
||||||
result = await computerUseService.agentStopSession(sessionId);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
res.status(404).json({ success: false, error: `Unknown Computer Use MCP tool "${toolName}".` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, data: result });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Computer Use MCP tool failed.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
|
|
||||||
import { computerUseService } from '@/modules/computer-use/computer-use.service.js';
|
|
||||||
import { AppError } from '@/shared/utils.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
type AuthenticatedRequest = express.Request & {
|
|
||||||
user?: {
|
|
||||||
id?: string | number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function requireUser(req: AuthenticatedRequest): { id: string | number } {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (userId === undefined || userId === null || String(userId).trim() === '') {
|
|
||||||
throw new AppError('Authenticated user is required.', {
|
|
||||||
code: 'AUTHENTICATED_USER_REQUIRED',
|
|
||||||
statusCode: 401,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { id: userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getErrorStatusCode(error: unknown, fallbackStatusCode: number): number {
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
return error.statusCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && typeof error === 'object') {
|
|
||||||
const statusCode = 'statusCode' in error ? error.statusCode : 'status' in error ? error.status : undefined;
|
|
||||||
if (typeof statusCode === 'number' && Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) {
|
|
||||||
return statusCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackStatusCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readParam(value: string | string[] | undefined): string {
|
|
||||||
return Array.isArray(value) ? value[0] || '' : value || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toButton(value: unknown): 'left' | 'right' | 'middle' {
|
|
||||||
return value === 'right' || value === 'middle' ? value : 'left';
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/status', async (_req, res) => {
|
|
||||||
try {
|
|
||||||
res.json({ success: true, data: await computerUseService.getStatus() });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to load Computer Use status.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/settings', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
requireUser(req);
|
|
||||||
res.json({ success: true, data: { settings: await computerUseService.getSettings() } });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(getErrorStatusCode(error, 500)).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to load Computer Use settings.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/settings', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
requireUser(req);
|
|
||||||
const settings = await computerUseService.updateSettings(req.body || {});
|
|
||||||
res.json({ success: true, data: { settings } });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(getErrorStatusCode(error, 400)).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to save Computer Use settings.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/runtime/install', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
requireUser(req);
|
|
||||||
const result = await computerUseService.installRuntime();
|
|
||||||
res.status(result.success ? 200 : 500).json({
|
|
||||||
success: result.success,
|
|
||||||
data: result,
|
|
||||||
error: result.success ? undefined : result.message,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
res.status(getErrorStatusCode(error, 500)).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to install Computer Use runtime.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/sessions', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
res.json({ success: true, data: { sessions: await computerUseService.listSessions(requireUser(req)) } });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(getErrorStatusCode(error, 500)).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to list Computer Use sessions.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/sessions/:sessionId/screenshot', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const session = await computerUseService.userScreenshot(requireUser(req), readParam(req.params.sessionId));
|
|
||||||
res.json({ success: true, data: { session } });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to capture the screen.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/sessions/:sessionId/click', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const x = Number(req.body?.x);
|
|
||||||
const y = Number(req.body?.y);
|
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Valid numeric coordinates are required.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await computerUseService.userClick(requireUser(req), readParam(req.params.sessionId), {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
button: toButton(req.body?.button),
|
|
||||||
double: req.body?.double === true,
|
|
||||||
});
|
|
||||||
res.json({ success: true, data: { session } });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to click.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/sessions/:sessionId/press-key', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const session = await computerUseService.userPressKey(requireUser(req), readParam(req.params.sessionId), String(req.body?.key || ''));
|
|
||||||
res.json({ success: true, data: { session } });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to send key input.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/sessions/:sessionId/consent/grant', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const session = await computerUseService.grantAgentAccess(requireUser(req), readParam(req.params.sessionId));
|
|
||||||
res.json({ success: true, data: { session } });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to grant control.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/sessions/:sessionId/consent/revoke', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const session = await computerUseService.revokeAgentAccess(requireUser(req), readParam(req.params.sessionId));
|
|
||||||
res.json({ success: true, data: { session } });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to revoke control.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/sessions/:sessionId/stop', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const result = await computerUseService.stopSession(requireUser(req), readParam(req.params.sessionId));
|
|
||||||
res.json({ success: true, data: result });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to stop Computer Use session.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/sessions/:sessionId', async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const result = await computerUseService.deleteSession(requireUser(req), readParam(req.params.sessionId));
|
|
||||||
res.json({ success: true, data: result });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to delete Computer Use session.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,920 +0,0 @@
|
|||||||
import { randomBytes, randomUUID } from 'node:crypto';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
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,
|
|
||||||
type Point,
|
|
||||||
type ClickButton,
|
|
||||||
type ScrollDirection,
|
|
||||||
} from '@/modules/computer-use/computer-executor.js';
|
|
||||||
import { runRawComputerAction } from '@/modules/computer-use/actions/raw-action-dispatcher.js';
|
|
||||||
import type { RawComputerAction } from '@/modules/computer-use/actions/raw-action-types.js';
|
|
||||||
import { desktopAgentRelay } from '@/modules/computer-use/desktop-agent-relay.service.js';
|
|
||||||
import { computerSemanticsService } from '@/modules/computer-use/computer-semantics.service.js';
|
|
||||||
import { semanticOperationNames } from '@/modules/computer-use/semantics/semantic-tool-dispatcher.js';
|
|
||||||
|
|
||||||
const __dirname = getModuleDir(import.meta.url);
|
|
||||||
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
|
||||||
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_MAX_SESSIONS_PER_OWNER || '1', 10);
|
|
||||||
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
|
|
||||||
const STOPPED_SESSION_RETENTION_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_STOPPED_SESSION_RETENTION_MS || String(30 * 60 * 1000), 10);
|
|
||||||
const MAX_STORED_SESSIONS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_MAX_STORED_SESSIONS || '100', 10);
|
|
||||||
const COMPUTER_USE_SETTINGS_KEY = 'computer_use_settings';
|
|
||||||
const COMPUTER_USE_MCP_TOKEN_KEY = 'computer_use_mcp_token';
|
|
||||||
type ComputerUseRuntime = 'cloud' | 'local';
|
|
||||||
type ComputerUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
|
|
||||||
|
|
||||||
type ComputerUseSession = {
|
|
||||||
id: string;
|
|
||||||
ownerId: string;
|
|
||||||
createdBy: 'user' | 'agent';
|
|
||||||
runtime: ComputerUseRuntime;
|
|
||||||
status: ComputerUseSessionStatus;
|
|
||||||
screenshotDataUrl: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
lastAction: string | null;
|
|
||||||
message: string | null;
|
|
||||||
/** Per-session consent: agents may act only while this is true. */
|
|
||||||
agentAccessEnabled: boolean;
|
|
||||||
/** Size of the captured screenshot in pixels — the coordinate space agents/users use. */
|
|
||||||
displaySize: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null;
|
|
||||||
cursor: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
actor: 'agent' | 'user';
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PublicComputerUseSession = Omit<ComputerUseSession, 'ownerId'>;
|
|
||||||
|
|
||||||
type ComputerUseOwner = {
|
|
||||||
id: string | number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComputerUseSettings = {
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RuntimeReadiness = {
|
|
||||||
nut: any | null;
|
|
||||||
screenshot: any | null;
|
|
||||||
nutInstalled: boolean;
|
|
||||||
screenshotInstalled: boolean;
|
|
||||||
installInProgress: boolean;
|
|
||||||
installMessage: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sessions = new Map<string, ComputerUseSession>();
|
|
||||||
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
|
||||||
let lastInstallMessage: string | null = null;
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: ComputerUseSettings = {
|
|
||||||
enabled: false,
|
|
||||||
};
|
|
||||||
const AGENT_OWNER_ID = 'agent';
|
|
||||||
const MCP_SERVER_NAME = 'cloudcli-computer-use';
|
|
||||||
const MCP_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'opencode'];
|
|
||||||
|
|
||||||
function getRuntime(): ComputerUseRuntime {
|
|
||||||
return IS_PLATFORM ? 'cloud' : 'local';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSettings(): ComputerUseSettings {
|
|
||||||
try {
|
|
||||||
const raw = appConfigDb.get(COMPUTER_USE_SETTINGS_KEY);
|
|
||||||
if (!raw) {
|
|
||||||
return DEFAULT_SETTINGS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as Partial<ComputerUseSettings>;
|
|
||||||
return {
|
|
||||||
enabled: parsed.enabled === true,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
console.warn('[Computer Use] Failed to read settings:', error?.message || error);
|
|
||||||
return DEFAULT_SETTINGS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSettings(settings: ComputerUseSettings): ComputerUseSettings {
|
|
||||||
const normalized = {
|
|
||||||
enabled: settings.enabled === true,
|
|
||||||
};
|
|
||||||
|
|
||||||
appConfigDb.set(COMPUTER_USE_SETTINGS_KEY, JSON.stringify(normalized));
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateMcpToken(): string {
|
|
||||||
const existing = appConfigDb.get(COMPUTER_USE_MCP_TOKEN_KEY);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const token = randomBytes(32).toString('hex');
|
|
||||||
appConfigDb.set(COMPUTER_USE_MCP_TOKEN_KEY, token);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSetupMessage(settings: ComputerUseSettings, readiness: RuntimeReadiness): string {
|
|
||||||
if (!settings.enabled) {
|
|
||||||
return 'Computer Use is disabled in settings.';
|
|
||||||
}
|
|
||||||
if (getRuntime() === 'cloud') {
|
|
||||||
return 'Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.';
|
|
||||||
}
|
|
||||||
if (!readiness.nutInstalled || !readiness.screenshotInstalled) {
|
|
||||||
return 'Install the desktop control runtime to capture the screen and drive the mouse and keyboard.';
|
|
||||||
}
|
|
||||||
return readiness.installMessage || 'Computer Use runtime is not ready.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMcpCommand(): { command: string; args: string[] } {
|
|
||||||
const serverDir = path.resolve(__dirname, '..', '..');
|
|
||||||
const mcpScriptPath = path.join(serverDir, 'computer-use-mcp.js');
|
|
||||||
if (fs.existsSync(mcpScriptPath)) {
|
|
||||||
return {
|
|
||||||
command: process.execPath,
|
|
||||||
args: [mcpScriptPath],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: 'cloudcli',
|
|
||||||
args: ['computer-use-mcp'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMcpApiUrl(): string {
|
|
||||||
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
|
|
||||||
return `http://127.0.0.1:${port}/api/computer-use-mcp`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuntimeReadiness(): RuntimeReadiness {
|
|
||||||
const base = getExecutorReadiness();
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
installInProgress: Boolean(installPromise),
|
|
||||||
installMessage: lastInstallMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInstallError(error: unknown): string {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (process.platform === 'linux' && /libxtst|x11|xtst|libpng|imagemagick|scrot/i.test(message)) {
|
|
||||||
return [
|
|
||||||
'Installing the desktop control runtime needs system packages.',
|
|
||||||
'On Debian/Ubuntu run: sudo apt-get install -y libxtst-dev libpng-dev imagemagick',
|
|
||||||
'then try again.',
|
|
||||||
].join(' ');
|
|
||||||
}
|
|
||||||
return message || 'Failed to install the Computer Use runtime.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPackagedElectronNodeRuntime(): boolean {
|
|
||||||
return process.env.ELECTRON_RUN_AS_NODE === '1' && Boolean(process.versions.electron);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installRuntime(): Promise<{ success: boolean; message: string }> {
|
|
||||||
if (installPromise) {
|
|
||||||
return installPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const readiness = getExecutorReadiness();
|
|
||||||
if (readiness.nutInstalled && readiness.screenshotInstalled) {
|
|
||||||
lastInstallMessage = 'Computer Use runtime is available.';
|
|
||||||
return { success: true, message: lastInstallMessage };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPackagedElectronNodeRuntime()) {
|
|
||||||
lastInstallMessage = 'Computer Use runtime was not bundled with this desktop build.';
|
|
||||||
return { success: false, message: lastInstallMessage };
|
|
||||||
}
|
|
||||||
|
|
||||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
||||||
installPromise = (async () => {
|
|
||||||
try {
|
|
||||||
lastInstallMessage = 'Installing desktop control runtime…';
|
|
||||||
await runCommand(npmCommand, [
|
|
||||||
'install',
|
|
||||||
'--no-save',
|
|
||||||
'--no-package-lock',
|
|
||||||
'@nut-tree-fork/nut-js',
|
|
||||||
'screenshot-desktop',
|
|
||||||
]);
|
|
||||||
|
|
||||||
lastInstallMessage = 'Computer Use runtime installed.';
|
|
||||||
return { success: true, message: lastInstallMessage };
|
|
||||||
} catch (error) {
|
|
||||||
lastInstallMessage = formatInstallError(error);
|
|
||||||
return { success: false, message: lastInstallMessage };
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await installPromise;
|
|
||||||
} finally {
|
|
||||||
installPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOwnerId(owner: ComputerUseOwner): string {
|
|
||||||
if (owner.id === undefined || owner.id === null || String(owner.id).trim() === '') {
|
|
||||||
throw new Error('Authenticated user is required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(owner.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function publicSession(session: ComputerUseSession): PublicComputerUseSession {
|
|
||||||
const { ownerId: _ownerId, ...publicFields } = session;
|
|
||||||
return publicFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ownerSessions(ownerId: string): ComputerUseSession[] {
|
|
||||||
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canAccessSession(ownerId: string, session: ComputerUseSession): boolean {
|
|
||||||
return session.ownerId === ownerId || session.ownerId === AGENT_OWNER_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSessionId(sessionId?: string | null): string | null {
|
|
||||||
if (typeof sessionId !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trimmed = sessionId.trim();
|
|
||||||
return trimmed ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findActiveAgentSession(): ComputerUseSession | null {
|
|
||||||
return ownerSessions(AGENT_OWNER_ID)
|
|
||||||
.filter((session) => session.status === 'ready')
|
|
||||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function positiveDuration(value: number, fallback: number): number {
|
|
||||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
|
||||||
const sessionTtl = positiveDuration(SESSION_TTL_MS, 30 * 60 * 1000);
|
|
||||||
const stoppedRetention = positiveDuration(STOPPED_SESSION_RETENTION_MS, sessionTtl);
|
|
||||||
|
|
||||||
for (const [sessionId, session] of sessions.entries()) {
|
|
||||||
const updatedAt = Date.parse(session.updatedAt);
|
|
||||||
if (!Number.isFinite(updatedAt)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.status === 'ready') {
|
|
||||||
if (now - updatedAt <= sessionTtl) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
session.status = 'stopped';
|
|
||||||
session.agentAccessEnabled = false;
|
|
||||||
session.updatedAt = new Date(now).toISOString();
|
|
||||||
session.lastAction = 'expire';
|
|
||||||
session.message = 'Computer Use session expired after inactivity.';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (now - updatedAt > stoppedRetention) {
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxStoredSessions = Number.isFinite(MAX_STORED_SESSIONS) && MAX_STORED_SESSIONS > 0
|
|
||||||
? MAX_STORED_SESSIONS
|
|
||||||
: 100;
|
|
||||||
if (sessions.size <= maxStoredSessions) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const removable = [...sessions.values()]
|
|
||||||
.filter((session) => session.status !== 'ready')
|
|
||||||
.sort((a, b) => Date.parse(a.updatedAt) - Date.parse(b.updatedAt));
|
|
||||||
for (const session of removable) {
|
|
||||||
if (sessions.size <= maxStoredSessions) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
sessions.delete(session.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Action layer: local executor (OSS) or cloud relay to the desktop agent --
|
|
||||||
//
|
|
||||||
// Every desktop interaction goes through `performAction` / `getCursorPosition`.
|
|
||||||
// In local mode it drives the in-process nut-js executor (computer-executor.ts);
|
|
||||||
// in cloud mode it forwards the action to the linked desktop agent over
|
|
||||||
// `desktopAgentRelay` and applies the returned screenshot. The local server
|
|
||||||
// itself never touches the OS in cloud mode.
|
|
||||||
|
|
||||||
/** Shape the desktop agent returns for any relayed action. */
|
|
||||||
type RelayResult = {
|
|
||||||
screenshotDataUrl?: string | null;
|
|
||||||
displaySize?: { width: number; height: number } | null;
|
|
||||||
cursor?: { x: number; y: number } | null;
|
|
||||||
position?: Point | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyRelayResult(session: ComputerUseSession, result: RelayResult): void {
|
|
||||||
if (typeof result.screenshotDataUrl === 'string') {
|
|
||||||
session.screenshotDataUrl = result.screenshotDataUrl;
|
|
||||||
}
|
|
||||||
if (result.displaySize) {
|
|
||||||
session.displaySize = result.displaySize;
|
|
||||||
}
|
|
||||||
if (result.cursor) {
|
|
||||||
session.cursor = { x: result.cursor.x, y: result.cursor.y, actor: session.cursor?.actor ?? 'agent' };
|
|
||||||
}
|
|
||||||
session.updatedAt = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripSessionArgs(args: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
const { sessionId: _sessionId, ...toolArgs } = args;
|
|
||||||
return toolArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshScreenshot(session: ComputerUseSession): Promise<void> {
|
|
||||||
if (getRuntime() === 'cloud') {
|
|
||||||
const result = (await desktopAgentRelay.relay('screenshot', { sessionId: session.id })) as RelayResult;
|
|
||||||
applyRelayResult(session, result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyRelayResult(session, await runRawComputerAction({ type: 'screenshot' }, session));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs one action and refreshes the session screenshot afterwards. */
|
|
||||||
async function performAction(session: ComputerUseSession, action: RawComputerAction): Promise<void> {
|
|
||||||
if (getRuntime() === 'cloud') {
|
|
||||||
const result = (await desktopAgentRelay.relay(action.type, {
|
|
||||||
...action,
|
|
||||||
sessionId: session.id,
|
|
||||||
displaySize: session.displaySize,
|
|
||||||
})) as RelayResult;
|
|
||||||
applyRelayResult(session, result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyRelayResult(session, await runRawComputerAction(action, session));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the current cursor position in screenshot-pixel space. */
|
|
||||||
async function getCursorPosition(session: ComputerUseSession): Promise<Point> {
|
|
||||||
if (getRuntime() === 'cloud') {
|
|
||||||
const result = (await desktopAgentRelay.relay('cursor_position', {
|
|
||||||
sessionId: session.id,
|
|
||||||
displaySize: session.displaySize,
|
|
||||||
})) as RelayResult;
|
|
||||||
applyRelayResult(session, result);
|
|
||||||
if (result.position) {
|
|
||||||
return result.position;
|
|
||||||
}
|
|
||||||
return session.cursor ? { x: session.cursor.x, y: session.cursor.y } : { x: 0, y: 0 };
|
|
||||||
}
|
|
||||||
const result = await runRawComputerAction({ type: 'cursor_position' }, session);
|
|
||||||
applyRelayResult(session, result);
|
|
||||||
return result.position || session.cursor || { x: 0, y: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertReady(session: ComputerUseSession): void {
|
|
||||||
if (session.status !== 'ready') {
|
|
||||||
throw new Error(session.message || 'Computer Use session is not available.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function agentToolsAvailable(): boolean {
|
|
||||||
const settings = readSettings();
|
|
||||||
if (!settings.enabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (getRuntime() === 'cloud') {
|
|
||||||
return desktopAgentRelay.isConnected();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertAgentToolsAvailable(): void {
|
|
||||||
if (agentToolsAvailable()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const settings = readSettings();
|
|
||||||
if (!settings.enabled) {
|
|
||||||
throw new Error('Computer Use agent tools are disabled.');
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
getRuntime() === 'cloud'
|
|
||||||
? 'No desktop is linked. Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.'
|
|
||||||
: 'Computer Use agent tools are disabled.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSessions(lastAction: string, message: string): void {
|
|
||||||
for (const session of sessions.values()) {
|
|
||||||
session.status = 'stopped';
|
|
||||||
session.agentAccessEnabled = false;
|
|
||||||
session.updatedAt = new Date().toISOString();
|
|
||||||
session.lastAction = lastAction;
|
|
||||||
session.message = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const computerUseService = {
|
|
||||||
async getSettings() {
|
|
||||||
return readSettings();
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateSettings(settings: Partial<ComputerUseSettings>) {
|
|
||||||
const current = readSettings();
|
|
||||||
const enabled = typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled;
|
|
||||||
const next = writeSettings({ enabled });
|
|
||||||
if (next.enabled) {
|
|
||||||
await this.registerAgentMcp();
|
|
||||||
} else {
|
|
||||||
await this.unregisterAgentMcp();
|
|
||||||
desktopAgentRelay.disconnectAll('Computer Use was disabled in this environment.');
|
|
||||||
stopSessions('settings:disabled', 'Computer Use was disabled in settings.');
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getStatus() {
|
|
||||||
const settings = readSettings();
|
|
||||||
const readiness = getRuntimeReadiness();
|
|
||||||
const isCloud = getRuntime() === 'cloud';
|
|
||||||
const runtimeReady = readiness.nutInstalled && readiness.screenshotInstalled;
|
|
||||||
// Cloud mode still respects the saved feature setting. When enabled, cloud
|
|
||||||
// availability comes from a linked desktop agent because the hosted server
|
|
||||||
// has no screen of its own.
|
|
||||||
const desktopAgentConnected = desktopAgentRelay.isConnected();
|
|
||||||
const available = settings.enabled && (isCloud
|
|
||||||
? desktopAgentConnected
|
|
||||||
: runtimeReady);
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: settings.enabled,
|
|
||||||
runtime: getRuntime(),
|
|
||||||
available,
|
|
||||||
desktopAgentConnected,
|
|
||||||
desktopAgentCount: desktopAgentRelay.connectedCount(),
|
|
||||||
nutInstalled: readiness.nutInstalled,
|
|
||||||
screenshotInstalled: readiness.screenshotInstalled,
|
|
||||||
installInProgress: readiness.installInProgress,
|
|
||||||
sessionCount: sessions.size,
|
|
||||||
message: available ? 'Computer Use runtime is available.' : getSetupMessage(settings, readiness),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async registerAgentMcp() {
|
|
||||||
const { command, args } = getMcpCommand();
|
|
||||||
const results = await providerMcpService.addMcpServerToAllProviders({
|
|
||||||
name: MCP_SERVER_NAME,
|
|
||||||
scope: 'user',
|
|
||||||
transport: 'stdio',
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
env: {
|
|
||||||
CLOUDCLI_COMPUTER_USE_MCP_TOKEN: getOrCreateMcpToken(),
|
|
||||||
CLOUDCLI_COMPUTER_USE_API_URL: getMcpApiUrl(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { name: MCP_SERVER_NAME, command, args, results };
|
|
||||||
},
|
|
||||||
|
|
||||||
getMcpToken() {
|
|
||||||
return getOrCreateMcpToken();
|
|
||||||
},
|
|
||||||
|
|
||||||
async unregisterAgentMcp() {
|
|
||||||
const results = await Promise.all(MCP_PROVIDERS.map(async (provider) => {
|
|
||||||
try {
|
|
||||||
const result = await providerMcpService.removeProviderMcpServer(provider, {
|
|
||||||
name: MCP_SERVER_NAME,
|
|
||||||
scope: 'user',
|
|
||||||
});
|
|
||||||
return { provider, removed: result.removed };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
removed: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
return { name: MCP_SERVER_NAME, results };
|
|
||||||
},
|
|
||||||
|
|
||||||
async installRuntime() {
|
|
||||||
const result = await installRuntime();
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
status: await this.getStatus(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async listSessions(owner: ComputerUseOwner) {
|
|
||||||
const ownerId = getOwnerId(owner);
|
|
||||||
await expireStaleSessions();
|
|
||||||
return [...sessions.values()]
|
|
||||||
.filter((session) => canAccessSession(ownerId, session))
|
|
||||||
.map(publicSession);
|
|
||||||
},
|
|
||||||
|
|
||||||
async createSession(owner: ComputerUseOwner, options?: { createdBy?: 'user' | 'agent' }) {
|
|
||||||
const ownerId = getOwnerId(owner);
|
|
||||||
await expireStaleSessions();
|
|
||||||
const createdBy = options?.createdBy ?? 'user';
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const session: ComputerUseSession = {
|
|
||||||
id: randomUUID(),
|
|
||||||
ownerId,
|
|
||||||
createdBy,
|
|
||||||
runtime: getRuntime(),
|
|
||||||
status: 'unavailable',
|
|
||||||
screenshotDataUrl: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
lastAction: 'create',
|
|
||||||
// Consent is always OFF at creation — the user must explicitly grant control,
|
|
||||||
// even for agent-initiated sessions controlling the full desktop.
|
|
||||||
agentAccessEnabled: false,
|
|
||||||
displaySize: null,
|
|
||||||
message: null,
|
|
||||||
cursor: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeOwnerSessions = ownerSessions(ownerId).filter((item) => item.status === 'ready');
|
|
||||||
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
|
|
||||||
throw new Error(`Computer Use is limited to ${MAX_SESSIONS_PER_OWNER} active session(s).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = readSettings();
|
|
||||||
const readiness = getRuntimeReadiness();
|
|
||||||
const isCloud = getRuntime() === 'cloud';
|
|
||||||
const runtimeReady = readiness.nutInstalled && readiness.screenshotInstalled;
|
|
||||||
const ready = settings.enabled && (isCloud
|
|
||||||
? desktopAgentRelay.isConnected()
|
|
||||||
: runtimeReady);
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
session.message = getSetupMessage(settings, readiness);
|
|
||||||
sessions.set(session.id, session);
|
|
||||||
return publicSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In cloud mode the linked desktop agent is the consent authority and prompts
|
|
||||||
// the user per its own consent mode, so the relay is allowed to act. In local
|
|
||||||
// mode the user must still grant control from the panel.
|
|
||||||
if (isCloud) {
|
|
||||||
session.agentAccessEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.status = 'ready';
|
|
||||||
session.message = isCloud
|
|
||||||
? 'Computer Use session is ready on the linked desktop.'
|
|
||||||
: 'Computer Use session is ready. Grant control to let agents act.';
|
|
||||||
sessions.set(session.id, session);
|
|
||||||
try {
|
|
||||||
await refreshScreenshot(session);
|
|
||||||
} catch (error) {
|
|
||||||
session.status = 'unavailable';
|
|
||||||
session.message = error instanceof Error ? error.message : 'Failed to capture the screen.';
|
|
||||||
}
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async grantAgentAccess(owner: ComputerUseOwner, sessionId: string) {
|
|
||||||
const ownerId = getOwnerId(owner);
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (!session || !canAccessSession(ownerId, session)) {
|
|
||||||
throw new Error('Computer Use session not found.');
|
|
||||||
}
|
|
||||||
session.agentAccessEnabled = true;
|
|
||||||
session.updatedAt = new Date().toISOString();
|
|
||||||
session.lastAction = 'consent:grant';
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async revokeAgentAccess(owner: ComputerUseOwner, sessionId: string) {
|
|
||||||
const ownerId = getOwnerId(owner);
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (!session || !canAccessSession(ownerId, session)) {
|
|
||||||
throw new Error('Computer Use session not found.');
|
|
||||||
}
|
|
||||||
session.agentAccessEnabled = false;
|
|
||||||
session.updatedAt = new Date().toISOString();
|
|
||||||
session.lastAction = 'consent:revoke';
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async stopSession(owner: ComputerUseOwner, sessionId: string) {
|
|
||||||
const ownerId = getOwnerId(owner);
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (!session || !canAccessSession(ownerId, session)) {
|
|
||||||
return { stopped: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
session.status = 'stopped';
|
|
||||||
session.agentAccessEnabled = false;
|
|
||||||
session.updatedAt = new Date().toISOString();
|
|
||||||
session.lastAction = 'stop';
|
|
||||||
session.message = 'Computer Use session stopped. Agent control is revoked.';
|
|
||||||
if (getRuntime() === 'cloud' && desktopAgentRelay.isConnected()) {
|
|
||||||
// Best-effort: tell the desktop agent to forget this session's consent.
|
|
||||||
void desktopAgentRelay.relay('stop_session', { sessionId }).catch(() => undefined);
|
|
||||||
}
|
|
||||||
return { stopped: true, session: publicSession(session) };
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteSession(owner: ComputerUseOwner, sessionId: string) {
|
|
||||||
const ownerId = getOwnerId(owner);
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (!session || !canAccessSession(ownerId, session)) {
|
|
||||||
return { deleted: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
return { deleted: true, sessionId };
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- User-initiated actions (from the panel) -------------------------------
|
|
||||||
|
|
||||||
async userScreenshot(owner: ComputerUseOwner, sessionId: string) {
|
|
||||||
const ownerId = getOwnerId(owner);
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (!session || !canAccessSession(ownerId, session)) {
|
|
||||||
throw new Error('Computer Use session not found.');
|
|
||||||
}
|
|
||||||
assertReady(session);
|
|
||||||
await refreshScreenshot(session);
|
|
||||||
session.lastAction = 'screenshot';
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async userClick(owner: ComputerUseOwner, sessionId: string, input: { x: number; y: number; button?: ClickButton; double?: boolean }) {
|
|
||||||
const ownerId = getOwnerId(owner);
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (!session || !canAccessSession(ownerId, session)) {
|
|
||||||
throw new Error('Computer Use session not found.');
|
|
||||||
}
|
|
||||||
assertReady(session);
|
|
||||||
await performAction(session, {
|
|
||||||
type: 'click',
|
|
||||||
button: input.button || 'left',
|
|
||||||
point: { x: input.x, y: input.y },
|
|
||||||
double: input.double === true,
|
|
||||||
});
|
|
||||||
session.cursor = { x: input.x, y: input.y, actor: 'user' };
|
|
||||||
session.lastAction = input.double ? 'double_click' : 'click';
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async userPressKey(owner: ComputerUseOwner, sessionId: string, key: string) {
|
|
||||||
const ownerId = getOwnerId(owner);
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (!session || !canAccessSession(ownerId, session)) {
|
|
||||||
throw new Error('Computer Use session not found.');
|
|
||||||
}
|
|
||||||
assertReady(session);
|
|
||||||
await performAction(session, { type: 'key', key });
|
|
||||||
session.lastAction = `key:${key}`;
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Agent-initiated actions (via MCP) ------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a session the agent is allowed to act on. In local mode this
|
|
||||||
* enforces the in-process per-session consent flag. In cloud mode the linked
|
|
||||||
* desktop agent is the consent authority (it prompts the user per its own
|
|
||||||
* consent mode), so this only requires the relay to be connected.
|
|
||||||
*/
|
|
||||||
async getOrCreateAgentSession(): Promise<ComputerUseSession> {
|
|
||||||
assertAgentToolsAvailable();
|
|
||||||
await expireStaleSessions();
|
|
||||||
const existing = findActiveAgentSession();
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await this.createSession({ id: AGENT_OWNER_ID }, { createdBy: 'agent' });
|
|
||||||
const session = sessions.get(created.id);
|
|
||||||
if (!session) {
|
|
||||||
throw new Error('Computer Use session could not be created.');
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getConsentedSession(sessionId?: string): Promise<ComputerUseSession> {
|
|
||||||
assertAgentToolsAvailable();
|
|
||||||
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
||||||
const session = normalizedSessionId
|
|
||||||
? sessions.get(normalizedSessionId)
|
|
||||||
: await this.getOrCreateAgentSession();
|
|
||||||
if (!session) {
|
|
||||||
throw new Error('Computer Use session not found.');
|
|
||||||
}
|
|
||||||
if (getRuntime() !== 'cloud' && !session.agentAccessEnabled) {
|
|
||||||
throw new Error(`Computer Use session ${session.id} is awaiting user consent. Ask the user to grant control in the Computer panel.`);
|
|
||||||
}
|
|
||||||
assertReady(session);
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentScreenshot(sessionId?: string) {
|
|
||||||
const session = await this.getConsentedSession(sessionId);
|
|
||||||
await refreshScreenshot(session);
|
|
||||||
session.lastAction = 'screenshot';
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentCursorPosition(sessionId?: string) {
|
|
||||||
const session = await this.getConsentedSession(sessionId);
|
|
||||||
const point = await getCursorPosition(session);
|
|
||||||
session.cursor = { ...point, actor: 'agent' };
|
|
||||||
session.lastAction = 'cursor_position';
|
|
||||||
return { session: publicSession(session), position: point };
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentMouseMove(sessionId: string | undefined, point: Point) {
|
|
||||||
const session = await this.getConsentedSession(sessionId);
|
|
||||||
await performAction(session, { type: 'mouse_move', point });
|
|
||||||
session.cursor = { ...point, actor: 'agent' };
|
|
||||||
session.lastAction = 'mouse_move';
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentUnifiedClick(sessionId: string | undefined, input: { button?: ClickButton; point?: Point; clickCount?: number }) {
|
|
||||||
const session = await this.getConsentedSession(sessionId);
|
|
||||||
const button = input.button || 'left';
|
|
||||||
const clickCount = Math.max(1, Math.min(Math.trunc(input.clickCount || 1), 5));
|
|
||||||
for (let index = 0; index < clickCount; index += 1) {
|
|
||||||
await performAction(session, { type: 'click', button, point: input.point, double: false });
|
|
||||||
}
|
|
||||||
if (input.point) {
|
|
||||||
session.cursor = { ...input.point, actor: 'agent' };
|
|
||||||
}
|
|
||||||
session.lastAction = clickCount > 1 ? `${button}_click:${clickCount}` : `${button}_click`;
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentDrag(sessionId: string | undefined, from: Point, to: Point, button: ClickButton = 'left') {
|
|
||||||
const session = await this.getConsentedSession(sessionId);
|
|
||||||
await performAction(session, { type: 'drag', from, to, button });
|
|
||||||
session.cursor = { ...to, actor: 'agent' };
|
|
||||||
session.lastAction = `${button}_drag`;
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentType(sessionId: string | undefined, text: string) {
|
|
||||||
const session = await this.getConsentedSession(sessionId);
|
|
||||||
await performAction(session, { type: 'type', text });
|
|
||||||
session.lastAction = 'type';
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentKey(sessionId: string | undefined, key: string) {
|
|
||||||
const session = await this.getConsentedSession(sessionId);
|
|
||||||
await performAction(session, { type: 'key', key });
|
|
||||||
session.lastAction = `key:${key}`;
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentScroll(sessionId: string | undefined, input: { direction: ScrollDirection; amount?: number; x?: number; y?: number }) {
|
|
||||||
const session = await this.getConsentedSession(sessionId);
|
|
||||||
const point = typeof input.x === 'number' && typeof input.y === 'number' ? { x: input.x, y: input.y } : undefined;
|
|
||||||
await performAction(session, { type: 'scroll', direction: input.direction, amount: input.amount, point });
|
|
||||||
if (point) {
|
|
||||||
session.cursor = { ...point, actor: 'agent' };
|
|
||||||
}
|
|
||||||
session.lastAction = `scroll:${input.direction}`;
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentWait(sessionId?: string, timeoutMs?: number) {
|
|
||||||
const session = await this.getConsentedSession(sessionId);
|
|
||||||
await performAction(session, { type: 'wait', ms: timeoutMs });
|
|
||||||
session.lastAction = 'wait';
|
|
||||||
return publicSession(session);
|
|
||||||
},
|
|
||||||
|
|
||||||
async agentStopSession(sessionId?: string) {
|
|
||||||
assertAgentToolsAvailable();
|
|
||||||
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
||||||
if (normalizedSessionId) {
|
|
||||||
return this.stopSession({ id: AGENT_OWNER_ID }, normalizedSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await expireStaleSessions();
|
|
||||||
const existing = findActiveAgentSession();
|
|
||||||
if (!existing) {
|
|
||||||
return { stopped: false };
|
|
||||||
}
|
|
||||||
return this.stopSession({ id: AGENT_OWNER_ID }, existing.id);
|
|
||||||
},
|
|
||||||
|
|
||||||
async callSemanticTool(toolName: string, args: Record<string, unknown>) {
|
|
||||||
if (!semanticOperationNames.has(toolName)) {
|
|
||||||
throw new Error(`Unsupported semantic Computer Use tool: ${toolName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = typeof args.sessionId === 'string' ? args.sessionId : undefined;
|
|
||||||
const session = await this.getConsentedSession(normalizeSessionId(sessionId) ?? undefined);
|
|
||||||
const toolArgs = { ...stripSessionArgs(args), sessionId: session.id };
|
|
||||||
const semanticResult = getRuntime() === 'cloud'
|
|
||||||
? await desktopAgentRelay.relay('semantic_tool', {
|
|
||||||
sessionId: session.id,
|
|
||||||
displaySize: session.displaySize,
|
|
||||||
toolName,
|
|
||||||
arguments: toolArgs,
|
|
||||||
})
|
|
||||||
: await computerSemanticsService.callTool(toolName, toolArgs);
|
|
||||||
|
|
||||||
applyRelayResult(session, semanticResult as RelayResult);
|
|
||||||
session.lastAction = `semantic:${toolName}`;
|
|
||||||
return { session: publicSession(session), result: semanticResult };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cloud only: when a desktop agent links to this hosted environment, expose
|
|
||||||
* the computer_* MCP tools only if the user enabled Computer Use in settings.
|
|
||||||
*/
|
|
||||||
async onDesktopAgentConnected() {
|
|
||||||
if (getRuntime() !== 'cloud') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!readSettings().enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this.registerAgentMcp();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[Computer Use] Failed to register MCP for linked desktop agent:', error instanceof Error ? error.message : error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Cloud only: tear down sessions when the last desktop agent disconnects. */
|
|
||||||
async onDesktopAgentDisconnected() {
|
|
||||||
if (getRuntime() !== 'cloud' || desktopAgentRelay.isConnected()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const session of sessions.values()) {
|
|
||||||
if (session.status === 'ready') {
|
|
||||||
session.status = 'stopped';
|
|
||||||
session.agentAccessEnabled = false;
|
|
||||||
session.updatedAt = new Date().toISOString();
|
|
||||||
session.lastAction = 'agent-disconnected';
|
|
||||||
session.message = 'The linked desktop agent disconnected.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async stopAllSessions() {
|
|
||||||
stopSessions('shutdown', 'Computer Use session stopped during server shutdown.');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Drive cloud MCP exposure + session teardown off desktop-agent connectivity.
|
|
||||||
desktopAgentRelay.setHooks({
|
|
||||||
canAcceptConnection: () => getRuntime() === 'cloud' && readSettings().enabled,
|
|
||||||
onFirstConnect: () => computerUseService.onDesktopAgentConnected(),
|
|
||||||
onLastDisconnect: () => computerUseService.onDesktopAgentDisconnected(),
|
|
||||||
});
|
|
||||||
|
|
||||||
process.once('beforeExit', () => {
|
|
||||||
void computerUseService.stopAllSessions();
|
|
||||||
});
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import type { WebSocket } from 'ws';
|
|
||||||
|
|
||||||
const RELAY_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_RELAY_TIMEOUT_MS || '60000', 10);
|
|
||||||
const WS_OPEN = 1;
|
|
||||||
|
|
||||||
type PendingRelay = {
|
|
||||||
resolve: (value: unknown) => void;
|
|
||||||
reject: (reason: Error) => void;
|
|
||||||
timer: ReturnType<typeof setTimeout>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConnectedAgent = {
|
|
||||||
ws: WebSocket;
|
|
||||||
label: string;
|
|
||||||
registeredAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RelayLifecycleHooks = {
|
|
||||||
canAcceptConnection?: () => boolean;
|
|
||||||
onFirstConnect?: () => void | Promise<void>;
|
|
||||||
onLastDisconnect?: () => void | Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const agents = new Map<WebSocket, ConnectedAgent>();
|
|
||||||
const pending = new Map<string, PendingRelay>();
|
|
||||||
let hooks: RelayLifecycleHooks = {};
|
|
||||||
|
|
||||||
function rejectAllPending(reason: string): void {
|
|
||||||
for (const [callId, call] of pending.entries()) {
|
|
||||||
clearTimeout(call.timer);
|
|
||||||
call.reject(new Error(reason));
|
|
||||||
pending.delete(callId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAgent(): ConnectedAgent | undefined {
|
|
||||||
for (const agent of agents.values()) {
|
|
||||||
if (agent.ws.readyState === WS_OPEN) {
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cloud-side registry of linked desktop agents and the request/response relay
|
|
||||||
* used to drive the user's real desktop. The hosted server never touches the OS
|
|
||||||
* itself — it only forwards `computer_*` actions to a connected desktop agent
|
|
||||||
* and awaits the screenshot it returns.
|
|
||||||
*/
|
|
||||||
export const desktopAgentRelay = {
|
|
||||||
setHooks(next: RelayLifecycleHooks): void {
|
|
||||||
hooks = next;
|
|
||||||
},
|
|
||||||
|
|
||||||
register(ws: WebSocket, label = 'desktop-agent'): boolean {
|
|
||||||
if (hooks.canAcceptConnection && !hooks.canAcceptConnection()) {
|
|
||||||
console.log(`[DesktopAgent] Rejected (${label}); Computer Use is disabled.`);
|
|
||||||
try {
|
|
||||||
ws.close(1008, 'Computer Use is disabled in this environment.');
|
|
||||||
} catch {
|
|
||||||
// ignore close failures
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasEmpty = pickAgent() === undefined;
|
|
||||||
agents.set(ws, { ws, label, registeredAt: new Date().toISOString() });
|
|
||||||
console.log(`[DesktopAgent] Registered (${label}); ${agents.size} connected.`);
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
const wasRegistered = agents.delete(ws);
|
|
||||||
console.log(`[DesktopAgent] Disconnected (${label}); ${agents.size} remain.`);
|
|
||||||
if (wasRegistered && pickAgent() === undefined) {
|
|
||||||
rejectAllPending('Desktop agent disconnected.');
|
|
||||||
void hooks.onLastDisconnect?.();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (wasEmpty) {
|
|
||||||
void hooks.onFirstConnect?.();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
disconnectAll(reason = 'Desktop agent disconnected.'): void {
|
|
||||||
const hadAgent = pickAgent() !== undefined;
|
|
||||||
const sockets = [...agents.keys()];
|
|
||||||
agents.clear();
|
|
||||||
for (const ws of sockets) {
|
|
||||||
try {
|
|
||||||
ws.close(1008, reason);
|
|
||||||
} catch {
|
|
||||||
// ignore close failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rejectAllPending(reason);
|
|
||||||
if (hadAgent) {
|
|
||||||
void hooks.onLastDisconnect?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Resolves a pending relay call with the desktop agent's reply. */
|
|
||||||
handleResult(id: string, result: unknown, error?: string): void {
|
|
||||||
const call = pending.get(id);
|
|
||||||
if (!call) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearTimeout(call.timer);
|
|
||||||
pending.delete(id);
|
|
||||||
if (error) {
|
|
||||||
call.reject(new Error(error));
|
|
||||||
} else {
|
|
||||||
call.resolve(result);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
isConnected(): boolean {
|
|
||||||
return pickAgent() !== undefined;
|
|
||||||
},
|
|
||||||
|
|
||||||
connectedCount(): number {
|
|
||||||
let count = 0;
|
|
||||||
for (const agent of agents.values()) {
|
|
||||||
if (agent.ws.readyState === WS_OPEN) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
},
|
|
||||||
|
|
||||||
async relay(type: string, params: Record<string, unknown>): Promise<unknown> {
|
|
||||||
const agent = pickAgent();
|
|
||||||
if (!agent) {
|
|
||||||
throw new Error(
|
|
||||||
'No desktop is linked. Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = randomUUID();
|
|
||||||
return new Promise<unknown>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
pending.delete(id);
|
|
||||||
reject(new Error('Desktop agent did not respond in time.'));
|
|
||||||
}, RELAY_TIMEOUT_MS);
|
|
||||||
pending.set(id, { resolve, reject, timer });
|
|
||||||
try {
|
|
||||||
agent.ws.send(JSON.stringify({ kind: 'computer_relay', id, type, params }));
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
pending.delete(id);
|
|
||||||
reject(error instanceof Error ? error : new Error('Failed to send to desktop agent.'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { computerUseService } from '@/modules/computer-use/computer-use.service.js';
|
|
||||||
export { desktopAgentRelay } from '@/modules/computer-use/desktop-agent-relay.service.js';
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { SemanticHelperProcess } from '@/modules/computer-use/semantics/helpers/semantic-helper-process.js';
|
|
||||||
import { resolveSemanticHelper } from '@/modules/computer-use/semantics/helpers/semantic-helper-resolver.js';
|
|
||||||
import type { SemanticAdapter, SemanticAdapterCapabilities } from '@/modules/computer-use/semantics/adapters/semantic-adapter.js';
|
|
||||||
import type { SemanticApp, SemanticAppState, SemanticToolInput } from '@/modules/computer-use/semantics/semantic-types.js';
|
|
||||||
|
|
||||||
type HelperMethod =
|
|
||||||
| 'list_apps'
|
|
||||||
| 'get_app_state'
|
|
||||||
| 'click_element'
|
|
||||||
| 'perform_secondary_action'
|
|
||||||
| 'set_value'
|
|
||||||
| 'type_text'
|
|
||||||
| 'press_key'
|
|
||||||
| 'scroll_element'
|
|
||||||
| 'drag';
|
|
||||||
|
|
||||||
export class HelperSemanticAdapter implements SemanticAdapter {
|
|
||||||
private helper: SemanticHelperProcess | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly platform: NodeJS.Platform,
|
|
||||||
private readonly arch: NodeJS.Architecture = process.arch,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
capabilities(): SemanticAdapterCapabilities {
|
|
||||||
return {
|
|
||||||
platform: this.platform,
|
|
||||||
appDiscovery: true,
|
|
||||||
accessibilityTree: true,
|
|
||||||
nativeElementActions: true,
|
|
||||||
nativeValueSetting: true,
|
|
||||||
targetedInput: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async listApps(): Promise<SemanticApp[]> {
|
|
||||||
return await this.request('list_apps', {}) as SemanticApp[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAppState(input: SemanticToolInput): Promise<SemanticAppState> {
|
|
||||||
return await this.request('get_app_state', input) as SemanticAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickElement(input: SemanticToolInput): Promise<SemanticAppState> {
|
|
||||||
return await this.request('click_element', input) as SemanticAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
async performSecondaryAction(input: SemanticToolInput): Promise<SemanticAppState> {
|
|
||||||
return await this.request('perform_secondary_action', input) as SemanticAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setValue(input: SemanticToolInput): Promise<SemanticAppState> {
|
|
||||||
return await this.request('set_value', input) as SemanticAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
async typeText(input: SemanticToolInput): Promise<SemanticAppState> {
|
|
||||||
return await this.request('type_text', input) as SemanticAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
async pressKey(input: SemanticToolInput): Promise<SemanticAppState> {
|
|
||||||
return await this.request('press_key', input) as SemanticAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
async scrollElement(input: SemanticToolInput): Promise<SemanticAppState> {
|
|
||||||
return await this.request('scroll_element', input) as SemanticAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
async drag(input: SemanticToolInput): Promise<SemanticAppState> {
|
|
||||||
return await this.request('drag', input) as SemanticAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request(method: HelperMethod, params: Record<string, unknown>): Promise<unknown> {
|
|
||||||
if (!this.helper) {
|
|
||||||
const resolution = resolveSemanticHelper(this.platform, this.arch);
|
|
||||||
if (!resolution.available || !resolution.path) {
|
|
||||||
throw new Error(resolution.reason || `Semantic helper is unavailable for ${this.platform}-${this.arch}.`);
|
|
||||||
}
|
|
||||||
this.helper = new SemanticHelperProcess(resolution.path);
|
|
||||||
}
|
|
||||||
return this.helper.request(method, params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { HelperSemanticAdapter } from '@/modules/computer-use/semantics/adapters/helper-semantic-adapter.js';
|
|
||||||
|
|
||||||
export function createMacOsSemanticAdapter(): HelperSemanticAdapter {
|
|
||||||
return new HelperSemanticAdapter('darwin');
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { SemanticApp, SemanticAppState, SemanticToolInput } from '@/modules/computer-use/semantics/semantic-types.js';
|
|
||||||
|
|
||||||
export type SemanticAdapterCapabilities = {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
appDiscovery: boolean;
|
|
||||||
accessibilityTree: boolean;
|
|
||||||
nativeElementActions: boolean;
|
|
||||||
nativeValueSetting: boolean;
|
|
||||||
targetedInput: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SemanticAdapter = {
|
|
||||||
capabilities(): SemanticAdapterCapabilities;
|
|
||||||
listApps(): Promise<SemanticApp[]>;
|
|
||||||
getAppState(input: SemanticToolInput): Promise<SemanticAppState>;
|
|
||||||
clickElement(input: SemanticToolInput): Promise<SemanticAppState>;
|
|
||||||
performSecondaryAction(input: SemanticToolInput): Promise<SemanticAppState>;
|
|
||||||
setValue(input: SemanticToolInput): Promise<SemanticAppState>;
|
|
||||||
typeText(input: SemanticToolInput): Promise<SemanticAppState>;
|
|
||||||
pressKey(input: SemanticToolInput): Promise<SemanticAppState>;
|
|
||||||
scrollElement(input: SemanticToolInput): Promise<SemanticAppState>;
|
|
||||||
drag(input: SemanticToolInput): Promise<SemanticAppState>;
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { HelperSemanticAdapter } from '@/modules/computer-use/semantics/adapters/helper-semantic-adapter.js';
|
|
||||||
|
|
||||||
export function createWindowsSemanticAdapter(): HelperSemanticAdapter {
|
|
||||||
return new HelperSemanticAdapter('win32');
|
|
||||||
}
|
|
||||||
@@ -1,467 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import ApplicationServices
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
typealias JSON = [String: Any]
|
|
||||||
|
|
||||||
struct ElementRecord {
|
|
||||||
let index: String
|
|
||||||
let role: String
|
|
||||||
let title: String?
|
|
||||||
let value: String?
|
|
||||||
let bounds: [String: Double]?
|
|
||||||
let actions: [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
var stateElements: [String: [ElementRecord]] = [:]
|
|
||||||
var stateAxElements: [String: [String: AXUIElement]] = [:]
|
|
||||||
var stateOrder: [String] = []
|
|
||||||
let maxStoredStates = 100
|
|
||||||
|
|
||||||
func jsonLine(_ object: Any) {
|
|
||||||
guard JSONSerialization.isValidJSONObject(object),
|
|
||||||
let data = try? JSONSerialization.data(withJSONObject: object),
|
|
||||||
let text = String(data: data, encoding: .utf8)
|
|
||||||
else {
|
|
||||||
print("{\"error\":\"Failed to encode JSON\"}")
|
|
||||||
fflush(stdout)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
print(text)
|
|
||||||
fflush(stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func respond(id: Any?, result: Any) {
|
|
||||||
jsonLine(["id": id ?? NSNull(), "result": result])
|
|
||||||
}
|
|
||||||
|
|
||||||
func respondError(id: Any?, _ message: String) {
|
|
||||||
jsonLine(["id": id ?? NSNull(), "error": message])
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringAttr(_ element: AXUIElement, _ attr: CFString) -> String? {
|
|
||||||
var value: CFTypeRef?
|
|
||||||
guard AXUIElementCopyAttributeValue(element, attr, &value) == .success else { return nil }
|
|
||||||
return value as? String
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolAttr(_ element: AXUIElement, _ attr: CFString) -> Bool? {
|
|
||||||
var value: CFTypeRef?
|
|
||||||
guard AXUIElementCopyAttributeValue(element, attr, &value) == .success else { return nil }
|
|
||||||
return value as? Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func arrayAttr(_ element: AXUIElement, _ attr: CFString) -> [AXUIElement] {
|
|
||||||
var value: CFTypeRef?
|
|
||||||
guard AXUIElementCopyAttributeValue(element, attr, &value) == .success else { return [] }
|
|
||||||
return value as? [AXUIElement] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
func actions(_ element: AXUIElement) -> [String] {
|
|
||||||
var names: CFArray?
|
|
||||||
guard AXUIElementCopyActionNames(element, &names) == .success else { return [] }
|
|
||||||
return names as? [String] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
func bounds(_ element: AXUIElement) -> [String: Double]? {
|
|
||||||
var positionRef: CFTypeRef?
|
|
||||||
var sizeRef: CFTypeRef?
|
|
||||||
guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionRef) == .success,
|
|
||||||
AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeRef) == .success,
|
|
||||||
let positionValue = positionRef,
|
|
||||||
let sizeValue = sizeRef
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
var point = CGPoint.zero
|
|
||||||
var size = CGSize.zero
|
|
||||||
guard CFGetTypeID(positionValue) == AXValueGetTypeID(),
|
|
||||||
CFGetTypeID(sizeValue) == AXValueGetTypeID()
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
let positionAxValue = positionValue as! AXValue
|
|
||||||
let sizeAxValue = sizeValue as! AXValue
|
|
||||||
guard AXValueGetValue(positionAxValue, .cgPoint, &point),
|
|
||||||
AXValueGetValue(sizeAxValue, .cgSize, &size)
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
return [
|
|
||||||
"x": Double(point.x),
|
|
||||||
"y": Double(point.y),
|
|
||||||
"width": Double(size.width),
|
|
||||||
"height": Double(size.height),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
func record(_ element: AXUIElement, index: String) -> ElementRecord {
|
|
||||||
ElementRecord(
|
|
||||||
index: index,
|
|
||||||
role: stringAttr(element, kAXRoleAttribute as CFString) ?? "AXUnknown",
|
|
||||||
title: stringAttr(element, kAXTitleAttribute as CFString) ?? stringAttr(element, kAXDescriptionAttribute as CFString),
|
|
||||||
value: stringAttr(element, kAXValueAttribute as CFString),
|
|
||||||
bounds: bounds(element),
|
|
||||||
actions: actions(element)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cachedElement(_ params: JSON) -> AXUIElement? {
|
|
||||||
guard let stateId = params["stateId"] as? String,
|
|
||||||
let elementIndex = params["element_index"] as? String
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return stateAxElements[stateId]?[elementIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
func dictionary(_ record: ElementRecord) -> JSON {
|
|
||||||
var output: JSON = [
|
|
||||||
"index": record.index,
|
|
||||||
"role": record.role,
|
|
||||||
"actions": record.actions,
|
|
||||||
]
|
|
||||||
if let title = record.title { output["title"] = title }
|
|
||||||
if let value = record.value { output["value"] = value }
|
|
||||||
if let bounds = record.bounds { output["bounds"] = bounds }
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
func pruneStoredStates() {
|
|
||||||
while stateOrder.count > maxStoredStates {
|
|
||||||
let evicted = stateOrder.removeFirst()
|
|
||||||
stateElements.removeValue(forKey: evicted)
|
|
||||||
stateAxElements.removeValue(forKey: evicted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveApp(_ query: String) throws -> NSRunningApplication {
|
|
||||||
let normalized = query.lowercased()
|
|
||||||
let apps = NSWorkspace.shared.runningApplications.filter { app in
|
|
||||||
app.activationPolicy == .regular
|
|
||||||
}
|
|
||||||
if let app = apps.first(where: { $0.bundleIdentifier?.lowercased() == normalized }) {
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
if let app = apps.first(where: { ($0.localizedName ?? "").lowercased() == normalized }) {
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
if let app = apps.first(where: { ($0.localizedName ?? "").lowercased().contains(normalized) }) {
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: 404, userInfo: [NSLocalizedDescriptionKey: "App is not running: \(query)"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func listApps() -> [[String: Any]] {
|
|
||||||
NSWorkspace.shared.runningApplications
|
|
||||||
.filter { $0.activationPolicy == .regular }
|
|
||||||
.map { app in
|
|
||||||
[
|
|
||||||
"id": app.bundleIdentifier ?? app.localizedName ?? "\(app.processIdentifier)",
|
|
||||||
"name": app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
|
||||||
"bundleIdentifier": app.bundleIdentifier ?? "",
|
|
||||||
"pid": Int(app.processIdentifier),
|
|
||||||
"running": true,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func walk(_ element: AXUIElement, depth: Int, maxDepth: Int, records: inout [ElementRecord], axRecords: inout [String: AXUIElement], limit: Int) {
|
|
||||||
if depth > maxDepth || records.count >= limit { return }
|
|
||||||
let index = "\(records.count + 1)"
|
|
||||||
records.append(record(element, index: index))
|
|
||||||
axRecords[index] = element
|
|
||||||
for child in arrayAttr(element, kAXChildrenAttribute as CFString) {
|
|
||||||
walk(child, depth: depth + 1, maxDepth: maxDepth, records: &records, axRecords: &axRecords, limit: limit)
|
|
||||||
if records.count >= limit { return }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pngDataUrlForMainDisplay() -> String? {
|
|
||||||
let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("cloudcli-semantics-\(UUID().uuidString).png")
|
|
||||||
let process = Process()
|
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
|
|
||||||
process.arguments = ["-x", "-t", "png", fileURL.path]
|
|
||||||
|
|
||||||
do {
|
|
||||||
try process.run()
|
|
||||||
process.waitUntilExit()
|
|
||||||
guard process.terminationStatus == 0 else { return nil }
|
|
||||||
let png = try Data(contentsOf: fileURL)
|
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
|
||||||
return png.isEmpty ? nil : "data:image/png;base64,\(png.base64EncodedString())"
|
|
||||||
} catch {
|
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAppState(_ params: JSON) throws -> JSON {
|
|
||||||
let appName = params["app"] as? String ?? ""
|
|
||||||
let app = try resolveApp(appName)
|
|
||||||
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
|
||||||
let windows = arrayAttr(axApp, kAXWindowsAttribute as CFString)
|
|
||||||
let root = windows.first ?? axApp
|
|
||||||
var records: [ElementRecord] = []
|
|
||||||
var axRecords: [String: AXUIElement] = [:]
|
|
||||||
walk(root, depth: 0, maxDepth: 5, records: &records, axRecords: &axRecords, limit: 300)
|
|
||||||
let stateId = "state_\(UUID().uuidString)"
|
|
||||||
stateElements[stateId] = records
|
|
||||||
stateAxElements[stateId] = axRecords
|
|
||||||
stateOrder.append(stateId)
|
|
||||||
pruneStoredStates()
|
|
||||||
|
|
||||||
let elements = records.map(dictionary)
|
|
||||||
return [
|
|
||||||
"stateId": stateId,
|
|
||||||
"app": app.localizedName ?? app.bundleIdentifier ?? appName,
|
|
||||||
"platform": "darwin",
|
|
||||||
"screenshotDataUrl": pngDataUrlForMainDisplay() ?? NSNull(),
|
|
||||||
"displaySize": [
|
|
||||||
"width": Int(CGDisplayPixelsWide(CGMainDisplayID())),
|
|
||||||
"height": Int(CGDisplayPixelsHigh(CGMainDisplayID())),
|
|
||||||
],
|
|
||||||
"elements": elements,
|
|
||||||
"accessibilityTree": elements,
|
|
||||||
"treeText": elements.map { "\($0["index"] ?? "") \($0["role"] ?? "") \($0["title"] ?? "")" }.joined(separator: "\n"),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
func cgMouseButton(_ value: Any?) -> CGMouseButton {
|
|
||||||
guard let button = value as? String else { return .left }
|
|
||||||
switch button {
|
|
||||||
case "right": return .right
|
|
||||||
case "middle": return .center
|
|
||||||
default: return .left
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mouseEventTypes(_ button: CGMouseButton) -> (CGEventType, CGEventType) {
|
|
||||||
switch button {
|
|
||||||
case .right: return (.rightMouseDown, .rightMouseUp)
|
|
||||||
case .center: return (.otherMouseDown, .otherMouseUp)
|
|
||||||
default: return (.leftMouseDown, .leftMouseUp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postMouseClick(point: CGPoint, button: CGMouseButton, clickCount: Int = 1) throws {
|
|
||||||
guard let source = CGEventSource(stateID: .combinedSessionState) else {
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: 500, userInfo: [NSLocalizedDescriptionKey: "Failed to create CGEventSource"])
|
|
||||||
}
|
|
||||||
let eventTypes = mouseEventTypes(button)
|
|
||||||
for _ in 0..<max(1, clickCount) {
|
|
||||||
let down = CGEvent(mouseEventSource: source, mouseType: eventTypes.0, mouseCursorPosition: point, mouseButton: button)
|
|
||||||
let up = CGEvent(mouseEventSource: source, mouseType: eventTypes.1, mouseCursorPosition: point, mouseButton: button)
|
|
||||||
down?.post(tap: .cghidEventTap)
|
|
||||||
up?.post(tap: .cghidEventTap)
|
|
||||||
usleep(80_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postDrag(from: CGPoint, to: CGPoint, button: CGMouseButton) throws {
|
|
||||||
guard let source = CGEventSource(stateID: .combinedSessionState) else {
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: 500, userInfo: [NSLocalizedDescriptionKey: "Failed to create CGEventSource"])
|
|
||||||
}
|
|
||||||
let eventTypes = mouseEventTypes(button)
|
|
||||||
CGEvent(mouseEventSource: source, mouseType: eventTypes.0, mouseCursorPosition: from, mouseButton: button)?.post(tap: .cghidEventTap)
|
|
||||||
usleep(80_000)
|
|
||||||
CGEvent(mouseEventSource: source, mouseType: .leftMouseDragged, mouseCursorPosition: to, mouseButton: button)?.post(tap: .cghidEventTap)
|
|
||||||
usleep(80_000)
|
|
||||||
CGEvent(mouseEventSource: source, mouseType: eventTypes.1, mouseCursorPosition: to, mouseButton: button)?.post(tap: .cghidEventTap)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runAppleScript(_ script: String) throws {
|
|
||||||
let process = Process()
|
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
||||||
process.arguments = ["-e", script]
|
|
||||||
process.standardOutput = Pipe()
|
|
||||||
let stderr = Pipe()
|
|
||||||
process.standardError = stderr
|
|
||||||
try process.run()
|
|
||||||
process.waitUntilExit()
|
|
||||||
if process.terminationStatus != 0 {
|
|
||||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
let message = String(data: data, encoding: .utf8) ?? "AppleScript failed."
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: message])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapedAppleScriptString(_ value: String) -> String {
|
|
||||||
value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
func pointForElement(_ params: JSON) -> CGPoint? {
|
|
||||||
if let x = params["x"] as? Double, let y = params["y"] as? Double {
|
|
||||||
return CGPoint(x: x, y: y)
|
|
||||||
}
|
|
||||||
guard let stateId = params["stateId"] as? String,
|
|
||||||
let elementIndex = params["element_index"] as? String,
|
|
||||||
let element = stateElements[stateId]?.first(where: { $0.index == elementIndex }),
|
|
||||||
let b = element.bounds,
|
|
||||||
let x = b["x"], let y = b["y"], let width = b["width"], let height = b["height"]
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return CGPoint(x: x + width / 2, y: y + height / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func click(_ params: JSON) throws -> JSON {
|
|
||||||
if let element = cachedElement(params),
|
|
||||||
cgMouseButton(params["mouse_button"]) == .left,
|
|
||||||
(params["click_count"] as? Int ?? 1) == 1,
|
|
||||||
actions(element).contains(kAXPressAction as String),
|
|
||||||
AXUIElementPerformAction(element, kAXPressAction as CFString) == .success {
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let point = pointForElement(params) else {
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "click_element requires x/y or stateId + element_index"])
|
|
||||||
}
|
|
||||||
let clickCount = params["click_count"] as? Int ?? 1
|
|
||||||
try postMouseClick(point: point, button: cgMouseButton(params["mouse_button"]), clickCount: clickCount)
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func performSecondaryAction(_ params: JSON) throws -> JSON {
|
|
||||||
if let element = cachedElement(params),
|
|
||||||
actions(element).contains(kAXShowMenuAction as String),
|
|
||||||
AXUIElementPerformAction(element, kAXShowMenuAction as CFString) == .success {
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
guard let point = pointForElement(params) else {
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "perform_secondary_action requires x/y or stateId + element_index"])
|
|
||||||
}
|
|
||||||
try postMouseClick(point: point, button: .right)
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setValue(_ params: JSON) throws -> JSON {
|
|
||||||
guard let value = params["value"] as? String else {
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "set_value requires value"])
|
|
||||||
}
|
|
||||||
if let element = cachedElement(params),
|
|
||||||
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success {
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
guard let point = pointForElement(params) else {
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "set_value requires x/y or stateId + element_index"])
|
|
||||||
}
|
|
||||||
try postMouseClick(point: point, button: .left)
|
|
||||||
try runAppleScript("tell application \"System Events\" to keystroke \"a\" using command down")
|
|
||||||
try runAppleScript("tell application \"System Events\" to keystroke \"\(escapedAppleScriptString(value))\"")
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func typeText(_ params: JSON) throws -> JSON {
|
|
||||||
let text = params["text"] as? String ?? ""
|
|
||||||
try runAppleScript("tell application \"System Events\" to keystroke \"\(escapedAppleScriptString(text))\"")
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func appleScriptModifiers(_ parts: [String]) -> String {
|
|
||||||
let modifiers = parts.compactMap { part -> String? in
|
|
||||||
switch part.lowercased() {
|
|
||||||
case "cmd", "command", "meta": return "command down"
|
|
||||||
case "ctrl", "control": return "control down"
|
|
||||||
case "alt", "option": return "option down"
|
|
||||||
case "shift": return "shift down"
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return modifiers.isEmpty ? "" : " using {\(modifiers.joined(separator: ", "))}"
|
|
||||||
}
|
|
||||||
|
|
||||||
func appleScriptKeyCode(_ key: String) -> Int? {
|
|
||||||
switch key.lowercased() {
|
|
||||||
case "return", "enter": return 36
|
|
||||||
case "tab": return 48
|
|
||||||
case "space": return 49
|
|
||||||
case "delete", "backspace": return 51
|
|
||||||
case "escape", "esc": return 53
|
|
||||||
case "left": return 123
|
|
||||||
case "right": return 124
|
|
||||||
case "down": return 125
|
|
||||||
case "up": return 126
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pressKey(_ params: JSON) throws -> JSON {
|
|
||||||
let raw = params["key"] as? String ?? ""
|
|
||||||
let parts = raw.split(separator: "+").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
|
|
||||||
let key = parts.last ?? raw
|
|
||||||
let modifiers = appleScriptModifiers(Array(parts.dropLast()))
|
|
||||||
if let keyCode = appleScriptKeyCode(key) {
|
|
||||||
try runAppleScript("tell application \"System Events\" to key code \(keyCode)\(modifiers)")
|
|
||||||
} else {
|
|
||||||
try runAppleScript("tell application \"System Events\" to keystroke \"\(escapedAppleScriptString(key))\"\(modifiers)")
|
|
||||||
}
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollElement(_ params: JSON) throws -> JSON {
|
|
||||||
guard let point = pointForElement(params) else {
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "scroll_element requires x/y or stateId + element_index"])
|
|
||||||
}
|
|
||||||
CGWarpMouseCursorPosition(point)
|
|
||||||
let direction = params["direction"] as? String ?? "down"
|
|
||||||
let pages = params["pages"] as? Double ?? 1.0
|
|
||||||
let amount = Int32(max(1.0, abs(pages) * 8.0))
|
|
||||||
let vertical = direction == "up" ? amount : direction == "down" ? -amount : 0
|
|
||||||
let horizontal = direction == "left" ? amount : direction == "right" ? -amount : 0
|
|
||||||
CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 2, wheel1: vertical, wheel2: horizontal, wheel3: 0)?.post(tap: .cghidEventTap)
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func drag(_ params: JSON) throws -> JSON {
|
|
||||||
guard let fromX = params["from_x"] as? Double,
|
|
||||||
let fromY = params["from_y"] as? Double,
|
|
||||||
let toX = params["to_x"] as? Double,
|
|
||||||
let toY = params["to_y"] as? Double
|
|
||||||
else {
|
|
||||||
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "drag requires from_x/from_y/to_x/to_y"])
|
|
||||||
}
|
|
||||||
try postDrag(from: CGPoint(x: fromX, y: fromY), to: CGPoint(x: toX, y: toY), button: cgMouseButton(params["mouse_button"]))
|
|
||||||
return try getAppState(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handle(_ request: JSON) {
|
|
||||||
let id = request["id"]
|
|
||||||
let method = request["method"] as? String ?? ""
|
|
||||||
let params = request["params"] as? JSON ?? [:]
|
|
||||||
|
|
||||||
do {
|
|
||||||
switch method {
|
|
||||||
case "list_apps":
|
|
||||||
respond(id: id, result: listApps())
|
|
||||||
case "get_app_state":
|
|
||||||
respond(id: id, result: try getAppState(params))
|
|
||||||
case "click_element":
|
|
||||||
respond(id: id, result: try click(params))
|
|
||||||
case "perform_secondary_action":
|
|
||||||
respond(id: id, result: try performSecondaryAction(params))
|
|
||||||
case "set_value":
|
|
||||||
respond(id: id, result: try setValue(params))
|
|
||||||
case "type_text":
|
|
||||||
respond(id: id, result: try typeText(params))
|
|
||||||
case "press_key":
|
|
||||||
respond(id: id, result: try pressKey(params))
|
|
||||||
case "scroll_element":
|
|
||||||
respond(id: id, result: try scrollElement(params))
|
|
||||||
case "drag":
|
|
||||||
respond(id: id, result: try drag(params))
|
|
||||||
default:
|
|
||||||
respondError(id: id, "Method is not implemented yet: \(method)")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
respondError(id: id, error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while let line = readLine() {
|
|
||||||
guard let data = line.data(using: .utf8),
|
|
||||||
let object = try? JSONSerialization.jsonObject(with: data),
|
|
||||||
let request = object as? JSON
|
|
||||||
else {
|
|
||||||
respondError(id: nil, "Invalid JSON request")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
handle(request)
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
||||||
import readline from 'node:readline';
|
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
|
||||||
|
|
||||||
type PendingRequest = {
|
|
||||||
resolve: (value: unknown) => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
timer: ReturnType<typeof setTimeout>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_SEMANTICS_HELPER_TIMEOUT_MS || '60000', 10);
|
|
||||||
|
|
||||||
function timeoutMs(): number {
|
|
||||||
return Number.isFinite(DEFAULT_TIMEOUT_MS) && DEFAULT_TIMEOUT_MS > 0 ? DEFAULT_TIMEOUT_MS : 60000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SemanticHelperProcess {
|
|
||||||
private child: ChildProcessWithoutNullStreams | null = null;
|
|
||||||
private reader: readline.Interface | null = null;
|
|
||||||
private nextId = 1;
|
|
||||||
private pending = new Map<number, PendingRequest>();
|
|
||||||
|
|
||||||
constructor(private readonly executablePath: string) {}
|
|
||||||
|
|
||||||
async request(method: string, params: JsonRecord): Promise<unknown> {
|
|
||||||
this.ensureStarted();
|
|
||||||
const child = this.child;
|
|
||||||
if (!child?.stdin.writable) {
|
|
||||||
throw new Error('Semantic helper process is not running.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = this.nextId++;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
this.pending.delete(id);
|
|
||||||
reject(new Error(`Semantic helper request timed out: ${method}`));
|
|
||||||
}, timeoutMs());
|
|
||||||
this.pending.set(id, { resolve, reject, timer });
|
|
||||||
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
const child = this.child;
|
|
||||||
this.child = null;
|
|
||||||
this.reader?.close();
|
|
||||||
this.reader = null;
|
|
||||||
this.rejectAll('Semantic helper stopped.');
|
|
||||||
if (child) {
|
|
||||||
try { child.kill('SIGTERM'); } catch { /* noop */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureStarted(): void {
|
|
||||||
if (this.child) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.child = spawn(this.executablePath, [], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.reader = readline.createInterface({ input: this.child.stdout });
|
|
||||||
this.reader.on('line', (line) => this.handleLine(line));
|
|
||||||
|
|
||||||
this.child.stderr.on('data', (chunk) => {
|
|
||||||
const text = String(chunk).trim();
|
|
||||||
if (text) {
|
|
||||||
console.error('[SemanticHelper]', text);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.child.once('error', (error) => {
|
|
||||||
this.child = null;
|
|
||||||
this.rejectAll(`Failed to start semantic helper: ${error.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.child.once('exit', (code) => {
|
|
||||||
this.child = null;
|
|
||||||
this.rejectAll(`Semantic helper exited with code ${code ?? 'null'}.`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleLine(line: string): void {
|
|
||||||
let message: JsonRecord;
|
|
||||||
try {
|
|
||||||
message = JSON.parse(line) as JsonRecord;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[SemanticHelper] Invalid JSON response:', errorMessage(error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = typeof message.id === 'number' ? message.id : null;
|
|
||||||
if (id === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pending = this.pending.get(id);
|
|
||||||
if (!pending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearTimeout(pending.timer);
|
|
||||||
this.pending.delete(id);
|
|
||||||
|
|
||||||
if (message.error) {
|
|
||||||
pending.reject(new Error(typeof message.error === 'string' ? message.error : 'Semantic helper request failed.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pending.resolve(message.result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private rejectAll(reason: string): void {
|
|
||||||
for (const [id, request] of this.pending.entries()) {
|
|
||||||
clearTimeout(request.timer);
|
|
||||||
request.reject(new Error(reason));
|
|
||||||
this.pending.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
export type SemanticHelperPlatform = 'darwin' | 'win32';
|
|
||||||
|
|
||||||
export type SemanticHelperResolution = {
|
|
||||||
available: boolean;
|
|
||||||
path: string | null;
|
|
||||||
source: 'bundled' | 'dev' | 'missing';
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
arch: NodeJS.Architecture;
|
|
||||||
reason?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function helperExecutableName(platform: NodeJS.Platform): string | null {
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
return 'CloudCLISemantics';
|
|
||||||
}
|
|
||||||
if (platform === 'win32') {
|
|
||||||
return 'CloudCLISemantics.exe';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathExists(filePath: string): boolean {
|
|
||||||
try {
|
|
||||||
fs.accessSync(filePath, fs.constants.X_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
fs.accessSync(filePath, fs.constants.F_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function candidatePaths(platform: NodeJS.Platform, arch: NodeJS.Architecture): Array<{ source: 'bundled' | 'dev'; path: string }> {
|
|
||||||
const executable = helperExecutableName(platform);
|
|
||||||
if (!executable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformArch = `${platform}-${arch}`;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: 'bundled',
|
|
||||||
path: path.resolve(__dirname, '..', 'bin', platformArch, executable),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: 'dev',
|
|
||||||
path: path.resolve(process.cwd(), 'server', 'modules', 'computer-use', 'semantics', 'bin', platformArch, executable),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSemanticHelper(
|
|
||||||
platform: NodeJS.Platform = process.platform,
|
|
||||||
arch: NodeJS.Architecture = process.arch,
|
|
||||||
): SemanticHelperResolution {
|
|
||||||
const executable = helperExecutableName(platform);
|
|
||||||
if (!executable) {
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
path: null,
|
|
||||||
source: 'missing',
|
|
||||||
platform,
|
|
||||||
arch,
|
|
||||||
reason: `Semantic Computer Use helper is not supported on ${platform}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of candidatePaths(platform, arch)) {
|
|
||||||
if (pathExists(candidate.path)) {
|
|
||||||
return {
|
|
||||||
available: true,
|
|
||||||
path: candidate.path,
|
|
||||||
source: candidate.source,
|
|
||||||
platform,
|
|
||||||
arch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
path: null,
|
|
||||||
source: 'missing',
|
|
||||||
platform,
|
|
||||||
arch,
|
|
||||||
reason: `Bundled semantic helper was not found for ${platform}-${arch} (${executable}).`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
|
||||||
<UseWPF>true</UseWPF>
|
|
||||||
<AssemblyName>CloudCLISemantics</AssemblyName>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Drawing;
|
|
||||||
using System.Drawing.Imaging;
|
|
||||||
using System.IO;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Windows.Automation;
|
|
||||||
|
|
||||||
static class Program
|
|
||||||
{
|
|
||||||
private const int MaxStoredStates = 100;
|
|
||||||
private static readonly Dictionary<string, List<ElementRecord>> StateElements = new();
|
|
||||||
private static readonly Dictionary<string, Dictionary<string, AutomationElement>> StateAutomationElements = new();
|
|
||||||
private static readonly Queue<string> StateOrder = new();
|
|
||||||
|
|
||||||
public static void Main()
|
|
||||||
{
|
|
||||||
string? line;
|
|
||||||
while ((line = Console.ReadLine()) != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(line);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
var id = root.TryGetProperty("id", out var idValue) ? idValue.Clone() : default;
|
|
||||||
var method = root.TryGetProperty("method", out var methodValue) ? methodValue.GetString() ?? "" : "";
|
|
||||||
var parameters = root.TryGetProperty("params", out var paramsValue) && paramsValue.ValueKind == JsonValueKind.Object
|
|
||||||
? paramsValue.Clone()
|
|
||||||
: JsonDocument.Parse("{}").RootElement.Clone();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
object result = method switch
|
|
||||||
{
|
|
||||||
"list_apps" => ListApps(),
|
|
||||||
"get_app_state" => GetAppState(parameters),
|
|
||||||
"click_element" => ClickElement(parameters),
|
|
||||||
"perform_secondary_action" => PerformSecondaryAction(parameters),
|
|
||||||
"set_value" => SetValue(parameters),
|
|
||||||
"type_text" => TypeText(parameters),
|
|
||||||
"press_key" => PressKey(parameters),
|
|
||||||
"scroll_element" => ScrollElement(parameters),
|
|
||||||
"drag" => Drag(parameters),
|
|
||||||
_ => throw new InvalidOperationException($"Method is not implemented yet: {method}")
|
|
||||||
};
|
|
||||||
Write(new Dictionary<string, object?> { ["id"] = JsonValue(id), ["result"] = result });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Write(new Dictionary<string, object?> { ["id"] = JsonValue(id), ["error"] = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Write(new Dictionary<string, object?> { ["id"] = null, ["error"] = $"Invalid JSON request: {ex.Message}" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object? JsonValue(JsonElement element)
|
|
||||||
{
|
|
||||||
return element.ValueKind switch
|
|
||||||
{
|
|
||||||
JsonValueKind.String => element.GetString(),
|
|
||||||
JsonValueKind.Number => element.TryGetInt64(out var number) ? number : element.GetDouble(),
|
|
||||||
JsonValueKind.True => true,
|
|
||||||
JsonValueKind.False => false,
|
|
||||||
_ => null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Write(object value)
|
|
||||||
{
|
|
||||||
Console.WriteLine(JsonSerializer.Serialize(value));
|
|
||||||
Console.Out.Flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Dictionary<string, object?>> ListApps()
|
|
||||||
{
|
|
||||||
return Process.GetProcesses()
|
|
||||||
.Where(process => process.MainWindowHandle != IntPtr.Zero)
|
|
||||||
.OrderBy(process => process.ProcessName)
|
|
||||||
.Select(process => new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["id"] = process.Id.ToString(),
|
|
||||||
["name"] = process.ProcessName,
|
|
||||||
["processName"] = process.ProcessName,
|
|
||||||
["pid"] = process.Id,
|
|
||||||
["running"] = true,
|
|
||||||
["windowTitle"] = process.MainWindowTitle
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Process ResolveProcess(string query)
|
|
||||||
{
|
|
||||||
var normalized = query.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(normalized))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("app is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var processes = Process.GetProcesses()
|
|
||||||
.Where(process => process.MainWindowHandle != IntPtr.Zero)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return processes.FirstOrDefault(process => process.ProcessName.Equals(normalized, StringComparison.OrdinalIgnoreCase))
|
|
||||||
?? processes.FirstOrDefault(process => process.MainWindowTitle.Equals(normalized, StringComparison.OrdinalIgnoreCase))
|
|
||||||
?? processes.FirstOrDefault(process => process.MainWindowTitle.Contains(normalized, StringComparison.OrdinalIgnoreCase))
|
|
||||||
?? throw new InvalidOperationException($"App is not running: {query}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> GetAppState(JsonElement parameters)
|
|
||||||
{
|
|
||||||
var appQuery = ReadString(parameters, "app");
|
|
||||||
var process = ResolveProcess(appQuery);
|
|
||||||
var root = AutomationElement.FromHandle(process.MainWindowHandle)
|
|
||||||
?? throw new InvalidOperationException("No UI Automation root window is available.");
|
|
||||||
|
|
||||||
var records = new List<ElementRecord>();
|
|
||||||
var automationElements = new Dictionary<string, AutomationElement>();
|
|
||||||
Walk(root, records, automationElements, 0, 5, 300);
|
|
||||||
var stateId = $"state_{Guid.NewGuid()}";
|
|
||||||
StateElements[stateId] = records;
|
|
||||||
StateAutomationElements[stateId] = automationElements;
|
|
||||||
StateOrder.Enqueue(stateId);
|
|
||||||
PruneStoredStates();
|
|
||||||
|
|
||||||
var elements = records.Select(record => record.ToDictionary()).ToList();
|
|
||||||
var bounds = root.Current.BoundingRectangle;
|
|
||||||
return new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["stateId"] = stateId,
|
|
||||||
["app"] = process.ProcessName,
|
|
||||||
["platform"] = "win32",
|
|
||||||
["screenshotDataUrl"] = CaptureScreen(),
|
|
||||||
["displaySize"] = new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["width"] = (int)System.Windows.Forms.Screen.PrimaryScreen!.Bounds.Width,
|
|
||||||
["height"] = (int)System.Windows.Forms.Screen.PrimaryScreen!.Bounds.Height
|
|
||||||
},
|
|
||||||
["window"] = new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["title"] = process.MainWindowTitle,
|
|
||||||
["bounds"] = BoundsDictionary(bounds)
|
|
||||||
},
|
|
||||||
["elements"] = elements,
|
|
||||||
["accessibilityTree"] = elements,
|
|
||||||
["treeText"] = string.Join("\n", elements.Select(element => $"{element["index"]} {element["role"]} {element.GetValueOrDefault("title")}"))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> ClickElement(JsonElement parameters)
|
|
||||||
{
|
|
||||||
var mouseButton = ReadString(parameters, "mouse_button");
|
|
||||||
if ((mouseButton == "" || mouseButton == "left") && ReadInt(parameters, "click_count", 1) == 1)
|
|
||||||
{
|
|
||||||
var element = AutomationElementFor(parameters);
|
|
||||||
if (element != null && TryInvoke(element))
|
|
||||||
{
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var point = PointFor(parameters);
|
|
||||||
if (point == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("click_element requires x/y or stateId + element_index.");
|
|
||||||
}
|
|
||||||
|
|
||||||
SendMouseClick(point.Value.X, point.Value.Y, ReadString(parameters, "mouse_button"), ReadInt(parameters, "click_count", 1));
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> PerformSecondaryAction(JsonElement parameters)
|
|
||||||
{
|
|
||||||
var point = PointFor(parameters);
|
|
||||||
if (point == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("perform_secondary_action requires x/y or stateId + element_index.");
|
|
||||||
}
|
|
||||||
|
|
||||||
SendMouseClick(point.Value.X, point.Value.Y, "right", 1);
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> SetValue(JsonElement parameters)
|
|
||||||
{
|
|
||||||
var value = ReadString(parameters, "value");
|
|
||||||
var element = AutomationElementFor(parameters);
|
|
||||||
var focused = false;
|
|
||||||
if (element != null)
|
|
||||||
{
|
|
||||||
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var valuePattern))
|
|
||||||
{
|
|
||||||
((ValuePattern)valuePattern).SetValue(value);
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
element.SetFocus();
|
|
||||||
focused = true;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Fall through to coordinate focus below.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var point = PointFor(parameters);
|
|
||||||
if (point != null)
|
|
||||||
{
|
|
||||||
SendMouseClick(point.Value.X, point.Value.Y, "left", 1);
|
|
||||||
focused = true;
|
|
||||||
}
|
|
||||||
else if (!focused && element == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("set_value requires x/y or stateId + element_index.");
|
|
||||||
}
|
|
||||||
else if (!focused)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("set_value could not focus the requested element.");
|
|
||||||
}
|
|
||||||
System.Windows.Forms.SendKeys.SendWait("^a");
|
|
||||||
System.Windows.Forms.SendKeys.SendWait(EscapeSendKeys(value));
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> TypeText(JsonElement parameters)
|
|
||||||
{
|
|
||||||
var text = ReadString(parameters, "text");
|
|
||||||
System.Windows.Forms.SendKeys.SendWait(EscapeSendKeys(text));
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> PressKey(JsonElement parameters)
|
|
||||||
{
|
|
||||||
var key = ReadString(parameters, "key");
|
|
||||||
System.Windows.Forms.SendKeys.SendWait(ToSendKeysChord(key));
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> ScrollElement(JsonElement parameters)
|
|
||||||
{
|
|
||||||
var element = AutomationElementFor(parameters);
|
|
||||||
var direction = ReadString(parameters, "direction");
|
|
||||||
var pages = ReadDouble(parameters, "pages", 1);
|
|
||||||
if (element != null && element.TryGetCurrentPattern(ScrollPattern.Pattern, out var scrollPatternValue))
|
|
||||||
{
|
|
||||||
var scrollPattern = (ScrollPattern)scrollPatternValue;
|
|
||||||
var vertical = direction == "up" ? ScrollAmount.LargeDecrement : direction == "down" ? ScrollAmount.LargeIncrement : ScrollAmount.NoAmount;
|
|
||||||
var horizontal = direction == "left" ? ScrollAmount.LargeDecrement : direction == "right" ? ScrollAmount.LargeIncrement : ScrollAmount.NoAmount;
|
|
||||||
scrollPattern.Scroll(horizontal, vertical);
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
var point = PointFor(parameters);
|
|
||||||
if (point == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("scroll_element requires x/y or stateId + element_index.");
|
|
||||||
}
|
|
||||||
SetCursorPos(point.Value.X, point.Value.Y);
|
|
||||||
var wheel = (int)Math.Round(Math.Max(1, pages) * 120);
|
|
||||||
if (direction == "down") wheel = -wheel;
|
|
||||||
mouse_event(0x0800, 0, 0, unchecked((uint)wheel), UIntPtr.Zero);
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PruneStoredStates()
|
|
||||||
{
|
|
||||||
while (StateOrder.Count > MaxStoredStates)
|
|
||||||
{
|
|
||||||
var evicted = StateOrder.Dequeue();
|
|
||||||
StateElements.Remove(evicted);
|
|
||||||
StateAutomationElements.Remove(evicted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> Drag(JsonElement parameters)
|
|
||||||
{
|
|
||||||
var fromX = ReadDouble(parameters, "from_x", double.NaN);
|
|
||||||
var fromY = ReadDouble(parameters, "from_y", double.NaN);
|
|
||||||
var toX = ReadDouble(parameters, "to_x", double.NaN);
|
|
||||||
var toY = ReadDouble(parameters, "to_y", double.NaN);
|
|
||||||
if (double.IsNaN(fromX) || double.IsNaN(fromY) || double.IsNaN(toX) || double.IsNaN(toY))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("drag requires from_x/from_y/to_x/to_y.");
|
|
||||||
}
|
|
||||||
|
|
||||||
SetCursorPos((int)Math.Round(fromX), (int)Math.Round(fromY));
|
|
||||||
mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero);
|
|
||||||
Thread.Sleep(80);
|
|
||||||
SetCursorPos((int)Math.Round(toX), (int)Math.Round(toY));
|
|
||||||
Thread.Sleep(80);
|
|
||||||
mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero);
|
|
||||||
return GetAppState(parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Walk(AutomationElement element, List<ElementRecord> records, Dictionary<string, AutomationElement> automationElements, int depth, int maxDepth, int limit)
|
|
||||||
{
|
|
||||||
if (depth > maxDepth || records.Count >= limit) return;
|
|
||||||
var index = (records.Count + 1).ToString();
|
|
||||||
records.Add(ElementRecord.From(element, index));
|
|
||||||
automationElements[index] = element;
|
|
||||||
var children = element.FindAll(TreeScope.Children, Condition.TrueCondition);
|
|
||||||
foreach (AutomationElement child in children)
|
|
||||||
{
|
|
||||||
Walk(child, records, automationElements, depth + 1, maxDepth, limit);
|
|
||||||
if (records.Count >= limit) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ReadString(JsonElement element, string property)
|
|
||||||
{
|
|
||||||
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
|
|
||||||
? value.GetString() ?? ""
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int ReadInt(JsonElement element, string property, int defaultValue)
|
|
||||||
{
|
|
||||||
return element.TryGetProperty(property, out var value) && value.TryGetInt32(out var number)
|
|
||||||
? number
|
|
||||||
: defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double ReadDouble(JsonElement element, string property, double defaultValue)
|
|
||||||
{
|
|
||||||
return element.TryGetProperty(property, out var value) && value.TryGetDouble(out var number)
|
|
||||||
? number
|
|
||||||
: defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AutomationElement? AutomationElementFor(JsonElement parameters)
|
|
||||||
{
|
|
||||||
var stateId = ReadString(parameters, "stateId");
|
|
||||||
var elementIndex = ReadString(parameters, "element_index");
|
|
||||||
return !string.IsNullOrWhiteSpace(stateId)
|
|
||||||
&& !string.IsNullOrWhiteSpace(elementIndex)
|
|
||||||
&& StateAutomationElements.TryGetValue(stateId, out var elements)
|
|
||||||
&& elements.TryGetValue(elementIndex, out var element)
|
|
||||||
? element
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static System.Drawing.Point? PointFor(JsonElement parameters)
|
|
||||||
{
|
|
||||||
if (parameters.TryGetProperty("x", out var xValue) && parameters.TryGetProperty("y", out var yValue)
|
|
||||||
&& xValue.TryGetDouble(out var x) && yValue.TryGetDouble(out var y))
|
|
||||||
{
|
|
||||||
return new System.Drawing.Point((int)Math.Round(x), (int)Math.Round(y));
|
|
||||||
}
|
|
||||||
|
|
||||||
var stateId = ReadString(parameters, "stateId");
|
|
||||||
var elementIndex = ReadString(parameters, "element_index");
|
|
||||||
if (string.IsNullOrWhiteSpace(stateId) || string.IsNullOrWhiteSpace(elementIndex)) return null;
|
|
||||||
if (!StateElements.TryGetValue(stateId, out var elements)) return null;
|
|
||||||
var element = elements.FirstOrDefault(item => item.Index == elementIndex);
|
|
||||||
if (element?.Bounds == null) return null;
|
|
||||||
return new System.Drawing.Point(
|
|
||||||
(int)Math.Round(element.Bounds.Value.Left + element.Bounds.Value.Width / 2),
|
|
||||||
(int)Math.Round(element.Bounds.Value.Top + element.Bounds.Value.Height / 2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string CaptureScreen()
|
|
||||||
{
|
|
||||||
var bounds = System.Windows.Forms.Screen.PrimaryScreen!.Bounds;
|
|
||||||
using var bitmap = new Bitmap(bounds.Width, bounds.Height);
|
|
||||||
using var graphics = Graphics.FromImage(bitmap);
|
|
||||||
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bounds.Size);
|
|
||||||
using var stream = new MemoryStream();
|
|
||||||
bitmap.Save(stream, ImageFormat.Png);
|
|
||||||
return $"data:image/png;base64,{Convert.ToBase64String(stream.ToArray())}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> BoundsDictionary(System.Windows.Rect rect)
|
|
||||||
{
|
|
||||||
return new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["x"] = rect.X,
|
|
||||||
["y"] = rect.Y,
|
|
||||||
["width"] = rect.Width,
|
|
||||||
["height"] = rect.Height
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern bool SetCursorPos(int x, int y);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
|
|
||||||
|
|
||||||
private static void SendMouseClick(int x, int y, string button, int clickCount)
|
|
||||||
{
|
|
||||||
var (down, up) = button switch
|
|
||||||
{
|
|
||||||
"right" => (0x0008u, 0x0010u),
|
|
||||||
"middle" => (0x0020u, 0x0040u),
|
|
||||||
_ => (0x0002u, 0x0004u)
|
|
||||||
};
|
|
||||||
SetCursorPos(x, y);
|
|
||||||
for (var i = 0; i < Math.Max(1, clickCount); i++)
|
|
||||||
{
|
|
||||||
mouse_event(down, 0, 0, 0, UIntPtr.Zero);
|
|
||||||
mouse_event(up, 0, 0, 0, UIntPtr.Zero);
|
|
||||||
Thread.Sleep(80);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryInvoke(AutomationElement element)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!element.TryGetCurrentPattern(InvokePattern.Pattern, out var pattern)) return false;
|
|
||||||
((InvokePattern)pattern).Invoke();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EscapeSendKeys(string value)
|
|
||||||
{
|
|
||||||
return value
|
|
||||||
.Replace("{", "{{}")
|
|
||||||
.Replace("}", "{}}")
|
|
||||||
.Replace("+", "{+}")
|
|
||||||
.Replace("^", "{^}")
|
|
||||||
.Replace("%", "{%}")
|
|
||||||
.Replace("~", "{~}")
|
|
||||||
.Replace("(", "{(}")
|
|
||||||
.Replace(")", "{)}")
|
|
||||||
.Replace("[", "{[}")
|
|
||||||
.Replace("]", "{]}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ToSendKeysChord(string key)
|
|
||||||
{
|
|
||||||
var normalized = key.Trim();
|
|
||||||
if (normalized.Contains('+'))
|
|
||||||
{
|
|
||||||
var parts = normalized.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
||||||
var modifiers = "";
|
|
||||||
var last = parts.LastOrDefault() ?? "";
|
|
||||||
foreach (var part in parts.Take(parts.Length - 1))
|
|
||||||
{
|
|
||||||
modifiers += part.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"ctrl" or "control" => "^",
|
|
||||||
"alt" => "%",
|
|
||||||
"shift" => "+",
|
|
||||||
"cmd" or "win" or "windows" => "^",
|
|
||||||
_ => ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return modifiers + SendKeyName(last);
|
|
||||||
}
|
|
||||||
return SendKeyName(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SendKeyName(string key)
|
|
||||||
{
|
|
||||||
return key.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"return" or "enter" => "{ENTER}",
|
|
||||||
"escape" or "esc" => "{ESC}",
|
|
||||||
"tab" => "{TAB}",
|
|
||||||
"backspace" => "{BACKSPACE}",
|
|
||||||
"delete" or "del" => "{DELETE}",
|
|
||||||
"left" => "{LEFT}",
|
|
||||||
"right" => "{RIGHT}",
|
|
||||||
"up" => "{UP}",
|
|
||||||
"down" => "{DOWN}",
|
|
||||||
"space" => " ",
|
|
||||||
_ => key.Length == 1 ? EscapeSendKeys(key) : $"{{{key.ToUpperInvariant()}}}"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record ElementRecord(
|
|
||||||
string Index,
|
|
||||||
string Role,
|
|
||||||
string? Title,
|
|
||||||
string? Value,
|
|
||||||
System.Windows.Rect? Bounds,
|
|
||||||
List<string> Actions)
|
|
||||||
{
|
|
||||||
public static ElementRecord From(AutomationElement element, string index)
|
|
||||||
{
|
|
||||||
var patterns = element.GetSupportedPatterns().Select(pattern => pattern.ProgrammaticName).ToList();
|
|
||||||
return new ElementRecord(
|
|
||||||
index,
|
|
||||||
element.Current.ControlType.ProgrammaticName.Replace("ControlType.", ""),
|
|
||||||
element.Current.Name,
|
|
||||||
TryValue(element),
|
|
||||||
element.Current.BoundingRectangle,
|
|
||||||
patterns
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Dictionary<string, object?> ToDictionary()
|
|
||||||
{
|
|
||||||
var output = new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["index"] = Index,
|
|
||||||
["role"] = Role,
|
|
||||||
["actions"] = Actions
|
|
||||||
};
|
|
||||||
if (!string.IsNullOrEmpty(Title)) output["title"] = Title;
|
|
||||||
if (!string.IsNullOrEmpty(Value)) output["value"] = Value;
|
|
||||||
if (Bounds != null) output["bounds"] = BoundsDictionary(Bounds.Value);
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? TryValue(AutomationElement element)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var pattern))
|
|
||||||
{
|
|
||||||
return ((ValuePattern)pattern).Current.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import type { SemanticAppState, SemanticElement } from '@/modules/computer-use/semantics/semantic-types.js';
|
|
||||||
|
|
||||||
const DEFAULT_STATE_TTL_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_SEMANTIC_STATE_TTL_MS || String(10 * 60 * 1000), 10);
|
|
||||||
|
|
||||||
type StoredState = {
|
|
||||||
sessionId: string;
|
|
||||||
appKey: string;
|
|
||||||
state: SemanticAppState;
|
|
||||||
updatedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeAppKey(app: string): string {
|
|
||||||
return app.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SemanticSessionStore {
|
|
||||||
private states = new Map<string, StoredState>();
|
|
||||||
private latestBySessionApp = new Map<string, string>();
|
|
||||||
|
|
||||||
createStateId(): string {
|
|
||||||
return `state_${randomUUID()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
save(sessionId: string, state: SemanticAppState): SemanticAppState {
|
|
||||||
const appKey = normalizeAppKey(state.app);
|
|
||||||
const nextState = {
|
|
||||||
...state,
|
|
||||||
stateId: state.stateId || this.createStateId(),
|
|
||||||
};
|
|
||||||
this.states.set(nextState.stateId, {
|
|
||||||
sessionId,
|
|
||||||
appKey,
|
|
||||||
state: nextState,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
});
|
|
||||||
this.latestBySessionApp.set(this.latestKey(sessionId, appKey), nextState.stateId);
|
|
||||||
return nextState;
|
|
||||||
}
|
|
||||||
|
|
||||||
getState(sessionId: string, app: string, stateId?: string): SemanticAppState | null {
|
|
||||||
this.expire();
|
|
||||||
if (stateId) {
|
|
||||||
const entry = this.states.get(stateId);
|
|
||||||
const appKey = normalizeAppKey(app);
|
|
||||||
return entry && entry.sessionId === sessionId && entry.appKey === appKey ? entry.state : null;
|
|
||||||
}
|
|
||||||
const latestStateId = this.latestBySessionApp.get(this.latestKey(sessionId, normalizeAppKey(app)));
|
|
||||||
return latestStateId ? this.states.get(latestStateId)?.state || null : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getElement(sessionId: string, app: string, elementIndex: string, stateId?: string): SemanticElement | null {
|
|
||||||
const state = this.getState(sessionId, app, stateId);
|
|
||||||
return state?.elements.find((element) => element.index === elementIndex) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSession(sessionId: string): void {
|
|
||||||
for (const [stateId, entry] of this.states.entries()) {
|
|
||||||
if (entry.sessionId === sessionId) {
|
|
||||||
this.states.delete(stateId);
|
|
||||||
this.latestBySessionApp.delete(this.latestKey(entry.sessionId, entry.appKey));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expire(now = Date.now()): void {
|
|
||||||
const ttl = Number.isFinite(DEFAULT_STATE_TTL_MS) && DEFAULT_STATE_TTL_MS > 0
|
|
||||||
? DEFAULT_STATE_TTL_MS
|
|
||||||
: 10 * 60 * 1000;
|
|
||||||
for (const [stateId, entry] of this.states.entries()) {
|
|
||||||
if (now - entry.updatedAt > ttl) {
|
|
||||||
this.states.delete(stateId);
|
|
||||||
const key = this.latestKey(entry.sessionId, entry.appKey);
|
|
||||||
if (this.latestBySessionApp.get(key) === stateId) {
|
|
||||||
this.latestBySessionApp.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private latestKey(sessionId: string, appKey: string): string {
|
|
||||||
return `${sessionId}:${appKey}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const semanticSessionStore = new SemanticSessionStore();
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export const semanticMcpToolMap: Record<string, string> = {
|
|
||||||
computer_app_drag: 'drag',
|
|
||||||
computer_click_element: 'click_element',
|
|
||||||
computer_get_app_state: 'get_app_state',
|
|
||||||
computer_list_apps: 'list_apps',
|
|
||||||
computer_perform_secondary_action: 'perform_secondary_action',
|
|
||||||
computer_press_key: 'press_key',
|
|
||||||
computer_scroll_element: 'scroll_element',
|
|
||||||
computer_set_value: 'set_value',
|
|
||||||
computer_type_text: 'type_text',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const semanticOperationNames = new Set(Object.values(semanticMcpToolMap));
|
|
||||||
|
|
||||||
export function semanticOperationForMcpTool(toolName: string): string | null {
|
|
||||||
return semanticMcpToolMap[toolName] || null;
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { DisplaySize, Point } from '@/modules/computer-use/computer-executor.js';
|
|
||||||
|
|
||||||
export type SemanticBounds = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SemanticApp = {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
bundleIdentifier?: string;
|
|
||||||
processName?: string;
|
|
||||||
pid?: number;
|
|
||||||
running: boolean;
|
|
||||||
windowTitle?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SemanticElement = {
|
|
||||||
index: string;
|
|
||||||
role: string;
|
|
||||||
title?: string;
|
|
||||||
value?: string;
|
|
||||||
description?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
focused?: boolean;
|
|
||||||
selected?: boolean;
|
|
||||||
bounds?: SemanticBounds;
|
|
||||||
actions?: string[];
|
|
||||||
settableValue?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SemanticAppState = {
|
|
||||||
stateId: string;
|
|
||||||
app: string;
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
screenshotDataUrl: string | null;
|
|
||||||
displaySize: DisplaySize | null;
|
|
||||||
elements: SemanticElement[];
|
|
||||||
accessibilityTree: SemanticElement[];
|
|
||||||
treeText?: string;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SemanticToolInput = Record<string, unknown> & {
|
|
||||||
sessionId?: string;
|
|
||||||
app?: string;
|
|
||||||
stateId?: string;
|
|
||||||
element_index?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SemanticToolResult = SemanticAppState | {
|
|
||||||
apps: SemanticApp[];
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SemanticActionPoint = Point;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { WebSocket } from 'ws';
|
|
||||||
|
|
||||||
import { desktopAgentRelay } from '@/modules/computer-use/index.js';
|
|
||||||
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
|
||||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the `/desktop-agent` websocket — the inbound side of the cloud
|
|
||||||
* Computer Use relay. A linked CloudCLI desktop app connects here and registers
|
|
||||||
* itself as the executor for this hosted environment. The server then forwards
|
|
||||||
* `computer_*` actions via `desktopAgentRelay`, and the agent returns results as
|
|
||||||
* `computer_relay_result` frames correlated by `id`.
|
|
||||||
*/
|
|
||||||
export function handleDesktopAgentConnection(
|
|
||||||
ws: WebSocket,
|
|
||||||
request: AuthenticatedWebSocketRequest
|
|
||||||
): void {
|
|
||||||
let registered = false;
|
|
||||||
|
|
||||||
ws.on('message', (rawMessage) => {
|
|
||||||
const data = parseIncomingJsonObject(rawMessage);
|
|
||||||
if (!data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const kind = typeof data.kind === 'string' ? data.kind : typeof data.type === 'string' ? data.type : '';
|
|
||||||
if (kind === 'register' && !registered) {
|
|
||||||
const label = typeof data.label === 'string' && data.label.trim()
|
|
||||||
? data.label.trim()
|
|
||||||
: request.user?.username
|
|
||||||
? `desktop:${request.user.username}`
|
|
||||||
: 'desktop-agent';
|
|
||||||
registered = true;
|
|
||||||
console.log('[INFO] Desktop agent websocket registered:', label);
|
|
||||||
desktopAgentRelay.register(ws, label);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === 'computer_relay_result' && typeof data.id === 'string') {
|
|
||||||
desktopAgentRelay.handleResult(
|
|
||||||
data.id,
|
|
||||||
(data as Record<string, unknown>).result,
|
|
||||||
typeof (data as Record<string, unknown>).error === 'string'
|
|
||||||
? ((data as Record<string, unknown>).error as string)
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
console.log('[INFO] Desktop agent websocket disconnected');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import { handleChatConnection } from '@/modules/websocket/services/chat-websocke
|
|||||||
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
|
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
|
||||||
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
||||||
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
||||||
import { handleDesktopAgentConnection } from '@/modules/websocket/services/desktop-agent-websocket.service.js';
|
|
||||||
import { handleDesktopNotificationsConnection } from '@/modules/notifications/index.js';
|
import { handleDesktopNotificationsConnection } from '@/modules/notifications/index.js';
|
||||||
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||||
|
|
||||||
@@ -65,11 +64,6 @@ export function createWebSocketServer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === '/desktop-agent') {
|
|
||||||
handleDesktopAgentConnection(ws, incomingRequest);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === '/desktop-notifications') {
|
if (pathname === '/desktop-notifications') {
|
||||||
handleDesktopNotificationsConnection(ws, incomingRequest);
|
handleDesktopNotificationsConnection(ws, incomingRequest);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
type AuthErrorAlertProps = {
|
type AuthErrorAlertProps = {
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
};
|
};
|
||||||
@@ -8,8 +10,12 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20">
|
<div
|
||||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
|
role="alert"
|
||||||
|
className="flex items-start gap-2.5 rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-destructive"
|
||||||
|
>
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<p className="text-sm leading-relaxed">{errorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
type AuthInputFieldProps = {
|
type AuthInputFieldProps = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -8,13 +12,14 @@ type AuthInputFieldProps = {
|
|||||||
type?: 'text' | 'password' | 'email';
|
type?: 'text' | 'password' | 'email';
|
||||||
name?: string;
|
name?: string;
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
|
icon?: ComponentType<{ className?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A labelled input field for authentication forms.
|
* A labelled input field for authentication forms.
|
||||||
* Renders a `<label>` / `<input>` pair and forwards browser autofill hints
|
* Renders a `<label>` / `<input>` pair and forwards browser autofill hints
|
||||||
* (`name`, `autoComplete`) so that password managers can identify and fill
|
* (`name`, `autoComplete`) so that password managers can identify and fill
|
||||||
* the field correctly.
|
* the field correctly. Password fields gain a show/hide visibility toggle.
|
||||||
*/
|
*/
|
||||||
export default function AuthInputField({
|
export default function AuthInputField({
|
||||||
id,
|
id,
|
||||||
@@ -26,24 +31,48 @@ export default function AuthInputField({
|
|||||||
type = 'text',
|
type = 'text',
|
||||||
name,
|
name,
|
||||||
autoComplete,
|
autoComplete,
|
||||||
|
icon: Icon,
|
||||||
}: AuthInputFieldProps) {
|
}: AuthInputFieldProps) {
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
const isPasswordField = type === 'password';
|
||||||
|
const resolvedType = isPasswordField && isPasswordVisible ? 'text' : type;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={id} className="mb-1 block text-sm font-medium text-foreground">
|
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="group relative">
|
||||||
|
{Icon && (
|
||||||
|
<Icon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type={type}
|
type={resolvedType}
|
||||||
name={name ?? id}
|
name={name ?? id}
|
||||||
autoComplete={autoComplete}
|
autoComplete={autoComplete}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className={`w-full rounded-xl border border-border bg-background/60 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||||
|
Icon ? 'pl-10' : 'pl-3.5'
|
||||||
|
} ${isPasswordField ? 'pr-11' : 'pr-3.5'}`}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
required
|
required
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
|
{isPasswordField && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPasswordVisible((previous) => !previous)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||||
|
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,37 @@
|
|||||||
import { MessageSquare } from 'lucide-react';
|
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../constants/branding';
|
||||||
|
|
||||||
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
|
const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
|
||||||
|
|
||||||
export default function AuthLoadingScreen() {
|
export default function AuthLoadingScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
||||||
<div className="text-center">
|
<div aria-hidden className="pointer-events-none absolute inset-0">
|
||||||
<div className="mb-4 flex justify-center">
|
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
|
</div>
|
||||||
<MessageSquare className="h-8 w-8 text-primary-foreground" />
|
|
||||||
|
<div className="relative text-center" role="status" aria-live="polite">
|
||||||
|
<div className="mb-5 flex justify-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
|
||||||
|
<img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
|
<h1
|
||||||
|
className="mb-4 text-2xl font-bold tracking-tight text-foreground"
|
||||||
<div className="flex items-center justify-center space-x-2">
|
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
|
||||||
|
>
|
||||||
|
CloudCLI
|
||||||
|
</h1>
|
||||||
|
<p className="sr-only">Loading authentication state…</p>
|
||||||
|
<div aria-hidden className="flex items-center justify-center gap-2">
|
||||||
{loadingDotAnimationDelays.map((delay) => (
|
{loadingDotAnimationDelays.map((delay) => (
|
||||||
<div
|
<div
|
||||||
key={delay}
|
key={delay}
|
||||||
className="h-2 w-2 animate-bounce rounded-full bg-blue-500"
|
className="h-2 w-2 animate-bounce rounded-full bg-primary"
|
||||||
style={{ animationDelay: delay }}
|
style={{ animationDelay: delay }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-muted-foreground">Loading...</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { MessageSquare } from 'lucide-react';
|
|
||||||
import { IS_PLATFORM } from '../../../constants/config';
|
import { IS_PLATFORM } from '../../../constants/config';
|
||||||
|
|
||||||
type AuthScreenLayoutProps = {
|
type AuthScreenLayoutProps = {
|
||||||
@@ -18,29 +17,38 @@ export default function AuthScreenLayout({
|
|||||||
logo,
|
logo,
|
||||||
}: AuthScreenLayoutProps) {
|
}: AuthScreenLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="relative h-screen overflow-y-auto bg-background">
|
||||||
<div className="w-full max-w-md">
|
{/* Ambient, on-brand backdrop that gives the screen depth without
|
||||||
<div className="space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg">
|
competing with the card content. Fixed so it stays put while the
|
||||||
|
form scrolls on short viewports. */}
|
||||||
|
<div aria-hidden className="pointer-events-none fixed inset-0">
|
||||||
|
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-32 -left-24 h-[26rem] w-[26rem] rounded-full bg-primary/5 blur-3xl" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(hsl(var(--foreground)/0.04)_1px,transparent_1px)] [background-size:22px_22px] opacity-60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto flex min-h-full w-full max-w-md items-center justify-center p-4 py-8">
|
||||||
|
<div className="w-full rounded-2xl border border-border/70 bg-card/90 p-8 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl sm:p-10">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4 flex justify-center">
|
<div className="mb-5 flex justify-center">
|
||||||
{logo ?? (
|
{logo ?? (
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
|
||||||
<MessageSquare className="h-8 w-8 text-primary-foreground" />
|
<img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
<h1 className="font-serif text-3xl font-bold tracking-tight text-foreground">{title}</h1>
|
||||||
<p className="mt-2 text-muted-foreground">{description}</p>
|
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
<div className="mt-8">{children}</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="mt-6 border-t border-border/60 pt-5 text-center">
|
||||||
<p className="text-sm text-muted-foreground">{footerText}</p>
|
<p className="text-xs leading-relaxed text-muted-foreground">{footerText}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!IS_PLATFORM && (
|
{!IS_PLATFORM && (
|
||||||
<div className="flex items-center justify-center gap-1.5 pt-2">
|
<div className="mt-4 flex items-center justify-center gap-1.5">
|
||||||
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Loader2, Lock, User } from 'lucide-react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import AuthErrorAlert from './AuthErrorAlert';
|
import AuthErrorAlert from './AuthErrorAlert';
|
||||||
import AuthInputField from './AuthInputField';
|
import AuthInputField from './AuthInputField';
|
||||||
@@ -69,6 +70,7 @@ export default function LoginForm() {
|
|||||||
placeholder={t('login.placeholders.username')}
|
placeholder={t('login.placeholders.username')}
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
icon={User}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -80,6 +82,7 @@ export default function LoginForm() {
|
|||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
icon={Lock}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthErrorAlert errorMessage={errorMessage} />
|
<AuthErrorAlert errorMessage={errorMessage} />
|
||||||
@@ -87,9 +90,16 @@ export default function LoginForm() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isSubmitting ? t('login.loading') : t('login.submit')}
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{t('login.loading')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('login.submit')
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthScreenLayout>
|
</AuthScreenLayout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
|
import { Loader2, Lock, ShieldCheck, User } from 'lucide-react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import AuthErrorAlert from './AuthErrorAlert';
|
import AuthErrorAlert from './AuthErrorAlert';
|
||||||
import AuthInputField from './AuthInputField';
|
import AuthInputField from './AuthInputField';
|
||||||
@@ -85,7 +86,6 @@ export default function SetupForm() {
|
|||||||
title="Welcome to CloudCLI"
|
title="Welcome to CloudCLI"
|
||||||
description="Set up your account to get started"
|
description="Set up your account to get started"
|
||||||
footerText="This is a single-user system. Only one account can be created."
|
footerText="This is a single-user system. Only one account can be created."
|
||||||
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}
|
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -94,9 +94,10 @@ export default function SetupForm() {
|
|||||||
label="Username"
|
label="Username"
|
||||||
value={formState.username}
|
value={formState.username}
|
||||||
onChange={(value) => updateField('username', value)}
|
onChange={(value) => updateField('username', value)}
|
||||||
placeholder="Enter your username"
|
placeholder="Choose a username"
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
icon={User}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -105,10 +106,11 @@ export default function SetupForm() {
|
|||||||
label="Password"
|
label="Password"
|
||||||
value={formState.password}
|
value={formState.password}
|
||||||
onChange={(value) => updateField('password', value)}
|
onChange={(value) => updateField('password', value)}
|
||||||
placeholder="Enter your password"
|
placeholder="Create a password"
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
icon={Lock}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthInputField
|
<AuthInputField
|
||||||
@@ -117,20 +119,33 @@ export default function SetupForm() {
|
|||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
value={formState.confirmPassword}
|
value={formState.confirmPassword}
|
||||||
onChange={(value) => updateField('confirmPassword', value)}
|
onChange={(value) => updateField('confirmPassword', value)}
|
||||||
placeholder="Confirm your password"
|
placeholder="Re-enter your password"
|
||||||
isDisabled={isSubmitting}
|
isDisabled={isSubmitting}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
icon={ShieldCheck}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
At least 3 characters for username, 6 for password.
|
||||||
|
</p>
|
||||||
|
|
||||||
<AuthErrorAlert errorMessage={errorMessage} />
|
<AuthErrorAlert errorMessage={errorMessage} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Setting up...' : 'Create Account'}
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Setting up...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Account'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthScreenLayout>
|
</AuthScreenLayout>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface UseChatSessionStateArgs {
|
|||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
autoScrollToBottom?: boolean;
|
|
||||||
externalMessageUpdate?: number;
|
externalMessageUpdate?: number;
|
||||||
newSessionTrigger?: number;
|
newSessionTrigger?: number;
|
||||||
processingSessions?: SessionActivityMap;
|
processingSessions?: SessionActivityMap;
|
||||||
@@ -96,7 +95,6 @@ export function useChatSessionState({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
autoScrollToBottom,
|
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
@@ -121,6 +119,7 @@ export function useChatSessionState({
|
|||||||
const [viewHiddenCount, setViewHiddenCount] = useState(0);
|
const [viewHiddenCount, setViewHiddenCount] = useState(0);
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wasNearTopRef = useRef(false);
|
||||||
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
|
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
|
||||||
const searchScrollActiveRef = useRef(false);
|
const searchScrollActiveRef = useRef(false);
|
||||||
const isLoadingSessionRef = useRef(false);
|
const isLoadingSessionRef = useRef(false);
|
||||||
@@ -185,6 +184,7 @@ export function useChatSessionState({
|
|||||||
setShowLoadAllOverlay(false);
|
setShowLoadAllOverlay(false);
|
||||||
setViewHiddenCount(0);
|
setViewHiddenCount(0);
|
||||||
setSearchTarget(null);
|
setSearchTarget(null);
|
||||||
|
wasNearTopRef.current = false;
|
||||||
searchScrollActiveRef.current = false;
|
searchScrollActiveRef.current = false;
|
||||||
topLoadLockRef.current = false;
|
topLoadLockRef.current = false;
|
||||||
pendingScrollRestoreRef.current = null;
|
pendingScrollRestoreRef.current = null;
|
||||||
@@ -336,12 +336,34 @@ export function useChatSessionState({
|
|||||||
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
||||||
limit: MESSAGES_PER_PAGE,
|
limit: MESSAGES_PER_PAGE,
|
||||||
});
|
});
|
||||||
if (!slot || slot.serverMessages.length === 0) return false;
|
if (!slot) return false;
|
||||||
|
if (slot.serverMessages.length === 0) {
|
||||||
|
if (!slot.hasMore) {
|
||||||
|
setHasMoreMessages(false);
|
||||||
|
allMessagesLoadedRef.current = true;
|
||||||
|
setAllMessagesLoaded(true);
|
||||||
|
if (loadAllOverlayTimerRef.current) {
|
||||||
|
clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
loadAllOverlayTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setShowLoadAllOverlay(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
|
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
|
||||||
setHasMoreMessages(slot.hasMore);
|
setHasMoreMessages(slot.hasMore);
|
||||||
setTotalMessages(slot.total);
|
setTotalMessages(slot.total);
|
||||||
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
|
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
|
||||||
|
if (!slot.hasMore) {
|
||||||
|
allMessagesLoadedRef.current = true;
|
||||||
|
setAllMessagesLoaded(true);
|
||||||
|
if (loadAllOverlayTimerRef.current) {
|
||||||
|
clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
loadAllOverlayTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setShowLoadAllOverlay(false);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingMoreRef.current = false;
|
isLoadingMoreRef.current = false;
|
||||||
@@ -357,8 +379,25 @@ export function useChatSessionState({
|
|||||||
const nearBottom = isNearBottom();
|
const nearBottom = isNearBottom();
|
||||||
setIsUserScrolledUp(!nearBottom);
|
setIsUserScrolledUp(!nearBottom);
|
||||||
|
|
||||||
if (!allMessagesLoadedRef.current) {
|
|
||||||
const scrolledNearTop = container.scrollTop < 100;
|
const scrolledNearTop = container.scrollTop < 100;
|
||||||
|
|
||||||
|
// "Load all" prompt: appear (with fade-in) when the user reaches the top
|
||||||
|
if (scrolledNearTop && hasMoreMessages && !allMessagesLoadedRef.current) {
|
||||||
|
if (!wasNearTopRef.current) {
|
||||||
|
wasNearTopRef.current = true;
|
||||||
|
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
|
||||||
|
setShowLoadAllOverlay(true);
|
||||||
|
loadAllOverlayTimerRef.current = setTimeout(() => {
|
||||||
|
setShowLoadAllOverlay(false);
|
||||||
|
loadAllOverlayTimerRef.current = null;
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
} else if (!scrolledNearTop) {
|
||||||
|
wasNearTopRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allMessagesLoadedRef.current) {
|
||||||
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
|
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
|
||||||
if (topLoadLockRef.current) {
|
if (topLoadLockRef.current) {
|
||||||
if (container.scrollTop > 20) topLoadLockRef.current = false;
|
if (container.scrollTop > 20) topLoadLockRef.current = false;
|
||||||
@@ -367,7 +406,7 @@ export function useChatSessionState({
|
|||||||
const didLoad = await loadOlderMessages(container);
|
const didLoad = await loadOlderMessages(container);
|
||||||
if (didLoad) topLoadLockRef.current = true;
|
if (didLoad) topLoadLockRef.current = true;
|
||||||
}
|
}
|
||||||
}, [isNearBottom, loadOlderMessages]);
|
}, [hasMoreMessages, isNearBottom, loadOlderMessages]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
|
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
|
||||||
@@ -386,6 +425,7 @@ export function useChatSessionState({
|
|||||||
}
|
}
|
||||||
topLoadLockRef.current = false;
|
topLoadLockRef.current = false;
|
||||||
pendingScrollRestoreRef.current = null;
|
pendingScrollRestoreRef.current = null;
|
||||||
|
wasNearTopRef.current = false;
|
||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||||
|
|
||||||
@@ -492,6 +532,7 @@ export function useChatSessionState({
|
|||||||
setLoadAllJustFinished(false);
|
setLoadAllJustFinished(false);
|
||||||
setShowLoadAllOverlay(false);
|
setShowLoadAllOverlay(false);
|
||||||
setViewHiddenCount(0);
|
setViewHiddenCount(0);
|
||||||
|
wasNearTopRef.current = false;
|
||||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
|
|
||||||
@@ -546,7 +587,7 @@ export function useChatSessionState({
|
|||||||
if (!isProcessing) {
|
if (!isProcessing) {
|
||||||
await sessionStore.refreshFromServer(selectedSession.id);
|
await sessionStore.refreshFromServer(selectedSession.id);
|
||||||
|
|
||||||
if (Boolean(autoScrollToBottom) && isNearBottom()) {
|
if (isNearBottom()) {
|
||||||
setTimeout(() => scrollToBottom(), 200);
|
setTimeout(() => scrollToBottom(), 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,7 +598,6 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
reloadExternalMessages();
|
reloadExternalMessages();
|
||||||
}, [
|
}, [
|
||||||
autoScrollToBottom,
|
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
isNearBottom,
|
isNearBottom,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
@@ -689,10 +729,9 @@ export function useChatSessionState({
|
|||||||
}, [chatMessages, visibleMessageCount]);
|
}, [chatMessages, visibleMessageCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoScrollToBottom && scrollContainerRef.current) {
|
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
|
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -700,8 +739,8 @@ export function useChatSessionState({
|
|||||||
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
|
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
|
||||||
if (searchScrollActiveRef.current) return;
|
if (searchScrollActiveRef.current) return;
|
||||||
|
|
||||||
if (autoScrollToBottom) {
|
if (!isUserScrolledUp) {
|
||||||
if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50);
|
setTimeout(() => scrollToBottom(), 50);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,7 +750,7 @@ export function useChatSessionState({
|
|||||||
const newHeight = container.scrollHeight;
|
const newHeight = container.scrollHeight;
|
||||||
const heightDiff = newHeight - prevHeight;
|
const heightDiff = newHeight - prevHeight;
|
||||||
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
|
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
|
||||||
}, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
}, [chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
@@ -720,23 +759,8 @@ export function useChatSessionState({
|
|||||||
return () => container.removeEventListener('scroll', handleScroll);
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
// "Load all" overlay
|
// "Load all" overlay visibility is driven by scroll-to-top in handleScroll;
|
||||||
const prevLoadingRef = useRef(false);
|
// timers are cleared on session change via the reset effect above.
|
||||||
useEffect(() => {
|
|
||||||
const wasLoading = prevLoadingRef.current;
|
|
||||||
prevLoadingRef.current = isLoadingMoreMessages;
|
|
||||||
|
|
||||||
if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) {
|
|
||||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
|
||||||
setShowLoadAllOverlay(true);
|
|
||||||
loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000);
|
|
||||||
}
|
|
||||||
if (!hasMoreMessages && !isLoadingMoreMessages) {
|
|
||||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
|
||||||
setShowLoadAllOverlay(false);
|
|
||||||
}
|
|
||||||
return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); };
|
|
||||||
}, [isLoadingMoreMessages, hasMoreMessages]);
|
|
||||||
|
|
||||||
const loadAllMessages = useCallback(async () => {
|
const loadAllMessages = useCallback(async () => {
|
||||||
if (!selectedSession || !selectedProject) return;
|
if (!selectedSession || !selectedProject) return;
|
||||||
@@ -746,6 +770,10 @@ export function useChatSessionState({
|
|||||||
isLoadingMoreRef.current = true;
|
isLoadingMoreRef.current = true;
|
||||||
setIsLoadingAllMessages(true);
|
setIsLoadingAllMessages(true);
|
||||||
setShowLoadAllOverlay(true);
|
setShowLoadAllOverlay(true);
|
||||||
|
if (loadAllOverlayTimerRef.current) {
|
||||||
|
clearTimeout(loadAllOverlayTimerRef.current);
|
||||||
|
loadAllOverlayTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
const previousScrollHeight = container ? container.scrollHeight : 0;
|
const previousScrollHeight = container ? container.scrollHeight : 0;
|
||||||
@@ -772,7 +800,11 @@ export function useChatSessionState({
|
|||||||
|
|
||||||
setLoadAllJustFinished(true);
|
setLoadAllJustFinished(true);
|
||||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);
|
loadAllFinishedTimerRef.current = setTimeout(() => {
|
||||||
|
setLoadAllJustFinished(false);
|
||||||
|
setShowLoadAllOverlay(false);
|
||||||
|
loadAllFinishedTimerRef.current = null;
|
||||||
|
}, 2500);
|
||||||
} else {
|
} else {
|
||||||
allMessagesLoadedRef.current = false;
|
allMessagesLoadedRef.current = false;
|
||||||
setShowLoadAllOverlay(false);
|
setShowLoadAllOverlay(false);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ interface ToolRendererProps {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||||
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
|
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
rawToolInput?: string;
|
rawToolInput?: string;
|
||||||
isSubagentContainer?: boolean;
|
isSubagentContainer?: boolean;
|
||||||
@@ -80,7 +79,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
createDiff,
|
createDiff,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
autoExpandTools = false,
|
|
||||||
showRawParameters = false,
|
showRawParameters = false,
|
||||||
rawToolInput,
|
rawToolInput,
|
||||||
isSubagentContainer,
|
isSubagentContainer,
|
||||||
@@ -151,8 +149,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
output={output}
|
output={output}
|
||||||
isError={Boolean(toolResult?.isError)}
|
isError={Boolean(toolResult?.isError)}
|
||||||
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
||||||
// Commands stay collapsed by default (even consecutive ones); only
|
// Commands stay collapsed by default; only failures auto-expand so they
|
||||||
// failures auto-expand so they remain visible.
|
// remain visible.
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -199,7 +197,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
<PlanDisplay
|
<PlanDisplay
|
||||||
title={title}
|
title={title}
|
||||||
content={contentProps.content || ''}
|
content={contentProps.content || ''}
|
||||||
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
|
defaultOpen={displayConfig.defaultOpen ?? false}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
showRawParameters={mode === 'input' && showRawParameters}
|
showRawParameters={mode === 'input' && showRawParameters}
|
||||||
rawContent={rawToolInput}
|
rawContent={rawToolInput}
|
||||||
@@ -216,7 +214,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
|
|
||||||
const defaultOpen = displayConfig.defaultOpen !== undefined
|
const defaultOpen = displayConfig.defaultOpen !== undefined
|
||||||
? displayConfig.defaultOpen
|
? displayConfig.defaultOpen
|
||||||
: autoExpandTools;
|
: false;
|
||||||
|
|
||||||
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
||||||
selectedProject,
|
selectedProject,
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
|
|||||||
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
||||||
: 'dark:hover:bg-gray-750/50 border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
|
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
@@ -277,7 +277,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
|
|||||||
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
|
||||||
isOtherOn
|
isOtherOn
|
||||||
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
|
||||||
: 'dark:hover:bg-gray-750/50 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
|
: 'border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
|
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
|
||||||
|
|||||||
@@ -126,10 +126,8 @@ export interface ChatInterfaceProps {
|
|||||||
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||||
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
autoScrollToBottom?: boolean;
|
|
||||||
sendByCtrlEnter?: boolean;
|
sendByCtrlEnter?: boolean;
|
||||||
externalMessageUpdate?: number;
|
externalMessageUpdate?: number;
|
||||||
newSessionTrigger?: number;
|
newSessionTrigger?: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ChatMessage } from '../types/types';
|
import type { ChatMessage } from '../types/types';
|
||||||
|
|
||||||
export const TOOL_GROUP_THRESHOLD = 3;
|
export const TOOL_GROUP_THRESHOLD = 2;
|
||||||
|
|
||||||
export interface ToolGroupItem {
|
export interface ToolGroupItem {
|
||||||
_isGroup: true;
|
_isGroup: true;
|
||||||
@@ -19,7 +19,17 @@ function isGroupableToolMessage(message: ChatMessage): message is ChatMessage &
|
|||||||
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
|
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] {
|
// Messages that render nothing (e.g. reasoning hidden when showThinking is off)
|
||||||
|
// shouldn't split an otherwise-continuous run of the same tool — providers like
|
||||||
|
// Codex interleave hidden reasoning between consecutive tool calls.
|
||||||
|
function rendersNothing(message: ChatMessage, showThinking: boolean): boolean {
|
||||||
|
return Boolean(message.isThinking && !showThinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupConsecutiveTools(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
showThinking: boolean = true,
|
||||||
|
): MessageListItem[] {
|
||||||
const items: MessageListItem[] = [];
|
const items: MessageListItem[] = [];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
@@ -35,13 +45,22 @@ export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[
|
|||||||
const run: ChatMessage[] = [message];
|
const run: ChatMessage[] = [message];
|
||||||
let nextIndex = index + 1;
|
let nextIndex = index + 1;
|
||||||
|
|
||||||
while (
|
while (nextIndex < messages.length) {
|
||||||
nextIndex < messages.length &&
|
const candidate = messages[nextIndex];
|
||||||
isGroupableToolMessage(messages[nextIndex]) &&
|
|
||||||
messages[nextIndex].toolName === message.toolName
|
// Skip invisible interleaved messages so they don't break the run.
|
||||||
) {
|
if (rendersNothing(candidate, showThinking)) {
|
||||||
run.push(messages[nextIndex]);
|
|
||||||
nextIndex += 1;
|
nextIndex += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroupableToolMessage(candidate) && candidate.toolName === message.toolName) {
|
||||||
|
run.push(candidate);
|
||||||
|
nextIndex += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (run.length >= TOOL_GROUP_THRESHOLD) {
|
if (run.length >= TOOL_GROUP_THRESHOLD) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowDownIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||||
@@ -30,10 +31,8 @@ function ChatInterface({
|
|||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onSessionEstablished,
|
onSessionEstablished,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
autoExpandTools,
|
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
showThinking,
|
showThinking,
|
||||||
autoScrollToBottom,
|
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
@@ -124,7 +123,6 @@ function ChatInterface({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
ws,
|
ws,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
autoScrollToBottom,
|
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
@@ -185,7 +183,7 @@ function ChatInterface({
|
|||||||
handlePermissionDecision,
|
handlePermissionDecision,
|
||||||
handleGrantToolPermission,
|
handleGrantToolPermission,
|
||||||
handleInputFocusChange,
|
handleInputFocusChange,
|
||||||
isInputFocused: _isInputFocused,
|
isInputFocused,
|
||||||
commandModalPayload,
|
commandModalPayload,
|
||||||
closeCommandModal,
|
closeCommandModal,
|
||||||
showCostModal,
|
showCostModal,
|
||||||
@@ -356,12 +354,26 @@ function ChatInterface({
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
onGrantToolPermission={handleGrantToolPermission}
|
onGrantToolPermission={handleGrantToolPermission}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
{isUserScrolledUp && chatMessages.length > 0 && (
|
||||||
|
<div className="pointer-events-none absolute -top-11 left-0 right-0 z-20 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={scrollToBottomAndReset}
|
||||||
|
aria-label={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
||||||
|
className="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
|
||||||
|
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="h-4 w-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ChatComposer
|
<ChatComposer
|
||||||
pendingPermissionRequests={pendingPermissionRequests}
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
@@ -377,9 +389,6 @@ function ChatInterface({
|
|||||||
onToggleCommandMenu={handleToggleCommandMenu}
|
onToggleCommandMenu={handleToggleCommandMenu}
|
||||||
hasInput={Boolean(input.trim())}
|
hasInput={Boolean(input.trim())}
|
||||||
onClearInput={handleClearInput}
|
onClearInput={handleClearInput}
|
||||||
isUserScrolledUp={isUserScrolledUp}
|
|
||||||
hasMessages={chatMessages.length > 0}
|
|
||||||
onScrollToBottom={scrollToBottomAndReset}
|
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isDragActive={isDragActive}
|
isDragActive={isDragActive}
|
||||||
attachedImages={attachedImages}
|
attachedImages={attachedImages}
|
||||||
@@ -414,6 +423,7 @@ function ChatInterface({
|
|||||||
onTextareaPaste={handlePaste}
|
onTextareaPaste={handlePaste}
|
||||||
onTextareaScrollSync={syncInputOverlayScroll}
|
onTextareaScrollSync={syncInputOverlayScroll}
|
||||||
onTextareaInput={handleTextareaInput}
|
onTextareaInput={handleTextareaInput}
|
||||||
|
isInputFocused={isInputFocused}
|
||||||
onInputFocusChange={handleInputFocusChange}
|
onInputFocusChange={handleInputFocusChange}
|
||||||
placeholder={t('input.placeholder', {
|
placeholder={t('input.placeholder', {
|
||||||
provider:
|
provider:
|
||||||
@@ -431,6 +441,7 @@ function ChatInterface({
|
|||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<QuickSettingsPanel />
|
<QuickSettingsPanel />
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
|||||||
type ActivityIndicatorProps = {
|
type ActivityIndicatorProps = {
|
||||||
activity: SessionActivity | null;
|
activity: SessionActivity | null;
|
||||||
onAbort?: () => void;
|
onAbort?: () => void;
|
||||||
|
isInputFocused?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_KEYS = [
|
const ACTION_KEYS = [
|
||||||
@@ -18,6 +19,7 @@ const ACTION_KEYS = [
|
|||||||
'claudeStatus.actions.reasoning',
|
'claudeStatus.actions.reasoning',
|
||||||
];
|
];
|
||||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||||
|
const EXIT_ANIMATION_MS = 220;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal response-in-progress indicator, in the spirit of the inline status
|
* Minimal response-in-progress indicator, in the spirit of the inline status
|
||||||
@@ -26,11 +28,31 @@ const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working',
|
|||||||
* session has an entry in the processing map; it disappears the instant that
|
* session has an entry in the processing map; it disappears the instant that
|
||||||
* entry is removed.
|
* entry is removed.
|
||||||
*/
|
*/
|
||||||
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const startedAt = activity?.startedAt ?? null;
|
const [renderedActivity, setRenderedActivity] = useState<SessionActivity | null>(activity);
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
const startedAt = renderedActivity?.startedAt ?? null;
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activity) {
|
||||||
|
setRenderedActivity(activity);
|
||||||
|
setIsExiting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renderedActivity) return;
|
||||||
|
|
||||||
|
setIsExiting(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setRenderedActivity(null);
|
||||||
|
setIsExiting(false);
|
||||||
|
}, EXIT_ANIMATION_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [activity, renderedActivity]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (startedAt === null) return;
|
if (startedAt === null) return;
|
||||||
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
||||||
@@ -39,10 +61,10 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [startedAt]);
|
}, [startedAt]);
|
||||||
|
|
||||||
if (!activity) return null;
|
if (!renderedActivity) return null;
|
||||||
|
|
||||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||||
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
const label = (renderedActivity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
||||||
.replace(/\.+$/, '');
|
.replace(/\.+$/, '');
|
||||||
|
|
||||||
const minutes = Math.floor(elapsedSeconds / 60);
|
const minutes = Math.floor(elapsedSeconds / 60);
|
||||||
@@ -50,19 +72,31 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
|
|||||||
const elapsedLabel = minutes < 1
|
const elapsedLabel = minutes < 1
|
||||||
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
||||||
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
||||||
|
const tabSurfaceClassName = [
|
||||||
|
'chat-activity-tab inline-flex h-8 items-center rounded-b-none rounded-t-lg border border-b-0 bg-card px-3 text-xs transition-all duration-200',
|
||||||
|
isInputFocused
|
||||||
|
? 'border-primary/30 shadow-[0_-1px_2px_hsl(var(--foreground)/0.08),1px_0_2px_hsl(var(--foreground)/0.06),-1px_0_2px_hsl(var(--foreground)/0.06)]'
|
||||||
|
: 'border-border/50 shadow-[0_-1px_1px_hsl(var(--foreground)/0.04),1px_0_1px_hsl(var(--foreground)/0.03),-1px_0_1px_hsl(var(--foreground)/0.03)]',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in mb-2 w-full duration-300">
|
<div
|
||||||
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
|
className={`pointer-events-none bg-transparent ${
|
||||||
|
isExiting ? 'chat-activity-exit' : 'chat-activity-enter'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-end justify-between gap-2">
|
||||||
|
<div className={`${tabSurfaceClassName} gap-2`}>
|
||||||
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||||
<Shimmer className="text-xs font-medium">{`${label}…`}</Shimmer>
|
<Shimmer className="font-medium">{`${label}…`}</Shimmer>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
<span className="tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{activity.canInterrupt && onAbort && (
|
{renderedActivity.canInterrupt && onAbort && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAbort}
|
onClick={onAbort}
|
||||||
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
className={`${tabSurfaceClassName} pointer-events-auto gap-1.5 text-muted-foreground hover:bg-card hover:text-destructive`}
|
||||||
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
||||||
>
|
>
|
||||||
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
RefObject,
|
RefObject,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
|
import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
||||||
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
||||||
@@ -68,9 +68,6 @@ interface ChatComposerProps {
|
|||||||
onToggleCommandMenu: () => void;
|
onToggleCommandMenu: () => void;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
onClearInput: () => void;
|
onClearInput: () => void;
|
||||||
isUserScrolledUp: boolean;
|
|
||||||
hasMessages: boolean;
|
|
||||||
onScrollToBottom: () => void;
|
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
|
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
|
||||||
isDragActive: boolean;
|
isDragActive: boolean;
|
||||||
attachedImages: File[];
|
attachedImages: File[];
|
||||||
@@ -101,6 +98,7 @@ interface ChatComposerProps {
|
|||||||
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
|
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
|
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
|
||||||
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||||
|
isInputFocused?: boolean;
|
||||||
onInputFocusChange?: (focused: boolean) => void;
|
onInputFocusChange?: (focused: boolean) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
isTextareaExpanded: boolean;
|
isTextareaExpanded: boolean;
|
||||||
@@ -122,9 +120,6 @@ export default function ChatComposer({
|
|||||||
onToggleCommandMenu,
|
onToggleCommandMenu,
|
||||||
hasInput,
|
hasInput,
|
||||||
onClearInput,
|
onClearInput,
|
||||||
isUserScrolledUp,
|
|
||||||
hasMessages,
|
|
||||||
onScrollToBottom,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isDragActive,
|
isDragActive,
|
||||||
attachedImages,
|
attachedImages,
|
||||||
@@ -155,6 +150,7 @@ export default function ChatComposer({
|
|||||||
onTextareaPaste,
|
onTextareaPaste,
|
||||||
onTextareaScrollSync,
|
onTextareaScrollSync,
|
||||||
onTextareaInput,
|
onTextareaInput,
|
||||||
|
isInputFocused = false,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
isTextareaExpanded,
|
isTextareaExpanded,
|
||||||
@@ -201,15 +197,18 @@ export default function ChatComposer({
|
|||||||
|
|
||||||
// Hide the thinking/status bar while any permission request is pending
|
// Hide the thinking/status bar while any permission request is pending
|
||||||
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
||||||
|
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-composer-shell flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
<div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
|
||||||
{!hasPendingPermissions && (
|
{!hasPendingPermissions && (
|
||||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 w-[calc(100%-1rem)] max-w-[54.25rem] -translate-x-1/2 translate-y-px bg-transparent sm:w-[calc(100%-2rem)]">
|
||||||
|
<ActivityIndicator activity={activity} onAbort={onAbortSession} isInputFocused={isInputFocused} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingPermissionRequests.length > 0 && (
|
{pendingPermissionRequests.length > 0 && (
|
||||||
<div className="mx-auto mb-3 max-w-4xl">
|
<div className="mx-auto mb-3 max-w-[54.25rem]">
|
||||||
<PermissionRequestsBanner
|
<PermissionRequestsBanner
|
||||||
pendingPermissionRequests={pendingPermissionRequests}
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
@@ -218,19 +217,7 @@ export default function ChatComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
|
{!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
|
||||||
{isUserScrolledUp && hasMessages && (
|
|
||||||
<div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onScrollToBottom}
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
|
|
||||||
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
|
||||||
>
|
|
||||||
<ArrowDownIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showFileDropdown && filteredFiles.length > 0 && (
|
{showFileDropdown && filteredFiles.length > 0 && (
|
||||||
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
||||||
{filteredFiles.map((file, index) => (
|
{filteredFiles.map((file, index) => (
|
||||||
@@ -271,7 +258,10 @@ export default function ChatComposer({
|
|||||||
<PromptInput
|
<PromptInput
|
||||||
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
|
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
|
||||||
status={isLoading ? 'streaming' : 'ready'}
|
status={isLoading ? 'streaming' : 'ready'}
|
||||||
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
|
className={[
|
||||||
|
isTextareaExpanded ? 'chat-input-expanded' : '',
|
||||||
|
hasActivityIndicator ? 'rounded-t-none' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
>
|
>
|
||||||
{isDragActive && (
|
{isDragActive && (
|
||||||
@@ -349,7 +339,7 @@ export default function ChatComposer({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onModeSwitch}
|
onClick={onModeSwitch}
|
||||||
className={`rounded-lg border p-2 text-xs font-medium transition-all duration-200 sm:px-2.5 sm:py-1 ${
|
className={`inline-flex h-8 items-center rounded-lg border px-2 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
|
||||||
permissionMode === 'default'
|
permissionMode === 'default'
|
||||||
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
|
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
|
||||||
: permissionMode === 'acceptEdits'
|
: permissionMode === 'acceptEdits'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { ChatMessage } from '../../types/types';
|
import type { ChatMessage } from '../../types/types';
|
||||||
@@ -15,6 +15,7 @@ import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping
|
|||||||
import MessageComponent from './MessageComponent';
|
import MessageComponent from './MessageComponent';
|
||||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||||
import ToolGroupContainer from './ToolGroupContainer';
|
import ToolGroupContainer from './ToolGroupContainer';
|
||||||
|
import LoadAllMessagesOverlay from './LoadAllMessagesOverlay';
|
||||||
|
|
||||||
interface ChatMessagesPaneProps {
|
interface ChatMessagesPaneProps {
|
||||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||||
@@ -61,7 +62,6 @@ interface ChatMessagesPaneProps {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject: Project;
|
selectedProject: Project;
|
||||||
@@ -111,48 +111,59 @@ function ChatMessagesPane({
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
onGrantToolPermission,
|
onGrantToolPermission,
|
||||||
autoExpandTools,
|
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
showThinking,
|
showThinking,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
}: ChatMessagesPaneProps) {
|
}: ChatMessagesPaneProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
const groupedVisibleMessages = useMemo(
|
||||||
const allocatedKeysRef = useRef<Set<string>>(new Set());
|
() => groupConsecutiveTools(visibleMessages, Boolean(showThinking)),
|
||||||
const generatedMessageKeyCounterRef = useRef(0);
|
[visibleMessages, showThinking],
|
||||||
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
|
);
|
||||||
|
|
||||||
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
|
// Stable, deterministic keys for the messages rendered this pass.
|
||||||
const getMessageKey = useCallback((message: ChatMessage) => {
|
//
|
||||||
const existingKey = messageKeyMapRef.current.get(message);
|
// `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store
|
||||||
if (existingKey) {
|
// update, so caching keys by object identity (or via a cross-render allocation
|
||||||
return existingKey;
|
// Set) minted a brand-new key for the *same* logical message on each prepend —
|
||||||
|
// remounting the whole list, which disconnects the scroll-restore anchor and
|
||||||
|
// reflows heights, jumping the viewport to the bottom. Deriving keys purely
|
||||||
|
// from this render's ordered messages (intrinsic key, disambiguated by
|
||||||
|
// occurrence index on collision) yields the same key for the same message
|
||||||
|
// order, so React preserves existing DOM nodes and component state on prepend.
|
||||||
|
const messageKeyMap = useMemo(() => {
|
||||||
|
const keys = new WeakMap<ChatMessage, string>();
|
||||||
|
const occurrences = new Map<string, number>();
|
||||||
|
const assign = (message: ChatMessage) => {
|
||||||
|
const intrinsicKey = getIntrinsicMessageKey(message) ?? 'message-generated';
|
||||||
|
const seen = occurrences.get(intrinsicKey) ?? 0;
|
||||||
|
occurrences.set(intrinsicKey, seen + 1);
|
||||||
|
keys.set(message, seen === 0 ? intrinsicKey : `${intrinsicKey}__${seen}`);
|
||||||
|
};
|
||||||
|
for (const item of groupedVisibleMessages) {
|
||||||
|
if (isToolGroupItem(item)) {
|
||||||
|
item.messages.forEach(assign);
|
||||||
|
} else {
|
||||||
|
assign(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
const intrinsicKey = getIntrinsicMessageKey(message);
|
|
||||||
let candidateKey = intrinsicKey;
|
|
||||||
|
|
||||||
if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {
|
|
||||||
do {
|
|
||||||
generatedMessageKeyCounterRef.current += 1;
|
|
||||||
candidateKey = intrinsicKey
|
|
||||||
? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
|
|
||||||
: `message-generated-${generatedMessageKeyCounterRef.current}`;
|
|
||||||
} while (allocatedKeysRef.current.has(candidateKey));
|
|
||||||
}
|
}
|
||||||
|
return keys;
|
||||||
|
}, [groupedVisibleMessages]);
|
||||||
|
|
||||||
allocatedKeysRef.current.add(candidateKey);
|
const getMessageKey = useCallback(
|
||||||
messageKeyMapRef.current.set(message, candidateKey);
|
(message: ChatMessage) =>
|
||||||
return candidateKey;
|
messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
|
||||||
}, []);
|
[messageKeyMap],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
className="chat-messages-pane relative min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
className="chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3 sm:py-4"
|
||||||
>
|
>
|
||||||
|
<div className="mx-auto w-full max-w-[54.25rem] space-y-3 px-4 sm:space-y-4">
|
||||||
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
||||||
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
@@ -208,35 +219,13 @@ function ChatMessagesPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Floating "Load all messages" overlay */}
|
<LoadAllMessagesOverlay
|
||||||
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
|
showLoadAllOverlay={showLoadAllOverlay}
|
||||||
<div className="pointer-events-none sticky top-2 z-20 flex justify-center">
|
isLoadingAllMessages={isLoadingAllMessages}
|
||||||
{loadAllJustFinished ? (
|
loadAllJustFinished={loadAllJustFinished}
|
||||||
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
|
totalMessages={totalMessages}
|
||||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onLoadAllMessages={loadAllMessages}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
/>
|
||||||
</svg>
|
|
||||||
<span>{t('session.messages.allLoaded')}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
|
|
||||||
onClick={loadAllMessages}
|
|
||||||
disabled={isLoadingAllMessages}
|
|
||||||
>
|
|
||||||
{isLoadingAllMessages && (
|
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{isLoadingAllMessages
|
|
||||||
? t('session.messages.loadingAll')
|
|
||||||
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legacy message count indicator (for non-paginated view) */}
|
{/* Legacy message count indicator (for non-paginated view) */}
|
||||||
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
||||||
@@ -273,7 +262,6 @@ function ChatMessagesPane({
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
onGrantToolPermission={onGrantToolPermission}
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
@@ -294,7 +282,6 @@ function ChatMessagesPane({
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
onGrantToolPermission={onGrantToolPermission}
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
@@ -306,6 +293,7 @@ function ChatMessagesPane({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import type { CSSProperties } from 'react';
|
import { createPortal } from 'react-dom';
|
||||||
|
import type { CSSProperties, ReactElement } from 'react';
|
||||||
import {
|
import {
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -77,6 +78,7 @@ const namespaceAccentClasses: Record<string, string> = {
|
|||||||
|
|
||||||
const MENU_EDGE_GAP = 16;
|
const MENU_EDGE_GAP = 16;
|
||||||
const MENU_MAX_HEIGHT = 360;
|
const MENU_MAX_HEIGHT = 360;
|
||||||
|
const MENU_MIN_HEIGHT = 160;
|
||||||
|
|
||||||
const getCommandKey = (command: CommandMenuCommand) =>
|
const getCommandKey = (command: CommandMenuCommand) =>
|
||||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||||
@@ -92,8 +94,9 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return { position: 'fixed', top: '16px', left: '16px' };
|
return { position: 'fixed', top: '16px', left: '16px' };
|
||||||
}
|
}
|
||||||
|
const maxAnchorBottom = Math.max(MENU_EDGE_GAP, window.innerHeight - MENU_EDGE_GAP - MENU_MIN_HEIGHT);
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
|
||||||
return {
|
return {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: `${anchorBottom}px`,
|
bottom: `${anchorBottom}px`,
|
||||||
@@ -104,7 +107,7 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
|
|||||||
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
|
||||||
const clampedLeft = Math.max(
|
const clampedLeft = Math.max(
|
||||||
MENU_EDGE_GAP,
|
MENU_EDGE_GAP,
|
||||||
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
|
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
|
||||||
@@ -216,12 +219,14 @@ export default function CommandMenu({
|
|||||||
: ['builtin', 'skill', 'project', 'user', 'other'];
|
: ['builtin', 'skill', 'project', 'user', 'other'];
|
||||||
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
||||||
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
||||||
|
const renderInPortal = (node: ReactElement) =>
|
||||||
|
typeof document === 'undefined' ? node : createPortal(node, document.body);
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
return (
|
return renderInPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="command-menu command-menu-empty border border-gray-200 bg-white/95 text-sm text-gray-500 dark:border-gray-700/80 dark:bg-gray-900/95 dark:text-gray-400"
|
className="command-menu command-menu-empty border border-border bg-popover/95 text-sm text-muted-foreground"
|
||||||
style={{
|
style={{
|
||||||
...menuBaseStyle,
|
...menuBaseStyle,
|
||||||
...menuPosition,
|
...menuPosition,
|
||||||
@@ -237,20 +242,20 @@ export default function CommandMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return renderInPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Available commands"
|
aria-label="Available commands"
|
||||||
className="command-menu border border-gray-200/90 bg-white/95 text-gray-900 dark:border-slate-700/80 dark:bg-slate-950/95 dark:text-slate-100"
|
className="command-menu border border-border bg-popover/95 text-popover-foreground"
|
||||||
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
|
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
|
||||||
>
|
>
|
||||||
{orderedNamespaces.map((namespace) => (
|
{orderedNamespaces.map((namespace) => (
|
||||||
<div key={namespace} className="command-group">
|
<div key={namespace} className="command-group">
|
||||||
{orderedNamespaces.length > 1 && (
|
{orderedNamespaces.length > 1 && (
|
||||||
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400">
|
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<span>{namespaceLabels[namespace] || namespace}</span>
|
<span>{namespaceLabels[namespace] || namespace}</span>
|
||||||
<span className="rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
<span className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
{(groupedCommands[namespace] || []).length}
|
{(groupedCommands[namespace] || []).length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,15 +273,15 @@ export default function CommandMenu({
|
|||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
|
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
|
? 'border-primary/30 bg-primary/10 shadow-sm'
|
||||||
: 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80'
|
: 'border-transparent bg-transparent hover:border-border hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
||||||
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
|
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-sky-500 dark:bg-cyan-300" />
|
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
|
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
|
||||||
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
|
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
|
||||||
@@ -284,20 +289,20 @@ export default function CommandMenu({
|
|||||||
<div className="min-w-0 flex-1 pr-1">
|
<div className="min-w-0 flex-1 pr-1">
|
||||||
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
||||||
<span
|
<span
|
||||||
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50"
|
className="min-w-0 truncate font-mono text-[13px] font-semibold text-foreground"
|
||||||
title={command.name}
|
title={command.name}
|
||||||
>
|
>
|
||||||
{command.name}
|
{command.name}
|
||||||
</span>
|
</span>
|
||||||
{command.metadata?.type && (
|
{command.metadata?.type && (
|
||||||
<span className="command-metadata-badge shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
|
<span className="command-metadata-badge shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
|
||||||
{command.metadata.type}
|
{command.metadata.type}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{command.description && (
|
{command.description && (
|
||||||
<div
|
<div
|
||||||
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400"
|
className="truncate whitespace-nowrap text-[12px] leading-4 text-muted-foreground"
|
||||||
title={command.description}
|
title={command.description}
|
||||||
>
|
>
|
||||||
{command.description}
|
{command.description}
|
||||||
@@ -305,7 +310,7 @@ export default function CommandMenu({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-sky-200 bg-white text-sky-600 shadow-sm dark:border-cyan-400/30 dark:bg-slate-950 dark:text-cyan-200">
|
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-primary/30 bg-card text-primary shadow-sm">
|
||||||
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
|
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -565,30 +565,26 @@ export default function CommandResultModal({
|
|||||||
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 ${
|
className={`flex shrink-0 items-start justify-between gap-3 border-b border-border bg-popover ${
|
||||||
isModelsModal ? 'px-4 pb-3 pt-3 sm:px-5 sm:pb-4 sm:pt-4' : 'px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5'
|
isModelsModal ? 'px-4 py-3 sm:px-5 sm:py-4' : 'px-4 py-4 sm:px-6 sm:py-5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" />
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-1/2 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_58%)]" />
|
|
||||||
|
|
||||||
<div className="relative flex items-start justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 items-start gap-3 sm:items-center">
|
|
||||||
<div
|
<div
|
||||||
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
|
className={`flex shrink-0 items-center justify-center rounded-xl border border-border bg-muted text-foreground ${
|
||||||
isModelsModal ? 'p-2.5' : 'p-3'
|
isModelsModal ? 'h-9 w-9' : 'h-10 w-10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
|
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[12px] font-bold uppercase tracking-[0.22em] text-primary">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
{activeMeta?.eyebrow}
|
{activeMeta?.eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-xl sm:text-2xl' : 'text-xl sm:text-2xl'}`}>
|
<p className="mt-0.5 text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||||
{activeMeta?.title}
|
{activeMeta?.title}
|
||||||
</p>
|
</p>
|
||||||
<p className={`mt-1 max-w-2xl ${isModelsModal ? 'text-sm leading-5 text-foreground/75' : 'text-sm leading-5 text-muted-foreground'}`}>
|
<p className="mt-0.5 max-w-2xl text-sm leading-5 text-muted-foreground">
|
||||||
{activeMeta?.subtitle}
|
{activeMeta?.subtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -599,13 +595,12 @@ export default function CommandResultModal({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:bg-background/70 hover:text-foreground"
|
className="h-8 w-8 shrink-0 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
aria-label="Close command result modal"
|
aria-label="Close command result modal"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
||||||
{payload?.kind === 'help' && <HelpContent data={payload.data as HelpCommandData} />}
|
{payload?.kind === 'help' && <HelpContent data={payload.data as HelpCommandData} />}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const loadAllOverlayAnimationStyle = `
|
||||||
|
@keyframes loadAllOverlayAutoFade {
|
||||||
|
0%, 80% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.load-all-overlay-auto-fade {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface LoadAllMessagesOverlayProps {
|
||||||
|
showLoadAllOverlay: boolean;
|
||||||
|
isLoadingAllMessages: boolean;
|
||||||
|
loadAllJustFinished: boolean;
|
||||||
|
totalMessages: number;
|
||||||
|
onLoadAllMessages: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadAllMessagesOverlay({
|
||||||
|
showLoadAllOverlay,
|
||||||
|
isLoadingAllMessages,
|
||||||
|
loadAllJustFinished,
|
||||||
|
totalMessages,
|
||||||
|
onLoadAllMessages,
|
||||||
|
}: LoadAllMessagesOverlayProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
|
if (!showLoadAllOverlay && !isLoadingAllMessages && !loadAllJustFinished) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none sticky top-2 z-20 flex justify-center ${!isLoadingAllMessages ? 'load-all-overlay-auto-fade' : ''}`}
|
||||||
|
style={!isLoadingAllMessages ? { animation: 'loadAllOverlayAutoFade 2500ms ease forwards' } : undefined}
|
||||||
|
>
|
||||||
|
<style>{loadAllOverlayAnimationStyle}</style>
|
||||||
|
{loadAllJustFinished ? (
|
||||||
|
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
|
||||||
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span>{t('session.messages.allLoaded')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||||
|
onClick={onLoadAllMessages}
|
||||||
|
disabled={isLoadingAllMessages}
|
||||||
|
>
|
||||||
|
{isLoadingAllMessages && (
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{isLoadingAllMessages
|
||||||
|
? t('session.messages.loadingAll')
|
||||||
|
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import remarkGfm from 'remark-gfm';
|
|||||||
import remarkMath from 'remark-math';
|
import remarkMath from 'remark-math';
|
||||||
import rehypeKatex from 'rehype-katex';
|
import rehypeKatex from 'rehype-katex';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
||||||
|
import { useTheme } from '../../../../contexts/ThemeContext';
|
||||||
|
|
||||||
type MarkdownProps = {
|
type MarkdownProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -59,6 +60,7 @@ type CodeBlockProps = {
|
|||||||
|
|
||||||
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
|
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
const { isDarkMode } = useTheme();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||||
const looksMultiline = /[\r\n]/.test(raw);
|
const looksMultiline = /[\r\n]/.test(raw);
|
||||||
@@ -96,7 +98,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100"
|
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted focus:opacity-100 active:opacity-100 group-hover:opacity-100"
|
||||||
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||||
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||||
>
|
>
|
||||||
@@ -132,17 +134,20 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
|||||||
|
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={language}
|
language={language}
|
||||||
style={oneDark}
|
style={isDarkMode ? oneDark : oneLight}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.75rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||||
|
// ChatGPT-style soft grey block in light mode; keep oneDark's own bg in dark.
|
||||||
|
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
|
||||||
}}
|
}}
|
||||||
codeTagProps={{
|
codeTagProps={{
|
||||||
style: {
|
style: {
|
||||||
fontFamily:
|
fontFamily:
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||||
|
...(isDarkMode ? {} : { background: 'transparent' }),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -154,6 +159,10 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
|||||||
|
|
||||||
const markdownComponents = {
|
const markdownComponents = {
|
||||||
code: CodeBlock,
|
code: CodeBlock,
|
||||||
|
// CodeBlock renders its own syntax-highlighted <pre>; this passthrough stops
|
||||||
|
// react-markdown (and Tailwind Typography) from wrapping it in a second,
|
||||||
|
// dark-themed <pre> shell that would frame the block.
|
||||||
|
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||||
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
@@ -30,7 +30,6 @@ type MessageComponentProps = {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
@@ -45,7 +44,7 @@ type InteractiveOption = {
|
|||||||
|
|
||||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||||
|
|
||||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
((prevMessage.type === 'assistant') ||
|
((prevMessage.type === 'assistant') ||
|
||||||
@@ -53,7 +52,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
(prevMessage.type === 'tool') ||
|
(prevMessage.type === 'tool') ||
|
||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const userCopyContent = String(message.content || '');
|
const userCopyContent = String(message.content || '');
|
||||||
const formattedMessageContent = useMemo(
|
const formattedMessageContent = useMemo(
|
||||||
() => formatUsageLimitText(String(message.content || '')),
|
() => formatUsageLimitText(String(message.content || '')),
|
||||||
@@ -72,32 +70,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
!message.isThinking;
|
!message.isThinking;
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const node = messageRef.current;
|
|
||||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting && !isExpanded) {
|
|
||||||
setIsExpanded(true);
|
|
||||||
const details = node.querySelectorAll<HTMLDetailsElement>('details');
|
|
||||||
details.forEach((detail) => {
|
|
||||||
detail.open = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(node);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.unobserve(node);
|
|
||||||
};
|
|
||||||
}, [autoExpandTools, isExpanded, message.isToolUse]);
|
|
||||||
|
|
||||||
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
|
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
|
||||||
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
|
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
|
||||||
|
|
||||||
@@ -115,7 +87,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
/* User message bubble on the right */
|
/* User message bubble on the right */
|
||||||
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||||
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
||||||
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
{message.images && message.images.length > 0 && (
|
{message.images && message.images.length > 0 && (
|
||||||
@@ -166,7 +138,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
🔧
|
🔧
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-foreground">
|
||||||
<SessionProviderLogo provider={provider} className="h-full w-full" />
|
<SessionProviderLogo provider={provider} className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -194,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm max-w-none font-serif dark:prose-invert">
|
||||||
{String(message.displayText || '')}
|
{String(message.displayText || '')}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +182,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
createDiff={createDiff}
|
createDiff={createDiff}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
|
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
|
||||||
isSubagentContainer={message.isSubagentContainer}
|
isSubagentContainer={message.isSubagentContainer}
|
||||||
@@ -233,7 +204,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
|
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative text-sm text-red-900 dark:text-red-100">
|
<div className="relative text-sm text-red-900 dark:text-red-100">
|
||||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-red max-w-none font-serif dark:prose-invert">
|
||||||
{String(message.toolResult.content || '')}
|
{String(message.toolResult.content || '')}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,7 +221,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
createDiff={createDiff}
|
createDiff={createDiff}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -342,7 +312,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
<Reasoning defaultOpen={false}>
|
<Reasoning defaultOpen={false}>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
|
||||||
{message.content}
|
{message.content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
<div className="mt-3 flex items-center text-[11px]">
|
<div className="mt-3 flex items-center text-[11px]">
|
||||||
@@ -377,15 +347,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2">
|
<div className="my-2">
|
||||||
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{t('json.response')}</span>
|
<span className="font-medium">{t('json.response')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900">
|
<div className="overflow-hidden rounded-lg border border-border bg-muted">
|
||||||
<pre className="overflow-x-auto p-4">
|
<pre className="overflow-x-auto p-4">
|
||||||
<code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200">
|
<code className="block whitespace-pre font-mono text-sm text-foreground">
|
||||||
{formatted}
|
{formatted}
|
||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -399,7 +369,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
|
|
||||||
// Normal rendering for non-JSON content
|
// Normal rendering for non-JSON content
|
||||||
return message.type === 'assistant' ? (
|
return message.type === 'assistant' ? (
|
||||||
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
|
||||||
{content}
|
{content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
|
|
||||||
@@ -49,9 +51,32 @@ const MessageCopyControl = ({
|
|||||||
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
|
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// The dropdown is rendered in a portal so it escapes the chat message's
|
||||||
|
// `contain: paint` box (which would otherwise clip it). Anchor it to the
|
||||||
|
// trigger, flipping above when there isn't room below.
|
||||||
|
const openDropdown = () => {
|
||||||
|
const rect = triggerRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) {
|
||||||
|
const ESTIMATED_MENU_HEIGHT = 84;
|
||||||
|
const openUp = rect.bottom + ESTIMATED_MENU_HEIGHT + 8 > window.innerHeight;
|
||||||
|
setMenuStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
right: Math.max(8, window.innerWidth - rect.right),
|
||||||
|
zIndex: 1000,
|
||||||
|
...(openUp
|
||||||
|
? { bottom: window.innerHeight - rect.top + 4 }
|
||||||
|
: { top: rect.bottom + 4 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsDropdownOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const copyFormatOptions: CopyFormatOption[] = useMemo(
|
const copyFormatOptions: CopyFormatOption[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -83,18 +108,28 @@ const MessageCopyControl = ({
|
|||||||
}, [defaultFormat]);
|
}, [defaultFormat]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Close the dropdown when clicking anywhere outside this control.
|
|
||||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
|
||||||
if (!isDropdownOpen) return;
|
if (!isDropdownOpen) return;
|
||||||
|
|
||||||
|
// Close when clicking outside both the control and the portaled menu.
|
||||||
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||||
const target = event.target as Node;
|
const target = event.target as Node;
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
|
if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
||||||
setIsDropdownOpen(false);
|
return;
|
||||||
}
|
}
|
||||||
|
setIsDropdownOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The menu is fixed-positioned; close it if the page scrolls so it can't
|
||||||
|
// detach from the trigger.
|
||||||
|
const closeOnScroll = () => setIsDropdownOpen(false);
|
||||||
|
|
||||||
window.addEventListener('mousedown', closeOnOutsideClick);
|
window.addEventListener('mousedown', closeOnOutsideClick);
|
||||||
|
window.addEventListener('scroll', closeOnScroll, true);
|
||||||
|
window.addEventListener('resize', closeOnScroll);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousedown', closeOnOutsideClick);
|
window.removeEventListener('mousedown', closeOnOutsideClick);
|
||||||
|
window.removeEventListener('scroll', closeOnScroll, true);
|
||||||
|
window.removeEventListener('resize', closeOnScroll);
|
||||||
};
|
};
|
||||||
}, [isDropdownOpen]);
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
@@ -170,8 +205,9 @@ const MessageCopyControl = ({
|
|||||||
{canSelectCopyFormat && (
|
{canSelectCopyFormat && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
|
||||||
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
||||||
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
@@ -186,8 +222,12 @@ const MessageCopyControl = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isDropdownOpen && (
|
{isDropdownOpen && createPortal(
|
||||||
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
style={menuStyle}
|
||||||
|
className="min-w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||||
|
>
|
||||||
{copyFormatOptions.map((option) => {
|
{copyFormatOptions.map((option) => {
|
||||||
const isSelected = option.format === selectedFormat;
|
const isSelected = option.format === selectedFormat;
|
||||||
return (
|
return (
|
||||||
@@ -196,15 +236,16 @@ const MessageCopyControl = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleFormatChange(option.format)}
|
onClick={() => handleFormatChange(option.format)}
|
||||||
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
||||||
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
|
? 'bg-accent text-foreground'
|
||||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
|
: 'text-foreground hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="block text-xs font-medium">{option.label}</span>
|
<span className="block text-xs font-medium">{option.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
if (!selectedSession && !currentSessionId) {
|
if (!selectedSession && !currentSessionId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center px-4">
|
<div className="flex h-full items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-[34.25rem]">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||||
{t("providerSelection.title")}
|
{t("providerSelection.title")}
|
||||||
@@ -352,7 +352,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
if (selectedSession) {
|
if (selectedSession) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="max-w-md px-6 text-center">
|
<div className="max-w-[34.25rem] px-6 text-center">
|
||||||
<p className="mb-1.5 text-lg font-semibold text-foreground">
|
<p className="mb-1.5 text-lg font-semibold text-foreground">
|
||||||
{t("session.continue.title")}
|
{t("session.continue.title")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
|
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
|
||||||
title={`${usedTokens.toLocaleString()} tokens used`}
|
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||||
aria-label="Show token usage"
|
aria-label="Show token usage"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface ToolGroupContainerProps {
|
|||||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
||||||
autoExpandTools?: boolean;
|
|
||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
@@ -66,7 +65,6 @@ export default function ToolGroupContainer({
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
onGrantToolPermission,
|
onGrantToolPermission,
|
||||||
autoExpandTools,
|
|
||||||
showRawParameters,
|
showRawParameters,
|
||||||
showThinking,
|
showThinking,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
@@ -133,7 +131,6 @@ export default function ToolGroupContainer({
|
|||||||
onFileOpen={onFileOpen}
|
onFileOpen={onFileOpen}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
onGrantToolPermission={onGrantToolPermission}
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export const CODE_EDITOR_STORAGE_KEYS = {
|
export const CODE_EDITOR_STORAGE_KEYS = {
|
||||||
theme: 'codeEditorTheme',
|
|
||||||
wordWrap: 'codeEditorWordWrap',
|
wordWrap: 'codeEditorWordWrap',
|
||||||
showMinimap: 'codeEditorShowMinimap',
|
showMinimap: 'codeEditorShowMinimap',
|
||||||
lineNumbers: 'codeEditorLineNumbers',
|
lineNumbers: 'codeEditorLineNumbers',
|
||||||
@@ -7,7 +6,6 @@ export const CODE_EDITOR_STORAGE_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const CODE_EDITOR_DEFAULTS = {
|
export const CODE_EDITOR_DEFAULTS = {
|
||||||
isDarkMode: true,
|
|
||||||
wordWrap: false,
|
wordWrap: false,
|
||||||
minimapEnabled: true,
|
minimapEnabled: true,
|
||||||
showLineNumbers: true,
|
showLineNumbers: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { api } from '../../../utils/api';
|
import { api } from '../../../utils/api';
|
||||||
import type { CodeEditorFile } from '../types/types';
|
import type { CodeEditorFile } from '../types/types';
|
||||||
import { isBinaryFile } from '../utils/binaryFile';
|
import { isBinaryFile } from '../utils/binaryFile';
|
||||||
|
import { getPreviewKind } from '../utils/previewableFile';
|
||||||
|
|
||||||
type UseCodeEditorDocumentParams = {
|
type UseCodeEditorDocumentParams = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [isBinary, setIsBinary] = useState(false);
|
const [isBinary, setIsBinary] = useState(false);
|
||||||
|
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
|
||||||
|
// editor shows an inline preview instead of the generic binary placeholder.
|
||||||
|
const previewKind = getPreviewKind(file.name);
|
||||||
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
||||||
// the fallback to `projectPath` preserves older callers that didn't yet
|
// the fallback to `projectPath` preserves older callers that didn't yet
|
||||||
// propagate the identifier.
|
// propagate the identifier.
|
||||||
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setIsBinary(false);
|
setIsBinary(false);
|
||||||
|
|
||||||
|
// Natively previewable media (image/pdf/audio/video) is rendered by
|
||||||
|
// CodeEditorMediaPreview, so there is nothing to read as text here.
|
||||||
|
// Clear any buffer left over from a previously opened text file so a
|
||||||
|
// stray save can't write stale content over the binary file.
|
||||||
|
if (getPreviewKind(file.name)) {
|
||||||
|
setContent('');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file is binary by extension
|
// Check if file is binary by extension
|
||||||
if (isBinaryFile(file.name)) {
|
if (isBinaryFile(file.name)) {
|
||||||
|
setContent('');
|
||||||
setIsBinary(true);
|
setIsBinary(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
// Preview-only and binary files have no editable text buffer; never write
|
||||||
|
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
|
||||||
|
if (previewKind || isBinaryFile(fileName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
|
||||||
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [content, filePath, fileProjectId]);
|
}, [content, filePath, fileProjectId, previewKind, fileName]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
|
previewKind,
|
||||||
|
fileProjectId,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,15 +5,6 @@ import {
|
|||||||
CODE_EDITOR_STORAGE_KEYS,
|
CODE_EDITOR_STORAGE_KEYS,
|
||||||
} from '../constants/settings';
|
} from '../constants/settings';
|
||||||
|
|
||||||
const readTheme = () => {
|
|
||||||
const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
|
|
||||||
if (!savedTheme) {
|
|
||||||
return CODE_EDITOR_DEFAULTS.isDarkMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return savedTheme === 'dark';
|
|
||||||
};
|
|
||||||
|
|
||||||
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
|
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
|
||||||
const value = localStorage.getItem(storageKey);
|
const value = localStorage.getItem(storageKey);
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
@@ -33,7 +24,6 @@ const readFontSize = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useCodeEditorSettings = () => {
|
export const useCodeEditorSettings = () => {
|
||||||
const [isDarkMode, setIsDarkMode] = useState(readTheme);
|
|
||||||
const [wordWrap, setWordWrap] = useState(readWordWrap);
|
const [wordWrap, setWordWrap] = useState(readWordWrap);
|
||||||
const [minimapEnabled, setMinimapEnabled] = useState(() => (
|
const [minimapEnabled, setMinimapEnabled] = useState(() => (
|
||||||
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
|
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
|
||||||
@@ -43,18 +33,13 @@ export const useCodeEditorSettings = () => {
|
|||||||
));
|
));
|
||||||
const [fontSize, setFontSize] = useState(readFontSize);
|
const [fontSize, setFontSize] = useState(readFontSize);
|
||||||
|
|
||||||
// Keep legacy behavior where the editor writes theme and wrap settings directly.
|
// Keep legacy behavior where the editor writes wrap settings directly.
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
|
|
||||||
}, [isDarkMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
|
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
|
||||||
}, [wordWrap]);
|
}, [wordWrap]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refreshFromStorage = () => {
|
const refreshFromStorage = () => {
|
||||||
setIsDarkMode(readTheme());
|
|
||||||
setWordWrap(readWordWrap());
|
setWordWrap(readWordWrap());
|
||||||
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
|
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
|
||||||
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
|
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
|
||||||
@@ -71,8 +56,6 @@ export const useCodeEditorSettings = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDarkMode,
|
|
||||||
setIsDarkMode,
|
|
||||||
wordWrap,
|
wordWrap,
|
||||||
setWordWrap,
|
setWordWrap,
|
||||||
minimapEnabled,
|
minimapEnabled,
|
||||||
|
|||||||
63
src/components/code-editor/utils/previewableFile.ts
Normal file
63
src/components/code-editor/utils/previewableFile.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Some binary files can't be edited as text, but the browser can still render
|
||||||
|
// them natively (images, PDFs, audio, video). For those we show an inline
|
||||||
|
// preview instead of the generic "binary file" placeholder. Anything not listed
|
||||||
|
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
|
||||||
|
|
||||||
|
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
|
||||||
|
|
||||||
|
// Single source of truth: every extension the browser can preview, mapped to the
|
||||||
|
// MIME type we apply when the server response has a missing/generic Content-Type.
|
||||||
|
// The preview kind is derived from the MIME type so the two never drift apart.
|
||||||
|
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
|
||||||
|
// absent and keep the binary fallback.
|
||||||
|
const EXTENSION_MIME: Record<string, string> = {
|
||||||
|
// Images
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
webp: 'image/webp',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
avif: 'image/avif',
|
||||||
|
apng: 'image/apng',
|
||||||
|
// PDF
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
// Video
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
ogv: 'video/ogg',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
m4v: 'video/x-m4v',
|
||||||
|
// Audio
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
opus: 'audio/opus',
|
||||||
|
oga: 'audio/ogg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
weba: 'audio/webm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
const kindForMime = (mime: string): PreviewKind | null => {
|
||||||
|
if (mime === 'application/pdf') return 'pdf';
|
||||||
|
if (mime.startsWith('image/')) return 'image';
|
||||||
|
if (mime.startsWith('video/')) return 'video';
|
||||||
|
if (mime.startsWith('audio/')) return 'audio';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPreviewKind = (filename: string): PreviewKind | null => {
|
||||||
|
const mime = EXTENSION_MIME[extensionOf(filename)];
|
||||||
|
return mime ? kindForMime(mime) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// MIME type to fall back to when the server returns no/generic Content-Type.
|
||||||
|
// Returns undefined for non-previewable extensions.
|
||||||
|
export const getPreviewMimeType = (filename: string): string | undefined =>
|
||||||
|
EXTENSION_MIME[extensionOf(filename)];
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
import { unifiedMergeView } from '@codemirror/merge';
|
import { unifiedMergeView } from '@codemirror/merge';
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
||||||
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
||||||
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
|
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
|
||||||
@@ -11,11 +13,13 @@ import type { CodeEditorFile } from '../types/types';
|
|||||||
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
||||||
import { getEditorStyles } from '../utils/editorStyles';
|
import { getEditorStyles } from '../utils/editorStyles';
|
||||||
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
||||||
|
|
||||||
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||||
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
||||||
|
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
|
||||||
|
|
||||||
type CodeEditorProps = {
|
type CodeEditorProps = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -42,8 +46,10 @@ export default function CodeEditor({
|
|||||||
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
|
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
|
||||||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||||||
|
|
||||||
|
// The code editor follows the app-wide theme; it has no theme of its own.
|
||||||
|
const { isDarkMode } = useTheme();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDarkMode,
|
|
||||||
wordWrap,
|
wordWrap,
|
||||||
minimapEnabled,
|
minimapEnabled,
|
||||||
showLineNumbers,
|
showLineNumbers,
|
||||||
@@ -58,6 +64,8 @@ export default function CodeEditor({
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
|
previewKind,
|
||||||
|
fileProjectId,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
} = useCodeEditorDocument({
|
} = useCodeEditorDocument({
|
||||||
@@ -70,6 +78,29 @@ export default function CodeEditor({
|
|||||||
return extension === 'md' || extension === 'markdown';
|
return extension === 'md' || extension === 'markdown';
|
||||||
}, [file.name]);
|
}, [file.name]);
|
||||||
|
|
||||||
|
const isHtmlPreviewFile = useMemo(() => {
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
return extension === 'html' || extension === 'htm';
|
||||||
|
}, [file.name]);
|
||||||
|
|
||||||
|
const openHtmlPreview = useCallback(() => {
|
||||||
|
const previewWindow = window.open('', '_blank');
|
||||||
|
if (!previewWindow) return;
|
||||||
|
|
||||||
|
previewWindow.opener = null;
|
||||||
|
previewWindow.document.title = file.name;
|
||||||
|
previewWindow.document.body.style.margin = '0';
|
||||||
|
|
||||||
|
const iframe = previewWindow.document.createElement('iframe');
|
||||||
|
iframe.title = file.name;
|
||||||
|
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
|
||||||
|
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
|
||||||
|
|
||||||
|
iframe.srcdoc = content;
|
||||||
|
|
||||||
|
previewWindow.document.body.appendChild(iframe);
|
||||||
|
}, [content, file.name]);
|
||||||
|
|
||||||
const minimapExtension = useMemo(
|
const minimapExtension = useMemo(
|
||||||
() => (
|
() => (
|
||||||
createMinimapExtension({
|
createMinimapExtension({
|
||||||
@@ -162,6 +193,30 @@ export default function CodeEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Natively previewable media (image/pdf/audio/video) is rendered inline
|
||||||
|
// instead of showing the generic "cannot be displayed" placeholder.
|
||||||
|
if (previewKind) {
|
||||||
|
return (
|
||||||
|
<CodeEditorMediaPreview
|
||||||
|
file={file}
|
||||||
|
kind={previewKind}
|
||||||
|
projectId={fileProjectId}
|
||||||
|
isSidebar={isSidebar}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onClose={onClose}
|
||||||
|
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
||||||
|
labels={{
|
||||||
|
loading: t('filePreview.loading', 'Loading preview...'),
|
||||||
|
error: t('filePreview.error', 'Unable to display this file.'),
|
||||||
|
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
|
||||||
|
fullscreen: t('actions.fullscreen', 'Fullscreen'),
|
||||||
|
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
|
||||||
|
close: t('actions.close', 'Close'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Binary file display
|
// Binary file display
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
return (
|
return (
|
||||||
@@ -197,10 +252,12 @@ export default function CodeEditor({
|
|||||||
isSidebar={isSidebar}
|
isSidebar={isSidebar}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
isMarkdownFile={isMarkdownFile}
|
isMarkdownFile={isMarkdownFile}
|
||||||
|
isHtmlPreviewFile={isHtmlPreviewFile}
|
||||||
markdownPreview={markdownPreview}
|
markdownPreview={markdownPreview}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
saveSuccess={saveSuccess}
|
saveSuccess={saveSuccess}
|
||||||
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
||||||
|
onOpenHtmlPreview={openHtmlPreview}
|
||||||
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -210,6 +267,7 @@ export default function CodeEditor({
|
|||||||
showingChanges: t('header.showingChanges'),
|
showingChanges: t('header.showingChanges'),
|
||||||
editMarkdown: t('actions.editMarkdown'),
|
editMarkdown: t('actions.editMarkdown'),
|
||||||
previewMarkdown: t('actions.previewMarkdown'),
|
previewMarkdown: t('actions.previewMarkdown'),
|
||||||
|
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
|
||||||
settings: t('toolbar.settings'),
|
settings: t('toolbar.settings'),
|
||||||
download: t('actions.download'),
|
download: t('actions.download'),
|
||||||
save: t('actions.save'),
|
save: t('actions.save'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
||||||
|
|
||||||
import type { CodeEditorFile } from '../../types/types';
|
import type { CodeEditorFile } from '../../types/types';
|
||||||
|
|
||||||
type CodeEditorHeaderProps = {
|
type CodeEditorHeaderProps = {
|
||||||
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
|
|||||||
isSidebar: boolean;
|
isSidebar: boolean;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
isMarkdownFile: boolean;
|
isMarkdownFile: boolean;
|
||||||
|
isHtmlPreviewFile: boolean;
|
||||||
markdownPreview: boolean;
|
markdownPreview: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
saveSuccess: boolean;
|
saveSuccess: boolean;
|
||||||
onToggleMarkdownPreview: () => void;
|
onToggleMarkdownPreview: () => void;
|
||||||
|
onOpenHtmlPreview: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
|
|||||||
showingChanges: string;
|
showingChanges: string;
|
||||||
editMarkdown: string;
|
editMarkdown: string;
|
||||||
previewMarkdown: string;
|
previewMarkdown: string;
|
||||||
|
previewHtml: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
download: string;
|
download: string;
|
||||||
save: string;
|
save: string;
|
||||||
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
|
|||||||
isSidebar,
|
isSidebar,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isMarkdownFile,
|
isMarkdownFile,
|
||||||
|
isHtmlPreviewFile,
|
||||||
markdownPreview,
|
markdownPreview,
|
||||||
saving,
|
saving,
|
||||||
saveSuccess,
|
saveSuccess,
|
||||||
onToggleMarkdownPreview,
|
onToggleMarkdownPreview,
|
||||||
|
onOpenHtmlPreview,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onDownload,
|
onDownload,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isHtmlPreviewFile && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenHtmlPreview}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
title={labels.previewHtml}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { authenticatedFetch } from '../../../../utils/api';
|
||||||
|
import type { CodeEditorFile } from '../../types/types';
|
||||||
|
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
|
||||||
|
|
||||||
|
type CodeEditorMediaPreviewProps = {
|
||||||
|
file: CodeEditorFile;
|
||||||
|
kind: PreviewKind;
|
||||||
|
// DB projectId used to build the raw-content URL; falls back to projectPath
|
||||||
|
// for older callers, mirroring useCodeEditorDocument.
|
||||||
|
projectId?: string;
|
||||||
|
isSidebar: boolean;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
labels: {
|
||||||
|
loading: string;
|
||||||
|
error: string;
|
||||||
|
openInNewTab: string;
|
||||||
|
fullscreen: string;
|
||||||
|
exitFullscreen: string;
|
||||||
|
close: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
|
||||||
|
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
|
||||||
|
const PDF_HEADER_SCAN_BYTES = 1024;
|
||||||
|
|
||||||
|
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
|
||||||
|
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
|
||||||
|
// PDFs must contain the "%PDF-" marker at the very start of the file.
|
||||||
|
return new TextDecoder('latin1').decode(header).includes('%PDF-');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CodeEditorMediaPreview({
|
||||||
|
file,
|
||||||
|
kind,
|
||||||
|
projectId,
|
||||||
|
isSidebar,
|
||||||
|
isFullscreen,
|
||||||
|
onClose,
|
||||||
|
onToggleFullscreen,
|
||||||
|
labels,
|
||||||
|
}: CodeEditorMediaPreviewProps) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
// Identifies which file the current `url` was loaded for. Rendering is gated on
|
||||||
|
// this so a blob from a previously-opened file can never show under the new
|
||||||
|
// file (the editor reuses this component instance across files).
|
||||||
|
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||||
|
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
setUrl(null);
|
||||||
|
setLoadedKey(null);
|
||||||
|
setError(labels.error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const loadMedia = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setUrl(null);
|
||||||
|
|
||||||
|
// The content endpoint requires the auth header, so we fetch the bytes
|
||||||
|
// ourselves and hand the media element a blob URL instead of a bare src.
|
||||||
|
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
|
||||||
|
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||||
|
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Pick the MIME type to expose to the browser. Preserve a valid
|
||||||
|
// Content-Type from the server, but supply an extension-specific
|
||||||
|
// default when it is missing or generic (application/octet-stream),
|
||||||
|
// otherwise formats like webm/ogg/flac/svg won't render.
|
||||||
|
const fallbackMime = getPreviewMimeType(file.name);
|
||||||
|
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
|
||||||
|
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
|
||||||
|
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
|
||||||
|
|
||||||
|
if (kind === 'pdf') {
|
||||||
|
// The PDF renders in a same-origin <iframe>, so verify the bytes are
|
||||||
|
// really a PDF and pin the type to application/pdf. That forces the
|
||||||
|
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
|
||||||
|
// executing scripts in the app's origin.
|
||||||
|
if (!(await looksLikePdf(blob))) {
|
||||||
|
throw new Error('File is not a valid PDF');
|
||||||
|
}
|
||||||
|
outType = 'application/pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
|
||||||
|
objectUrl = URL.createObjectURL(typed);
|
||||||
|
|
||||||
|
// The cleanup may have already run (deps changed during an await), in
|
||||||
|
// which case it revoked nothing because objectUrl was still null. Don't
|
||||||
|
// publish a URL the cleanup will never revoke — drop it ourselves.
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrl(objectUrl);
|
||||||
|
setLoadedKey(sourceKey);
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
if (loadError instanceof Error && loadError.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Error loading preview:', loadError);
|
||||||
|
setError(labels.error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMedia();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
|
||||||
|
|
||||||
|
// Only expose the blob once it matches the file currently being shown, so a
|
||||||
|
// stale URL from the previous file is never rendered during a switch.
|
||||||
|
const currentUrl = url && loadedKey === sourceKey ? url : null;
|
||||||
|
|
||||||
|
// SVGs render safely inline via <img> (scripts don't execute there), but the
|
||||||
|
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
|
||||||
|
// app's origin, so a user-controlled SVG with an embedded <script> would run
|
||||||
|
// as same-origin script. Withhold the new-tab action for SVGs.
|
||||||
|
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
|
||||||
|
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
|
||||||
|
|
||||||
|
const renderMedia = () => {
|
||||||
|
if (!currentUrl) return null;
|
||||||
|
switch (kind) {
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={currentUrl}
|
||||||
|
alt={file.name}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'pdf':
|
||||||
|
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
|
||||||
|
// load inside a sandboxed frame (any `sandbox` value yields a broken
|
||||||
|
// viewer). Script execution is instead prevented upstream by validating
|
||||||
|
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
|
||||||
|
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
|
||||||
|
{labels.error}
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
case 'audio':
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
|
||||||
|
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
|
||||||
|
<audio src={currentUrl} controls className="w-full">
|
||||||
|
{labels.error}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewBody = (
|
||||||
|
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-muted-foreground">{labels.loading}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && currentUrl && renderMedia()}
|
||||||
|
|
||||||
|
{!loading && !currentUrl && (
|
||||||
|
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
|
||||||
|
<p className="text-sm">{error || labels.error}</p>
|
||||||
|
<p className="break-all text-xs">{file.path}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerActions = (
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
{canOpenInNewTab && currentUrl && (
|
||||||
|
<a
|
||||||
|
href={currentUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={labels.openInNewTab}
|
||||||
|
title={labels.openInNewTab}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{!isSidebar && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||||
|
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={labels.close}
|
||||||
|
title={labels.close}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
|
||||||
|
</div>
|
||||||
|
{headerActions}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSidebar) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col bg-background">
|
||||||
|
{header}
|
||||||
|
{previewBody}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerClassName = isFullscreen
|
||||||
|
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
|
||||||
|
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
|
||||||
|
|
||||||
|
const innerClassName = isFullscreen
|
||||||
|
? 'bg-background flex flex-col w-full h-full'
|
||||||
|
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClassName}>
|
||||||
|
<div className={innerClassName}>
|
||||||
|
{header}
|
||||||
|
{previewBody}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { oneDark as prismOneDark, oneLight as prismOneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
||||||
|
import { useTheme } from '../../../../../contexts/ThemeContext';
|
||||||
|
|
||||||
type MarkdownCodeBlockProps = {
|
type MarkdownCodeBlockProps = {
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
@@ -16,6 +17,7 @@ export default function MarkdownCodeBlock({
|
|||||||
node: _node,
|
node: _node,
|
||||||
...props
|
...props
|
||||||
}: MarkdownCodeBlockProps) {
|
}: MarkdownCodeBlockProps) {
|
||||||
|
const { isDarkMode } = useTheme();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
|
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||||
const looksMultiline = /[\r\n]/.test(rawContent);
|
const looksMultiline = /[\r\n]/.test(rawContent);
|
||||||
@@ -50,20 +52,22 @@ export default function MarkdownCodeBlock({
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 group-hover:opacity-100"
|
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={language}
|
language={language}
|
||||||
style={prismOneDark}
|
style={isDarkMode ? prismOneDark : prismOneLight}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.75rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||||
|
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
|
||||||
}}
|
}}
|
||||||
|
codeTagProps={{ style: isDarkMode ? {} : { background: 'transparent' } }}
|
||||||
>
|
>
|
||||||
{rawContent}
|
{rawContent}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ type MarkdownPreviewProps = {
|
|||||||
|
|
||||||
const markdownPreviewComponents: Components = {
|
const markdownPreviewComponents: Components = {
|
||||||
code: MarkdownCodeBlock,
|
code: MarkdownCodeBlock,
|
||||||
|
// MarkdownCodeBlock renders its own highlighted <pre>; passthrough prevents a
|
||||||
|
// second Typography-styled <pre> shell from framing it.
|
||||||
|
pre: ({ children }) => <>{children}</>,
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { default as ComputerUsePanel } from './view/ComputerUsePanel';
|
|
||||||
@@ -1,537 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from '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;
|
|
||||||
sessionCount: number;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComputerUseSession = {
|
|
||||||
id: string;
|
|
||||||
status: 'ready' | 'stopped' | 'unavailable';
|
|
||||||
screenshotDataUrl: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
lastAction: string | null;
|
|
||||||
message: string | null;
|
|
||||||
agentAccessEnabled: boolean;
|
|
||||||
createdBy: 'user' | 'agent';
|
|
||||||
displaySize: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null;
|
|
||||||
cursor: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
actor: 'agent' | 'user';
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComputerUsePanelProps = {
|
|
||||||
isVisible: boolean;
|
|
||||||
onShowSettings?: (tab?: SettingsMainTab) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const viewerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const selectedSession = useMemo(
|
|
||||||
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
|
|
||||||
[selectedSessionId, sessions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
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(() => {
|
|
||||||
if (!isVisible) return;
|
|
||||||
void refresh().catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Computer Use'));
|
|
||||||
}, [isVisible, refresh]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
void refresh().catch((err) => setError(err instanceof Error ? err.message : 'Failed to refresh Computer Use'));
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
// Poll while an active session exists so agent-driven changes show up live.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVisible || !selectedSession || selectedSession.status !== 'ready') return;
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
void refresh().catch(() => undefined);
|
|
||||||
}, 1500);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [isVisible, selectedSession, refresh]);
|
|
||||||
|
|
||||||
const runAction = useCallback(async (action: () => Promise<void>) => {
|
|
||||||
setIsBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
await refresh();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Computer Use action failed');
|
|
||||||
} finally {
|
|
||||||
setIsBusy(false);
|
|
||||||
}
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const captureScreenshot = () => runAction(async () => {
|
|
||||||
if (!selectedSession) return;
|
|
||||||
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/screenshot`, { method: 'POST' });
|
|
||||||
await readJson(response);
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopSession = () => runAction(async () => {
|
|
||||||
if (!selectedSession) return;
|
|
||||||
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
|
|
||||||
await readJson(response);
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteSession = () => runAction(async () => {
|
|
||||||
if (!selectedSession) return;
|
|
||||||
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}`, { method: 'DELETE' });
|
|
||||||
await readJson(response);
|
|
||||||
setIsFullscreen(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
const grantControl = () => runAction(async () => {
|
|
||||||
if (!selectedSession) return;
|
|
||||||
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/consent/grant`, { method: 'POST' });
|
|
||||||
await readJson(response);
|
|
||||||
});
|
|
||||||
|
|
||||||
const revokeControl = () => runAction(async () => {
|
|
||||||
if (!selectedSession) return;
|
|
||||||
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/consent/revoke`, { method: 'POST' });
|
|
||||||
await readJson(response);
|
|
||||||
});
|
|
||||||
|
|
||||||
const installRuntime = () => runAction(async () => {
|
|
||||||
setIsInstalling(true);
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch('/api/computer-use/runtime/install', { method: 'POST' });
|
|
||||||
await readJson(response);
|
|
||||||
} finally {
|
|
||||||
setIsInstalling(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const clickViewer = useCallback((event: MouseEvent<HTMLImageElement>) => {
|
|
||||||
if (!selectedSession || selectedSession.status !== 'ready' || !selectedSession.displaySize) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
viewerRef.current?.focus();
|
|
||||||
|
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
|
||||||
const scaleX = selectedSession.displaySize.width / bounds.width;
|
|
||||||
const scaleY = selectedSession.displaySize.height / bounds.height;
|
|
||||||
const x = Math.round((event.clientX - bounds.left) * scaleX);
|
|
||||||
const y = Math.round((event.clientY - bounds.top) * scaleY);
|
|
||||||
|
|
||||||
void runAction(async () => {
|
|
||||||
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/click`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ x, y, double: event.detail === 2 }),
|
|
||||||
});
|
|
||||||
await readJson(response);
|
|
||||||
});
|
|
||||||
}, [runAction, selectedSession]);
|
|
||||||
|
|
||||||
const keyForEvent = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (event.key === ' ') return 'Space';
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (event.ctrlKey) parts.push('ctrl');
|
|
||||||
if (event.altKey) parts.push('alt');
|
|
||||||
if (event.shiftKey && event.key.length > 1) parts.push('shift');
|
|
||||||
if (event.metaKey) parts.push('meta');
|
|
||||||
parts.push(event.key);
|
|
||||||
return parts.join('+');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const pressViewerKey = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (!selectedSession || selectedSession.status !== 'ready') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ignoredKeys = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock']);
|
|
||||||
if (ignoredKeys.has(event.key)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
const key = keyForEvent(event);
|
|
||||||
void runAction(async () => {
|
|
||||||
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/press-key`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ key }),
|
|
||||||
});
|
|
||||||
await readJson(response);
|
|
||||||
});
|
|
||||||
}, [keyForEvent, runAction, selectedSession]);
|
|
||||||
|
|
||||||
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
|
|
||||||
? {
|
|
||||||
left: `${(selectedSession.cursor.x / selectedSession.displaySize.width) * 100}%`,
|
|
||||||
top: `${(selectedSession.cursor.y / selectedSession.displaySize.height) * 100}%`,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const renderSurface = (fullscreen = false) => (
|
|
||||||
<div
|
|
||||||
ref={viewerRef}
|
|
||||||
tabIndex={selectedSession?.status === 'ready' ? 0 : -1}
|
|
||||||
onKeyDown={pressViewerKey}
|
|
||||||
className={`flex min-h-[360px] flex-1 items-center justify-center bg-neutral-950 outline-none ${fullscreen ? 'min-h-[80vh]' : ''}`}
|
|
||||||
>
|
|
||||||
{selectedSession?.screenshotDataUrl ? (
|
|
||||||
<div className="relative inline-block max-h-full">
|
|
||||||
<img
|
|
||||||
src={selectedSession.screenshotDataUrl}
|
|
||||||
alt="Desktop screenshot"
|
|
||||||
className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[70vh] w-auto max-w-full object-contain'}
|
|
||||||
onClick={clickViewer}
|
|
||||||
/>
|
|
||||||
{cursorStyle && (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-sky-500/80 shadow-[0_0_0_6px_rgba(14,165,233,0.18)]"
|
|
||||||
style={cursorStyle}
|
|
||||||
>
|
|
||||||
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-w-md px-6 text-center">
|
|
||||||
<MonitorCog className="mx-auto h-10 w-10 text-neutral-500" />
|
|
||||||
<div className="mt-3 text-sm font-medium text-neutral-100">
|
|
||||||
{selectedSession?.message || 'No active Computer Use session.'}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs leading-relaxed text-neutral-400">
|
|
||||||
{isCloud
|
|
||||||
? 'Agents create sessions automatically. Keep the CloudCLI desktop app connected to approve control requests.'
|
|
||||||
: 'Agents create sessions automatically. Enable Computer Use and install the local runtime if needed.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
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">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<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>
|
|
||||||
<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 linked desktops.'
|
|
||||||
: 'Monitor local desktop sessions and grant control only when an agent needs it.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={isRefreshing || isBusy}
|
|
||||||
title="Refresh Computer Use"
|
|
||||||
aria-label="Refresh Computer Use"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
{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.'}
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
||||||
<span className="rounded-md border border-border px-2 py-1">
|
|
||||||
Control lib: {status?.nutInstalled ? 'installed' : 'missing'}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-md border border-border px-2 py-1">
|
|
||||||
Screen capture: {status?.screenshotInstalled ? 'installed' : 'missing'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
{isInstalling || status?.installInProgress ? 'Installing…' : 'Install Runtime'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<div className="rounded-lg border border-border/70 bg-muted/30 p-3 text-xs leading-relaxed text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-1.5 font-medium text-foreground">
|
|
||||||
<ShieldCheck className="h-3.5 w-3.5" />
|
|
||||||
Safety
|
|
||||||
</div>
|
|
||||||
{isCloud ? (
|
|
||||||
<p className="mt-1.5">
|
|
||||||
Agents create sessions automatically through MCP. The CloudCLI desktop app asks for approval on this
|
|
||||||
computer, and <span className="font-medium text-foreground">Stop</span> ends the session and clears access.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-1.5">
|
|
||||||
Agents create sessions automatically through MCP but cannot act until you grant control here. Use
|
|
||||||
<span className="font-medium text-foreground"> Grant Control </span>
|
|
||||||
to allow agent actions, and
|
|
||||||
<span className="font-medium text-foreground"> Stop </span>
|
|
||||||
to revoke instantly.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{sessions.map((session) => (
|
|
||||||
<button
|
|
||||||
key={session.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedSessionId(session.id)}
|
|
||||||
className={`w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors ${selectedSession?.id === session.id
|
|
||||||
? 'border-primary/50 bg-primary/10 text-foreground'
|
|
||||||
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate font-medium">
|
|
||||||
{session.createdBy === 'agent' ? 'Agent session' : 'Desktop session'}
|
|
||||||
</span>
|
|
||||||
<Badge variant="outline" className="text-[10px]">{session.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
|
||||||
{session.agentAccessEnabled ? (
|
|
||||||
<span className="rounded border border-emerald-500/30 px-1.5 py-0.5 text-[10px] text-emerald-600 dark:text-emerald-300">
|
|
||||||
control granted
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="rounded border border-amber-500/30 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300">
|
|
||||||
awaiting consent
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 truncate text-xs">{session.lastAction || session.message || session.id}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{sessions.length === 0 && (
|
|
||||||
<div className="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
|
|
||||||
Agents will create sessions automatically when they need desktop access.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="flex min-h-0 flex-col">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={captureScreenshot} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
|
|
||||||
<Camera className="h-4 w-4" />
|
|
||||||
Screenshot
|
|
||||||
</Button>
|
|
||||||
{!isCloud && selectedSession?.agentAccessEnabled ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={revokeControl} disabled={isBusy || !selectedSession}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
Revoke Control
|
|
||||||
</Button>
|
|
||||||
) : !isCloud ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={grantControl}
|
|
||||||
disabled={isBusy || !selectedSession || selectedSession.status !== 'ready' || !status?.enabled}
|
|
||||||
>
|
|
||||||
<Bot className="h-4 w-4" />
|
|
||||||
Grant Control
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl}>
|
|
||||||
<Expand className="h-4 w-4" />
|
|
||||||
Full Screen
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="border-b border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
|
|
||||||
<div className="mx-auto flex min-h-[420px] max-w-6xl flex-col overflow-hidden rounded-lg border border-border bg-background shadow-sm">
|
|
||||||
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
<MonitorCog className="h-3.5 w-3.5" />
|
|
||||||
<span className="truncate">
|
|
||||||
{selectedSession?.displaySize
|
|
||||||
? `${selectedSession.displaySize.width}×${selectedSession.displaySize.height}`
|
|
||||||
: 'No screen captured'}
|
|
||||||
</span>
|
|
||||||
{selectedSession?.agentAccessEnabled && (
|
|
||||||
<span className="ml-auto inline-flex items-center gap-1 rounded border border-emerald-500/30 px-2 py-0.5 text-emerald-600 dark:text-emerald-300">
|
|
||||||
<Bot className="h-3.5 w-3.5" />
|
|
||||||
{isCloud ? 'Desktop-approved session' : 'Agent control active'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{renderSurface()}
|
|
||||||
</div>
|
|
||||||
<p className="mx-auto mt-2 max-w-6xl text-center text-xs text-muted-foreground">
|
|
||||||
{selectedSession
|
|
||||||
? 'Click the screenshot to click the real desktop. Focus the view and type to send keystrokes.'
|
|
||||||
: 'Computer Use sessions appear here after an agent requests desktop access.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
{isFullscreen && selectedSession && (
|
|
||||||
<div className="fixed inset-0 z-50 bg-black/90 p-6">
|
|
||||||
<div className="flex h-full flex-col rounded-lg border border-white/10 bg-black">
|
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80">
|
|
||||||
<div className="min-w-0 truncate">Desktop session</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{renderSurface(true)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -189,7 +189,7 @@ export default function GitPanelHeader({
|
|||||||
<button
|
<button
|
||||||
onClick={requestPublishConfirmation}
|
onClick={requestPublishConfirmation}
|
||||||
disabled={anyPending}
|
disabled={anyPending}
|
||||||
className="flex items-center gap-1 rounded-lg bg-purple-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-purple-700 disabled:opacity-50"
|
className="flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
title={`Publish "${currentBranch}" to ${remoteName}`}
|
title={`Publish "${currentBranch}" to ${remoteName}`}
|
||||||
>
|
>
|
||||||
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export type MainContentHeaderProps = {
|
|||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
shouldShowTasksTab: boolean;
|
shouldShowTasksTab: boolean;
|
||||||
shouldShowBrowserTab: boolean;
|
shouldShowBrowserTab: boolean;
|
||||||
shouldShowComputerTab: boolean;
|
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
|||||||
import GitPanel from '../../git-panel/view/GitPanel';
|
import GitPanel from '../../git-panel/view/GitPanel';
|
||||||
import PluginTabContent from '../../plugins/view/PluginTabContent';
|
import PluginTabContent from '../../plugins/view/PluginTabContent';
|
||||||
import { BrowserUsePanel } from '../../browser-use';
|
import { BrowserUsePanel } from '../../browser-use';
|
||||||
import { ComputerUsePanel } from '../../computer-use';
|
|
||||||
import type { MainContentProps } from '../types/types';
|
import type { MainContentProps } from '../types/types';
|
||||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||||
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||||
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
|
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
|
||||||
import { COMPUTER_USE_MENUS_ENABLED } from '../../../constants/featureFlags';
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
||||||
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
||||||
@@ -56,16 +54,14 @@ function MainContent({
|
|||||||
newSessionTrigger,
|
newSessionTrigger,
|
||||||
}: MainContentProps) {
|
}: MainContentProps) {
|
||||||
const { preferences } = useUiPreferences();
|
const { preferences } = useUiPreferences();
|
||||||
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
|
const { showRawParameters, showThinking, sendByCtrlEnter } = preferences;
|
||||||
|
|
||||||
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
|
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
|
||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
|
||||||
const [browserUseEnabled, setBrowserUseEnabled] = useState(false);
|
const [browserUseEnabled, setBrowserUseEnabled] = useState(false);
|
||||||
const [computerUseEnabled, setComputerUseEnabled] = useState<boolean | undefined>(undefined);
|
|
||||||
|
|
||||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
||||||
const shouldShowBrowserTab = browserUseEnabled;
|
const shouldShowBrowserTab = browserUseEnabled;
|
||||||
const shouldShowComputerTab = COMPUTER_USE_MENUS_ENABLED && computerUseEnabled === true;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
editingFile,
|
editingFile,
|
||||||
@@ -125,60 +121,6 @@ function MainContent({
|
|||||||
}
|
}
|
||||||
}, [shouldShowBrowserTab, activeTab, setActiveTab]);
|
}, [shouldShowBrowserTab, activeTab, setActiveTab]);
|
||||||
|
|
||||||
const loadComputerUseSettings = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [settingsResponse, statusResponse] = await Promise.allSettled([
|
|
||||||
authenticatedFetch('/api/computer-use/settings'),
|
|
||||||
authenticatedFetch('/api/computer-use/status'),
|
|
||||||
]);
|
|
||||||
const settingsRes = settingsResponse.status === 'fulfilled' ? settingsResponse.value : null;
|
|
||||||
const statusRes = statusResponse.status === 'fulfilled' ? statusResponse.value : null;
|
|
||||||
const readJson = async (response: Response | null) => {
|
|
||||||
if (!response) return null;
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const settingsData = await readJson(settingsRes);
|
|
||||||
const statusData = await readJson(statusRes);
|
|
||||||
const runtime = statusData?.data?.runtime;
|
|
||||||
const settingsUsable = Boolean(settingsRes?.ok && settingsData?.success !== false);
|
|
||||||
const statusUsable = Boolean(statusRes?.ok && statusData?.success !== false);
|
|
||||||
const settingsEnabled = Boolean(
|
|
||||||
settingsUsable &&
|
|
||||||
settingsData?.data?.settings?.enabled
|
|
||||||
);
|
|
||||||
const cloudEnabled = Boolean(
|
|
||||||
statusUsable &&
|
|
||||||
runtime === 'cloud' &&
|
|
||||||
statusData?.data?.enabled
|
|
||||||
);
|
|
||||||
if (runtime === 'cloud') {
|
|
||||||
setComputerUseEnabled(cloudEnabled);
|
|
||||||
} else if (settingsUsable) {
|
|
||||||
setComputerUseEnabled(settingsEnabled);
|
|
||||||
} else if (statusUsable) {
|
|
||||||
setComputerUseEnabled(Boolean(statusData?.data?.enabled));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep the current tab availability on transient status/settings failures.
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadComputerUseSettings();
|
|
||||||
window.addEventListener('computerUseSettingsChanged', loadComputerUseSettings);
|
|
||||||
return () => window.removeEventListener('computerUseSettingsChanged', loadComputerUseSettings);
|
|
||||||
}, [loadComputerUseSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldShowComputerTab && activeTab === 'computer') {
|
|
||||||
setActiveTab('chat');
|
|
||||||
}
|
|
||||||
}, [shouldShowComputerTab, activeTab, setActiveTab]);
|
|
||||||
|
|
||||||
usePaletteOpsRegister({
|
usePaletteOpsRegister({
|
||||||
openFile: (filePath: string) => {
|
openFile: (filePath: string) => {
|
||||||
setActiveTab('files');
|
setActiveTab('files');
|
||||||
@@ -207,7 +149,6 @@ function MainContent({
|
|||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
shouldShowTasksTab={shouldShowTasksTab}
|
shouldShowTasksTab={shouldShowTasksTab}
|
||||||
shouldShowBrowserTab={shouldShowBrowserTab}
|
shouldShowBrowserTab={shouldShowBrowserTab}
|
||||||
shouldShowComputerTab={shouldShowComputerTab}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onMenuClick={onMenuClick}
|
onMenuClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
@@ -229,10 +170,8 @@ function MainContent({
|
|||||||
onNavigateToSession={onNavigateToSession}
|
onNavigateToSession={onNavigateToSession}
|
||||||
onSessionEstablished={onSessionEstablished}
|
onSessionEstablished={onSessionEstablished}
|
||||||
onShowSettings={onShowSettings}
|
onShowSettings={onShowSettings}
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
autoScrollToBottom={autoScrollToBottom}
|
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
externalMessageUpdate={externalMessageUpdate}
|
externalMessageUpdate={externalMessageUpdate}
|
||||||
newSessionTrigger={newSessionTrigger}
|
newSessionTrigger={newSessionTrigger}
|
||||||
@@ -272,12 +211,6 @@ function MainContent({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldShowComputerTab && activeTab === 'computer' && (
|
|
||||||
<div className="h-full overflow-hidden">
|
|
||||||
<ComputerUsePanel isVisible={activeTab === 'computer'} onShowSettings={onShowSettings} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab.startsWith('plugin:') && (
|
{activeTab.startsWith('plugin:') && (
|
||||||
<div className="h-full overflow-hidden">
|
<div className="h-full overflow-hidden">
|
||||||
<PluginTabContent
|
<PluginTabContent
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export default function MainContentHeader({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
shouldShowTasksTab,
|
shouldShowTasksTab,
|
||||||
shouldShowBrowserTab,
|
shouldShowBrowserTab,
|
||||||
shouldShowComputerTab,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
onMenuClick,
|
onMenuClick,
|
||||||
}: MainContentHeaderProps) {
|
}: MainContentHeaderProps) {
|
||||||
@@ -62,7 +61,6 @@ export default function MainContentHeader({
|
|||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
shouldShowTasksTab={shouldShowTasksTab}
|
shouldShowTasksTab={shouldShowTasksTab}
|
||||||
shouldShowBrowserTab={shouldShowBrowserTab}
|
shouldShowBrowserTab={shouldShowBrowserTab}
|
||||||
shouldShowComputerTab={shouldShowComputerTab}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canScrollRight && (
|
{canScrollRight && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, MonitorCog, MonitorPlay, type LucideIcon } from 'lucide-react';
|
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, MonitorPlay, type LucideIcon } from 'lucide-react';
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ type MainContentTabSwitcherProps = {
|
|||||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||||
shouldShowTasksTab: boolean;
|
shouldShowTasksTab: boolean;
|
||||||
shouldShowBrowserTab: boolean;
|
shouldShowBrowserTab: boolean;
|
||||||
shouldShowComputerTab: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type BuiltInTab = {
|
type BuiltInTab = {
|
||||||
@@ -46,13 +45,6 @@ const BROWSER_TAB: BuiltInTab = {
|
|||||||
icon: MonitorPlay,
|
icon: MonitorPlay,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPUTER_TAB: BuiltInTab = {
|
|
||||||
kind: 'builtin',
|
|
||||||
id: 'computer',
|
|
||||||
labelKey: 'tabs.computer',
|
|
||||||
icon: MonitorCog,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TASKS_TAB: BuiltInTab = {
|
const TASKS_TAB: BuiltInTab = {
|
||||||
kind: 'builtin',
|
kind: 'builtin',
|
||||||
id: 'tasks',
|
id: 'tasks',
|
||||||
@@ -65,7 +57,6 @@ export default function MainContentTabSwitcher({
|
|||||||
setActiveTab,
|
setActiveTab,
|
||||||
shouldShowTasksTab,
|
shouldShowTasksTab,
|
||||||
shouldShowBrowserTab,
|
shouldShowBrowserTab,
|
||||||
shouldShowComputerTab,
|
|
||||||
}: MainContentTabSwitcherProps) {
|
}: MainContentTabSwitcherProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { plugins } = usePlugins();
|
const { plugins } = usePlugins();
|
||||||
@@ -73,7 +64,6 @@ export default function MainContentTabSwitcher({
|
|||||||
const builtInTabs: BuiltInTab[] = [
|
const builtInTabs: BuiltInTab[] = [
|
||||||
...BASE_TABS,
|
...BASE_TABS,
|
||||||
...(shouldShowBrowserTab ? [BROWSER_TAB] : []),
|
...(shouldShowBrowserTab ? [BROWSER_TAB] : []),
|
||||||
...(shouldShowComputerTab ? [COMPUTER_TAB] : []),
|
|
||||||
...(shouldShowTasksTab ? [TASKS_TAB] : []),
|
...(shouldShowTasksTab ? [TASKS_TAB] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,6 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
|
|||||||
return t('tabs.browser');
|
return t('tabs.browser');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab === 'computer') {
|
|
||||||
return t('tabs.computer');
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Project';
|
return 'Project';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +70,7 @@ export default function MainContentTitle({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{activeTab === 'chat' && selectedSession ? (
|
{activeTab === 'chat' && selectedSession ? (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground">
|
<h2 title={getSessionTitle(selectedSession)} className="truncate text-sm font-semibold leading-tight text-foreground">
|
||||||
{getSessionTitle(selectedSession)}
|
{getSessionTitle(selectedSession)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
|
|||||||
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
|
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
|
||||||
|
|
||||||
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
||||||
claude: 'bg-purple-600 text-white hover:bg-purple-700',
|
claude: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
|
cursor: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
codex: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
|
gemini: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
|
opencode: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Server className="h-5 w-5 text-purple-500" />
|
<Server className="h-5 w-5 text-primary" />
|
||||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FolderOpen, Globe, X } from 'lucide-react';
|
import { FolderOpen, Globe, X } from 'lucide-react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Input } from '../../../../shared/view/ui';
|
import { Button, Input } from '../../../../shared/view/ui';
|
||||||
@@ -119,8 +120,8 @@ export default function McpServerFormModal({
|
|||||||
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
||||||
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
|
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4">
|
||||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
|
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
|
||||||
<div className="flex items-center justify-between border-b border-border p-4">
|
<div className="flex items-center justify-between border-b border-border p-4">
|
||||||
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
|
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
|
||||||
@@ -418,7 +419,7 @@ export default function McpServerFormModal({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !canSubmit}
|
disabled={isSubmitting || !canSubmit}
|
||||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? t('mcpForm.actions.saving')
|
? t('mcpForm.actions.saving')
|
||||||
@@ -429,6 +430,7 @@ export default function McpServerFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,11 +148,18 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="relative h-screen overflow-y-auto bg-background">
|
||||||
<div className="w-full max-w-2xl">
|
<div aria-hidden className="pointer-events-none fixed inset-0">
|
||||||
|
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-32 -left-24 h-[26rem] w-[26rem] rounded-full bg-primary/5 blur-3xl" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(hsl(var(--foreground)/0.04)_1px,transparent_1px)] [background-size:22px_22px] opacity-60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto flex min-h-full w-full max-w-2xl items-center justify-center p-4">
|
||||||
|
<div className="w-full py-6">
|
||||||
<OnboardingStepProgress currentStep={currentStep} />
|
<OnboardingStepProgress currentStep={currentStep} />
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-card p-8 shadow-lg">
|
<div className="rounded-2xl border border-border/70 bg-card/90 p-6 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl">
|
||||||
{currentStep === 0 ? (
|
{currentStep === 0 ? (
|
||||||
<GitConfigurationStep
|
<GitConfigurationStep
|
||||||
gitName={gitName}
|
gitName={gitName}
|
||||||
@@ -169,12 +176,15 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="mt-6 rounded-lg border border-red-300 bg-red-100 p-4 dark:border-red-800 dark:bg-red-900/20">
|
<div
|
||||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
|
role="alert"
|
||||||
|
className="mt-5 rounded-xl border border-destructive/30 bg-destructive/10 p-3.5"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-between border-t border-border pt-6">
|
<div className="mt-6 flex items-center justify-between border-t border-border pt-5">
|
||||||
<button
|
<button
|
||||||
onClick={handlePreviousStep}
|
onClick={handlePreviousStep}
|
||||||
disabled={currentStep === 0 || isSubmitting}
|
disabled={currentStep === 0 || isSubmitting}
|
||||||
@@ -189,7 +199,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleNextStep}
|
onClick={handleNextStep}
|
||||||
disabled={!isCurrentStepValid || isSubmitting}
|
disabled={!isCurrentStepValid || isSubmitting}
|
||||||
className="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
|
className="flex items-center gap-2 rounded-xl bg-primary px-6 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
@@ -207,7 +217,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleFinish}
|
onClick={handleFinish}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-green-700 disabled:cursor-not-allowed disabled:bg-green-400"
|
className="flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-2.5 font-medium text-white shadow-lg shadow-emerald-600/25 transition-all duration-200 hover:bg-emerald-700 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
@@ -227,6 +237,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{activeLoginProvider && (
|
{activeLoginProvider && (
|
||||||
<ProviderLoginModal
|
<ProviderLoginModal
|
||||||
|
|||||||
@@ -31,26 +31,26 @@ export default function AgentConnectionCard({
|
|||||||
: status.error || 'Not connected';
|
: status.error || 'Not connected';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border p-4 transition-colors ${containerClassName}`}>
|
<div className={`rounded-xl border px-3 py-2.5 transition-colors ${containerClassName}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${iconContainerClassName}`}>
|
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full ${iconContainerClassName}`}>
|
||||||
<SessionProviderLogo provider={provider} className="h-5 w-5" />
|
<SessionProviderLogo provider={provider} className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 font-medium text-foreground">
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
{title}
|
{title}
|
||||||
{status.authenticated && <Check className="h-4 w-4 text-green-500" />}
|
{status.authenticated && <Check className="h-3.5 w-3.5 flex-shrink-0 text-emerald-500" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{statusText}</div>
|
<div className="truncate text-xs text-muted-foreground" title={statusText}>{statusText}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!status.authenticated && !status.loading && (
|
{!status.authenticated && !status.loading && (
|
||||||
<button
|
<button
|
||||||
onClick={onLogin}
|
onClick={onLogin}
|
||||||
className={`${loginButtonClassName} rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors`}
|
className={`${loginButtonClassName} flex-shrink-0 rounded-lg px-4 py-1.5 text-sm font-medium text-white transition-colors`}
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,15 +51,15 @@ export default function AgentConnectionsStep({
|
|||||||
onOpenProviderLogin,
|
onOpenProviderLogin,
|
||||||
}: AgentConnectionsStepProps) {
|
}: AgentConnectionsStepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div className="mb-6 text-center">
|
<div className="text-center">
|
||||||
<h2 className="mb-2 text-2xl font-bold text-foreground">Connect Your AI Agents</h2>
|
<h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Connect Your AI Agents</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||||
Login to one or more AI coding assistants. All are optional.
|
Login to one or more AI coding assistants. All are optional.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="-mr-1 max-h-[38vh] space-y-2 overflow-y-auto pr-1">
|
||||||
{providerCards.map((providerCard) => (
|
{providerCards.map((providerCard) => (
|
||||||
<AgentConnectionCard
|
<AgentConnectionCard
|
||||||
key={providerCard.provider}
|
key={providerCard.provider}
|
||||||
@@ -74,9 +74,7 @@ export default function AgentConnectionsStep({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 text-center text-sm text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">You can configure these later in Settings.</p>
|
||||||
<p>You can configure these later in Settings.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ export default function GitConfigurationStep({
|
|||||||
onGitEmailChange,
|
onGitEmailChange,
|
||||||
}: GitConfigurationStepProps) {
|
}: GitConfigurationStepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<div className="mb-8 text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-inset ring-primary/20">
|
||||||
<GitBranch className="h-8 w-8 text-blue-600 dark:text-blue-400" />
|
<GitBranch className="h-7 w-7 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-2 text-2xl font-bold text-foreground">Git Configuration</h2>
|
<h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Git Configuration</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||||
Configure your git identity to ensure proper attribution for commits.
|
Configure your git identity to ensure proper attribution for commits.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +38,7 @@ export default function GitConfigurationStep({
|
|||||||
id="gitName"
|
id="gitName"
|
||||||
value={gitName}
|
value={gitName}
|
||||||
onChange={(event) => onGitNameChange(event.target.value)}
|
onChange={(event) => onGitNameChange(event.target.value)}
|
||||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full rounded-xl border border-border bg-background/60 px-4 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
required
|
required
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
@@ -56,7 +56,7 @@ export default function GitConfigurationStep({
|
|||||||
id="gitEmail"
|
id="gitEmail"
|
||||||
value={gitEmail}
|
value={gitEmail}
|
||||||
onChange={(event) => onGitEmailChange(event.target.value)}
|
onChange={(event) => onGitEmailChange(event.target.value)}
|
||||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full rounded-xl border border-border bg-background/60 px-4 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
placeholder="john@example.com"
|
placeholder="john@example.com"
|
||||||
required
|
required
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const onboardingSteps = [
|
|||||||
|
|
||||||
export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {
|
export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{onboardingSteps.map((step, index) => {
|
{onboardingSteps.map((step, index) => {
|
||||||
const isCompleted = index < currentStep;
|
const isCompleted = index < currentStep;
|
||||||
@@ -22,18 +22,18 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
|
|||||||
<div key={step.title} className="contents">
|
<div key={step.title} className="contents">
|
||||||
<div className="flex flex-1 flex-col items-center">
|
<div className="flex flex-1 flex-col items-center">
|
||||||
<div
|
<div
|
||||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-colors duration-200 ${
|
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||||
isCompleted
|
isCompleted
|
||||||
? 'border-green-500 bg-green-500 text-white'
|
? 'border-emerald-500 bg-emerald-500 text-white shadow-lg shadow-emerald-500/25'
|
||||||
: isActive
|
: isActive
|
||||||
? 'border-blue-600 bg-blue-600 text-white'
|
? 'border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/25'
|
||||||
: 'border-border bg-background text-muted-foreground'
|
: 'border-border bg-card text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isCompleted ? <Check className="h-6 w-6" /> : <Icon className="h-6 w-6" />}
|
{isCompleted ? <Check className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 text-center">
|
<div className="mt-1.5 text-center">
|
||||||
<p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>
|
<p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||||
{step.title}
|
{step.title}
|
||||||
</p>
|
</p>
|
||||||
@@ -42,7 +42,7 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{index < onboardingSteps.length - 1 && (
|
{index < onboardingSteps.length - 1 && (
|
||||||
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-green-500' : 'bg-border'}`} />
|
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-emerald-500' : 'bg-border'}`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
ArrowDown,
|
|
||||||
Brain,
|
Brain,
|
||||||
Eye,
|
Eye,
|
||||||
Languages,
|
Languages,
|
||||||
Maximize2,
|
|
||||||
Mic,
|
Mic,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import type { PreferenceToggleItem } from './types';
|
import type { PreferenceToggleItem } from './types';
|
||||||
|
|
||||||
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
|
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
|
||||||
@@ -16,7 +15,7 @@ export const HANDLE_POSITION_MAX = 90;
|
|||||||
export const DRAG_THRESHOLD_PX = 5;
|
export const DRAG_THRESHOLD_PX = 5;
|
||||||
|
|
||||||
export const SETTING_ROW_CLASS =
|
export const SETTING_ROW_CLASS =
|
||||||
'flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600';
|
'flex items-center justify-between p-3 rounded-lg bg-muted/60 hover:bg-accent transition-colors border border-transparent hover:border-border';
|
||||||
|
|
||||||
export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;
|
export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;
|
||||||
|
|
||||||
@@ -24,11 +23,6 @@ export const CHECKBOX_CLASS =
|
|||||||
'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600';
|
'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600';
|
||||||
|
|
||||||
export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
|
export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
|
||||||
{
|
|
||||||
key: 'autoExpandTools',
|
|
||||||
labelKey: 'quickSettings.autoExpandTools',
|
|
||||||
icon: Maximize2,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'showRawParameters',
|
key: 'showRawParameters',
|
||||||
labelKey: 'quickSettings.showRawParameters',
|
labelKey: 'quickSettings.showRawParameters',
|
||||||
@@ -41,14 +35,6 @@ export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const VIEW_OPTION_TOGGLES: PreferenceToggleItem[] = [
|
|
||||||
{
|
|
||||||
key: 'autoScrollToBottom',
|
|
||||||
labelKey: 'quickSettings.autoScrollToBottom',
|
|
||||||
icon: ArrowDown,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
|
export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
|
||||||
{
|
{
|
||||||
key: 'sendByCtrlEnter',
|
key: 'sendByCtrlEnter',
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import type { CSSProperties } from 'react';
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
export type PreferenceToggleKey =
|
export type PreferenceToggleKey =
|
||||||
| 'autoExpandTools'
|
|
||||||
| 'showRawParameters'
|
| 'showRawParameters'
|
||||||
| 'showThinking'
|
| 'showThinking'
|
||||||
| 'autoScrollToBottom'
|
|
||||||
| 'sendByCtrlEnter'
|
| 'sendByCtrlEnter'
|
||||||
| 'voiceEnabled';
|
| 'voiceEnabled';
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Moon, Sun } from 'lucide-react';
|
import { Moon, Sun } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { DarkModeToggle } from '../../../shared/view/ui';
|
import { DarkModeToggle } from '../../../shared/view/ui';
|
||||||
import LanguageSelector from '../../../shared/view/ui/LanguageSelector';
|
import LanguageSelector from '../../../shared/view/ui/LanguageSelector';
|
||||||
import {
|
import {
|
||||||
INPUT_SETTING_TOGGLES,
|
INPUT_SETTING_TOGGLES,
|
||||||
SETTING_ROW_CLASS,
|
SETTING_ROW_CLASS,
|
||||||
TOOL_DISPLAY_TOGGLES,
|
TOOL_DISPLAY_TOGGLES,
|
||||||
VIEW_OPTION_TOGGLES,
|
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import type {
|
import type {
|
||||||
PreferenceToggleItem,
|
PreferenceToggleItem,
|
||||||
PreferenceToggleKey,
|
PreferenceToggleKey,
|
||||||
QuickSettingsPreferences,
|
QuickSettingsPreferences,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import QuickSettingsSection from './QuickSettingsSection';
|
import QuickSettingsSection from './QuickSettingsSection';
|
||||||
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
|
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
|
||||||
|
|
||||||
@@ -48,11 +49,11 @@ export default function QuickSettingsContent({
|
|||||||
<div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
|
<div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
|
||||||
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
|
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
|
||||||
<div className={SETTING_ROW_CLASS}>
|
<div className={SETTING_ROW_CLASS}>
|
||||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
<span className="flex items-center gap-2 text-sm text-foreground">
|
||||||
{isDarkMode ? (
|
{isDarkMode ? (
|
||||||
<Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Moon className="h-4 w-4 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
{t('quickSettings.darkMode')}
|
{t('quickSettings.darkMode')}
|
||||||
</span>
|
</span>
|
||||||
@@ -65,13 +66,9 @@ export default function QuickSettingsContent({
|
|||||||
{renderToggleRows(TOOL_DISPLAY_TOGGLES)}
|
{renderToggleRows(TOOL_DISPLAY_TOGGLES)}
|
||||||
</QuickSettingsSection>
|
</QuickSettingsSection>
|
||||||
|
|
||||||
<QuickSettingsSection title={t('quickSettings.sections.viewOptions')}>
|
|
||||||
{renderToggleRows(VIEW_OPTION_TOGGLES)}
|
|
||||||
</QuickSettingsSection>
|
|
||||||
|
|
||||||
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
|
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
|
||||||
{renderToggleRows(inputSettingToggles)}
|
{renderToggleRows(inputSettingToggles)}
|
||||||
<p className="ml-3 text-xs text-gray-500 dark:text-gray-400">
|
<p className="ml-3 text-xs text-muted-foreground">
|
||||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||||
</p>
|
</p>
|
||||||
</QuickSettingsSection>
|
</QuickSettingsSection>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ export default function QuickSettingsPanelHeader() {
|
|||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900">
|
<div className="border-b border-border bg-muted/40 p-4">
|
||||||
<h3 className="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||||
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
<Settings2 className="h-5 w-5 text-muted-foreground" />
|
||||||
{t('quickSettings.title')}
|
{t('quickSettings.title')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||||
|
|
||||||
import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
|
import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
|
||||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { useQuickSettingsDrag } from '../hooks/useQuickSettingsDrag';
|
import { useQuickSettingsDrag } from '../hooks/useQuickSettingsDrag';
|
||||||
import type { PreferenceToggleKey, QuickSettingsPreferences } from '../types';
|
import type { PreferenceToggleKey, QuickSettingsPreferences } from '../types';
|
||||||
|
|
||||||
import QuickSettingsContent from './QuickSettingsContent';
|
import QuickSettingsContent from './QuickSettingsContent';
|
||||||
import QuickSettingsHandle from './QuickSettingsHandle';
|
import QuickSettingsHandle from './QuickSettingsHandle';
|
||||||
import QuickSettingsPanelHeader from './QuickSettingsPanelHeader';
|
import QuickSettingsPanelHeader from './QuickSettingsPanelHeader';
|
||||||
@@ -22,15 +24,11 @@ export default function QuickSettingsPanelView() {
|
|||||||
} = useQuickSettingsDrag({ isMobile });
|
} = useQuickSettingsDrag({ isMobile });
|
||||||
|
|
||||||
const quickSettingsPreferences = useMemo<QuickSettingsPreferences>(() => ({
|
const quickSettingsPreferences = useMemo<QuickSettingsPreferences>(() => ({
|
||||||
autoExpandTools: preferences.autoExpandTools,
|
|
||||||
showRawParameters: preferences.showRawParameters,
|
showRawParameters: preferences.showRawParameters,
|
||||||
showThinking: preferences.showThinking,
|
showThinking: preferences.showThinking,
|
||||||
autoScrollToBottom: preferences.autoScrollToBottom,
|
|
||||||
sendByCtrlEnter: preferences.sendByCtrlEnter,
|
sendByCtrlEnter: preferences.sendByCtrlEnter,
|
||||||
voiceEnabled: preferences.voiceEnabled,
|
voiceEnabled: preferences.voiceEnabled,
|
||||||
}), [
|
}), [
|
||||||
preferences.autoExpandTools,
|
|
||||||
preferences.autoScrollToBottom,
|
|
||||||
preferences.sendByCtrlEnter,
|
preferences.sendByCtrlEnter,
|
||||||
preferences.showRawParameters,
|
preferences.showRawParameters,
|
||||||
preferences.showThinking,
|
preferences.showThinking,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function QuickSettingsSection({
|
|||||||
}: QuickSettingsSectionProps) {
|
}: QuickSettingsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${className}`}>
|
<div className={`space-y-2 ${className}`}>
|
||||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{title}
|
{title}
|
||||||
</h4>
|
</h4>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ function QuickSettingsToggleRow({
|
|||||||
}: QuickSettingsToggleRowProps) {
|
}: QuickSettingsToggleRowProps) {
|
||||||
return (
|
return (
|
||||||
<label className={TOGGLE_ROW_CLASS}>
|
<label className={TOGGLE_ROW_CLASS}>
|
||||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
<span className="flex items-center gap-2 text-sm text-foreground">
|
||||||
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp
|
|||||||
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
||||||
export const DEFAULT_SAVE_STATUS = null;
|
export const DEFAULT_SAVE_STATUS = null;
|
||||||
export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
|
export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
|
||||||
theme: 'dark',
|
|
||||||
wordWrap: false,
|
wordWrap: false,
|
||||||
showMinimap: true,
|
showMinimap: true,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { COMPUTER_USE_MENUS_ENABLED } from '../../../constants/featureFlags';
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
|
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
|
||||||
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
||||||
@@ -55,11 +54,11 @@ type NotificationPreferencesResponse = {
|
|||||||
|
|
||||||
type ActiveLoginProvider = AgentProvider | '';
|
type ActiveLoginProvider = AgentProvider | '';
|
||||||
|
|
||||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'computer', 'notifications', 'plugins', 'about'];
|
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'notifications', 'plugins', 'about'];
|
||||||
|
|
||||||
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||||
// Keep backwards compatibility with older callers that still pass "tools".
|
// Keep backwards compatibility with older callers that still pass "tools".
|
||||||
if (tab === 'tools' || (tab === 'computer' && !COMPUTER_USE_MENUS_ENABLED)) {
|
if (tab === 'tools') {
|
||||||
return 'agents';
|
return 'agents';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +86,6 @@ const toCodexPermissionMode = (value: unknown): CodexPermissionMode => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const readCodeEditorSettings = (): CodeEditorSettingsState => ({
|
const readCodeEditorSettings = (): CodeEditorSettingsState => ({
|
||||||
theme: localStorage.getItem('codeEditorTheme') === 'light' ? 'light' : 'dark',
|
|
||||||
wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',
|
wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',
|
||||||
showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',
|
showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',
|
||||||
lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',
|
lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',
|
||||||
@@ -331,7 +329,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
}, [notificationPreferences.channels.sound]);
|
}, [notificationPreferences.channels.sound]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
|
||||||
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
||||||
localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));
|
localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));
|
||||||
localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));
|
localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react';
|
|||||||
import type { LLMProvider } from '../../../types/app';
|
import type { LLMProvider } from '../../../types/app';
|
||||||
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
||||||
|
|
||||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'computer' | 'notifications' | 'plugins' | 'about';
|
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
|
||||||
export type AgentProvider = LLMProvider;
|
export type AgentProvider = LLMProvider;
|
||||||
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
|
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
|
||||||
export type ProjectSortOrder = 'name' | 'date';
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
@@ -47,7 +47,6 @@ export type CursorPermissionsState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CodeEditorSettingsState = {
|
export type CodeEditorSettingsState = {
|
||||||
theme: 'dark' | 'light';
|
|
||||||
wordWrap: boolean;
|
wordWrap: boolean;
|
||||||
showMinimap: boolean;
|
showMinimap: boolean;
|
||||||
lineNumbers: boolean;
|
lineNumbers: boolean;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSetting
|
|||||||
import VoiceSettingsTab from '../view/tabs/VoiceSettingsTab';
|
import VoiceSettingsTab from '../view/tabs/VoiceSettingsTab';
|
||||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||||
import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab';
|
import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab';
|
||||||
import ComputerUseSettingsTab from '../view/tabs/computer-use-settings/ComputerUseSettingsTab';
|
|
||||||
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
||||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||||
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
||||||
@@ -169,7 +168,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
projectSortOrder={projectSortOrder}
|
projectSortOrder={projectSortOrder}
|
||||||
onProjectSortOrderChange={setProjectSortOrder}
|
onProjectSortOrderChange={setProjectSortOrder}
|
||||||
codeEditorSettings={codeEditorSettings}
|
codeEditorSettings={codeEditorSettings}
|
||||||
onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}
|
|
||||||
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
|
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
|
||||||
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
|
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
|
||||||
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
|
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
|
||||||
@@ -199,8 +197,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
|
|
||||||
{activeTab === 'browser' && <BrowserUseSettingsTab />}
|
{activeTab === 'browser' && <BrowserUseSettingsTab />}
|
||||||
|
|
||||||
{activeTab === 'computer' && <ComputerUseSettingsTab />}
|
|
||||||
|
|
||||||
{activeTab === 'notifications' && (
|
{activeTab === 'notifications' && (
|
||||||
<NotificationsSettingsTab
|
<NotificationsSettingsTab
|
||||||
notificationPreferences={notificationPreferences}
|
notificationPreferences={notificationPreferences}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user