=> {
+ if (
+ !confirm(
+ "This will remove the workspace locally and attempt to soft-delete it on the server.\nConnected agents will be disconnected.\n\nAre you sure?",
+ )
+ )
+ return
+ try {
+ showToast("Removing workspace...", "info")
+ await window.api.removeWorkspace(slug)
+ showToast("Workspace removed", "success")
+ loadWorkspaces()
+ } catch (err: unknown) {
+ showToast(`Error: ${(err as Error).message}`, "error")
+ }
+ }
+
+ const runtimeRows: Array<{
+ label: string
+ value: string
+ ok: boolean | null
+ loading: boolean
+ }> = [
+ {
+ label: "Node.js:",
+ value: runtimeInfo?.nodeVersion || "Not installed",
+ ok: runtimeInfo ? !!runtimeInfo.nodeVersion : null,
+ loading: !runtimeInfo,
+ },
+ {
+ label: "npm:",
+ value: runtimeInfo?.npmVersion
+ ? `v${runtimeInfo.npmVersion}`
+ : "Not installed",
+ ok: runtimeInfo ? !!runtimeInfo.npmVersion : null,
+ loading: !runtimeInfo,
+ },
+ {
+ label: "Core Library:",
+ value: runtimeInfo?.coreVersion
+ ? `v${runtimeInfo.coreVersion}`
+ : "Not installed",
+ ok: runtimeInfo ? !!runtimeInfo.coreVersion : null,
+ loading: !runtimeInfo,
+ },
+ {
+ label: "Latest Available:",
+ value: runtimeInfo?.latestVersion
+ ? `v${runtimeInfo.latestVersion}${
+ runtimeInfo.coreVersion === runtimeInfo.latestVersion
+ ? " (up to date)"
+ : " (update available)"
+ }`
+ : "Unable to check",
+ ok:
+ runtimeInfo && runtimeInfo.latestVersion
+ ? runtimeInfo.coreVersion === runtimeInfo.latestVersion
+ : null,
+ loading:
+ !runtimeInfo || (!!runtimeInfo.npmVersion && !runtimeInfo.latestVersion),
+ },
+ ]
+
+ const runtimeColor = (ok: boolean | null): string | undefined => {
+ if (ok === null) return undefined
+ if (ok === true) return "var(--success-text)"
+ return "var(--danger-text)"
+ }
+
+ return (
+
+ Settings
+
+ {/* General — preserve launcher (modern) design */}
+
+
General
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Workspaces */}
+
+
Workspaces
+ {workspaces.length === 0 ? (
+
No workspaces configured.
+ ) : (
+
+ {workspaces.map((ws) => {
+ const slug = ws.slug || ws.id
+ const name = ws.name || slug
+ const url = `https://workspace.openagents.org/${slug}`
+ const fullUrl = ws.token
+ ? `${url}?token=${encodeURIComponent(ws.token)}`
+ : url
+ return (
+ -
+ {name}
+ window.api.openExternal(fullUrl)}
+ >
+ {url}
+
+
+
+ )
+ })}
+
+ )}
+
+
+ {/* Runtime */}
+
+
Runtime
+ {runtimeRows.map((row, idx) => (
+
+ {row.label}
+ {row.loading ? (
+
+ ) : (
+ {row.value}
+ )}
+
+ ))}
+
+
+ {/* About */}
+
+
+ )
+}
diff --git a/packages/launcher/src/renderer/icons/aider.svg b/packages/launcher/src/renderer/public/icons/aider.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/aider.svg
rename to packages/launcher/src/renderer/public/icons/aider.svg
diff --git a/packages/launcher/src/renderer/icons/amp.svg b/packages/launcher/src/renderer/public/icons/amp.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/amp.svg
rename to packages/launcher/src/renderer/public/icons/amp.svg
diff --git a/packages/launcher/src/renderer/icons/claude.svg b/packages/launcher/src/renderer/public/icons/claude.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/claude.svg
rename to packages/launcher/src/renderer/public/icons/claude.svg
diff --git a/packages/launcher/src/renderer/icons/cline.svg b/packages/launcher/src/renderer/public/icons/cline.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/cline.svg
rename to packages/launcher/src/renderer/public/icons/cline.svg
diff --git a/packages/launcher/src/renderer/icons/codex.svg b/packages/launcher/src/renderer/public/icons/codex.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/codex.svg
rename to packages/launcher/src/renderer/public/icons/codex.svg
diff --git a/packages/launcher/src/renderer/icons/copilot.svg b/packages/launcher/src/renderer/public/icons/copilot.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/copilot.svg
rename to packages/launcher/src/renderer/public/icons/copilot.svg
diff --git a/packages/launcher/src/renderer/icons/cursor.svg b/packages/launcher/src/renderer/public/icons/cursor.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/cursor.svg
rename to packages/launcher/src/renderer/public/icons/cursor.svg
diff --git a/packages/launcher/src/renderer/icons/default.svg b/packages/launcher/src/renderer/public/icons/default.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/default.svg
rename to packages/launcher/src/renderer/public/icons/default.svg
diff --git a/packages/launcher/src/renderer/icons/gemini.svg b/packages/launcher/src/renderer/public/icons/gemini.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/gemini.svg
rename to packages/launcher/src/renderer/public/icons/gemini.svg
diff --git a/packages/launcher/src/renderer/icons/goose.svg b/packages/launcher/src/renderer/public/icons/goose.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/goose.svg
rename to packages/launcher/src/renderer/public/icons/goose.svg
diff --git a/packages/launcher/src/renderer/icons/kimi.svg b/packages/launcher/src/renderer/public/icons/kimi.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/kimi.svg
rename to packages/launcher/src/renderer/public/icons/kimi.svg
diff --git a/packages/launcher/src/renderer/icons/nanoclaw.svg b/packages/launcher/src/renderer/public/icons/nanoclaw.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/nanoclaw.svg
rename to packages/launcher/src/renderer/public/icons/nanoclaw.svg
diff --git a/packages/launcher/src/renderer/icons/openai.svg b/packages/launcher/src/renderer/public/icons/openai.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/openai.svg
rename to packages/launcher/src/renderer/public/icons/openai.svg
diff --git a/packages/launcher/src/renderer/icons/openclaw.svg b/packages/launcher/src/renderer/public/icons/openclaw.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/openclaw.svg
rename to packages/launcher/src/renderer/public/icons/openclaw.svg
diff --git a/packages/launcher/src/renderer/icons/opencode.svg b/packages/launcher/src/renderer/public/icons/opencode.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/opencode.svg
rename to packages/launcher/src/renderer/public/icons/opencode.svg
diff --git a/packages/launcher/src/renderer/icons/swebench.svg b/packages/launcher/src/renderer/public/icons/swebench.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/swebench.svg
rename to packages/launcher/src/renderer/public/icons/swebench.svg
diff --git a/packages/launcher/src/renderer/icons/yaml-agent.svg b/packages/launcher/src/renderer/public/icons/yaml-agent.svg
similarity index 100%
rename from packages/launcher/src/renderer/icons/yaml-agent.svg
rename to packages/launcher/src/renderer/public/icons/yaml-agent.svg
diff --git a/packages/launcher/src/renderer/renderer.js b/packages/launcher/src/renderer/renderer.js
deleted file mode 100644
index 699c83675..000000000
--- a/packages/launcher/src/renderer/renderer.js
+++ /dev/null
@@ -1,1781 +0,0 @@
-// ---- Tab navigation ----
-
-function switchTab(tabName) {
- document.querySelectorAll('.nav-item').forEach((el) => {
- el.classList.toggle('active', el.dataset.tab === tabName);
- });
- document.querySelectorAll('.tab-content').forEach((el) => {
- el.classList.toggle('active', el.id === `tab-${tabName}`);
- });
-}
-
-document.querySelectorAll('.nav-item').forEach((el) => {
- el.addEventListener('click', () => switchTab(el.dataset.tab));
-});
-
-// Auto-refresh active tab every 5 seconds
-let _currentTab = 'dashboard';
-const _origSwitchTab = switchTab;
-let _refreshDashboardInFlight = null;
-let _refreshDashboardQueued = false;
-let _refreshAgentListInFlight = null;
-let _refreshAgentListQueued = false;
-const _pendingAgentActions = new Set();
-let _tabLoadToken = 0;
-const TAB_LOAD_DELAY_MS = 120;
-let _logsOffset = 0;
-let _logsFilter = '';
-let _clearLogsInFlight = false;
-const LOGS_INITIAL_LINES = 200;
-const LOGS_MAX_BUFFER_LINES = 400;
-
-switchTab = function(tabName) {
- _currentTab = tabName;
- _origSwitchTab(tabName);
- showTabSkeleton(tabName);
- const token = ++_tabLoadToken;
- requestAnimationFrame(() => {
- setTimeout(() => loadTabContent(tabName, token), TAB_LOAD_DELAY_MS);
- });
-};
-setInterval(() => {
- if (_currentTab === 'dashboard') scheduleRefreshDashboard();
- else if (_currentTab === 'agents') scheduleRefreshAgentList();
-}, 5000);
-
-// Keyboard shortcuts: Ctrl+1..5 for tabs
-const tabShortcuts = ['dashboard', 'agents', 'install', 'logs', 'settings'];
-document.addEventListener('keydown', (e) => {
- if (e.ctrlKey && e.key >= '1' && e.key <= '5') {
- e.preventDefault();
- switchTab(tabShortcuts[parseInt(e.key) - 1]);
- }
-});
-
-// ---- Toast notifications ----
-
-function showToast(message, type = 'info') {
- let container = document.getElementById('toast-container');
- if (!container) {
- container = document.createElement('div');
- container.id = 'toast-container';
- container.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;';
- document.body.appendChild(container);
- }
- const toast = document.createElement('div');
- const colors = { info: 'var(--accent)', success: 'var(--success)', error: 'var(--danger)', warning: 'var(--warning)' };
- toast.style.cssText = `background:var(--bg-card);border:1px solid ${colors[type] || colors.info};border-radius:var(--radius);padding:12px 18px;font-size:13px;color:var(--text-primary);box-shadow:0 4px 12px rgba(0,0,0,0.3);max-width:350px;animation:fadeIn 0.2s;`;
- toast.textContent = message;
- container.appendChild(toast);
- setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
-}
-
-// ---- Modal system ----
-
-// ---- Agent icon helper ----
-
-// Core library icons directory (resolved once at startup)
-let _coreIconsDir = null;
-(async () => {
- try { _coreIconsDir = await window.api.getIconsDir(); } catch {}
-})();
-
-function showModal(html) {
- document.getElementById('modal-content').innerHTML = html;
- document.getElementById('modal-overlay').style.display = 'flex';
-}
-
-function closeModal() {
- document.getElementById('modal-overlay').style.display = 'none';
- document.getElementById('modal-content').innerHTML = '';
-}
-
-document.getElementById('modal-overlay').addEventListener('click', (e) => {
- if (e.target === e.currentTarget) closeModal();
-});
-
-document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') closeModal();
-});
-
-// ---- Agent Icon Helper ----
-
-const BUNDLED_AGENT_ICON_SLUGS = new Set([
- 'aider',
- 'amp',
- 'claude',
- 'cline',
- 'codex',
- 'copilot',
- 'cursor',
- 'default',
- 'gemini',
- 'goose',
- 'kimi',
- 'nanoclaw',
- 'openai',
- 'openclaw',
- 'opencode',
- 'swebench',
- 'yaml-agent',
-]);
-
-function agentIcon(type, size = 24) {
- const slug = (type || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
- const iconSlug = BUNDLED_AGENT_ICON_SLUGS.has(slug) ? slug : 'default';
- return `
`;
-}
-
-function formatHealthLabel(health) {
- if (!health) return 'Not configured';
- if (!health.ready) return health.message || 'Not configured';
- const parts = ['Ready'];
- if (health.auth_mode === 'api_key') parts.push('API key');
- else if (health.auth_mode === 'cli_login') parts.push('CLI login');
- if (health.execution_mode && health.execution_mode !== 'unavailable') {
- parts.push(health.execution_mode);
- }
- return parts.join(' · ');
-}
-
-// ---- Dashboard ----
-
-function scheduleRefreshDashboard() {
- if (_refreshDashboardInFlight) {
- _refreshDashboardQueued = true;
- return _refreshDashboardInFlight;
- }
- _refreshDashboardInFlight = (async () => {
- try {
- await refreshDashboard();
- } finally {
- _refreshDashboardInFlight = null;
- if (_refreshDashboardQueued) {
- _refreshDashboardQueued = false;
- scheduleRefreshDashboard();
- }
- }
- })();
- return _refreshDashboardInFlight;
-}
-
-async function loadTabContent(tabName, token) {
- if (token !== _tabLoadToken) return;
-
- if (tabName === 'dashboard') {
- await scheduleRefreshDashboard();
- return;
- }
-
- if (tabName === 'agents') {
- await scheduleRefreshAgentList();
- return;
- }
-
- if (tabName === 'install') {
- if (token !== _tabLoadToken) return;
- refreshInstallStatus();
- return;
- }
-
- if (tabName === 'logs') {
- if (token !== _tabLoadToken) return;
- refreshLogs();
- return;
- }
-
- if (tabName === 'settings') {
- if (token !== _tabLoadToken) return;
- refreshSettingsWorkspaces();
- refreshSettingsRuntime();
- }
-}
-
-function showTabSkeleton(tabName) {
- if (tabName === 'dashboard') {
- const el = document.getElementById('agent-cards');
- if (el) {
- el.innerHTML = `
-
- `;
- }
- return;
- }
-
- if (tabName === 'agents') {
- const el = document.getElementById('agent-list');
- if (el) {
- el.innerHTML = `
- `;
- }
- return;
- }
-
- if (tabName === 'install') {
- const el = document.getElementById('catalog-table-container');
- if (el) {
- el.innerHTML = `
- `;
- }
- return;
- }
-
- if (tabName === 'logs') {
- _logsOffset = 0;
- const el = document.getElementById('log-viewer');
- if (el) {
- el.textContent = 'Loading logs...';
- }
- return;
- }
-
- if (tabName === 'settings') {
- const ws = document.getElementById('settings-workspaces');
- if (ws) ws.innerHTML = 'Loading...
';
- const ids = ['settings-nodejs-version', 'settings-npm-version', 'settings-core-version', 'settings-core-latest'];
- ids.forEach((id) => {
- const el = document.getElementById(id);
- if (el) el.textContent = 'Loading...';
- });
- }
-}
-
-async function refreshDashboard() {
- if (_currentTab !== 'dashboard') return;
- let agents = [];
- try {
- agents = await window.api.listAgents() || [];
- if (_currentTab !== 'dashboard') return;
- const cardsEl = document.getElementById('agent-cards');
-
- if (agents.length === 0) {
- cardsEl.innerHTML = `
-
-
No agents configured yet.
-
-
`;
- } else {
- cardsEl.innerHTML = agents.map((a) => {
- const isRunning = a.state === 'online' || a.state === 'running' || a.state === 'idle';
- const health = a.health || {};
- const isConnected = !!a.network;
- const isUnsupported = isUnsupportedAgent(a);
- const wsLabel = a.network
- ? (a.networkName && a.networkName !== a.network ? `${a.network} (${a.networkName})` : a.network)
- : '';
- const configLabel = formatHealthLabel(health);
-
- // Status indicators
- const configStatus = isUnsupported
- ? 'Launcher core update required'
- : (health.ready
- ? `${esc(configLabel)}`
- : `${esc(configLabel)}`);
- const connectStatus = isConnected
- ? `Connected: ${esc(wsLabel)}`
- : 'Not connected';
-
- // Simplified buttons
- let buttons = '';
- if (isRunning) {
- buttons += ``;
- if (isConnected) {
- buttons += ``;
- }
- } else {
- buttons += ``;
- }
-
- return `
-
-
-
-
- ${esc(displayState(a.state))}
-
-
- ${configStatus} ${connectStatus}
-
- ${a.lastError ? `
${esc(a.lastError)}
` : ''}
-
${buttons}
-
`;
- }).join('');
- }
- } catch (err) {
- console.error('Dashboard refresh error:', err);
- }
-
- // Update daemon status bar using the same agents data (no extra IPC call)
- updateDaemonStatusFromAgents(agents);
-
- try {
- const status = await window.api.pythonStatus();
- const banner = document.getElementById('setup-banner');
- const versionEl = document.getElementById('sdk-version');
- // Node.js native — always ready
- banner.style.display = 'none';
- const launcherEl = document.getElementById('launcher-version');
- if (launcherEl && status.launcherVersion) launcherEl.textContent = `Launcher v${status.launcherVersion}`;
- versionEl.textContent = `Core v${status.sdkVersion}`;
- } catch {}
-}
-
-function scheduleRefreshAgentList() {
- if (_refreshAgentListInFlight) {
- _refreshAgentListQueued = true;
- return _refreshAgentListInFlight;
- }
- _refreshAgentListInFlight = (async () => {
- try {
- await refreshAgentList();
- } finally {
- _refreshAgentListInFlight = null;
- if (_refreshAgentListQueued) {
- _refreshAgentListQueued = false;
- scheduleRefreshAgentList();
- }
- }
- })();
- return _refreshAgentListInFlight;
-}
-
-function updateDaemonStatusFromAgents(agents) {
- const el = document.getElementById('daemon-status');
- const hasOnline = agents.some((a) => a.state === 'online' || a.state === 'running' || a.state === 'idle' || a.state === 'idle');
- const hasStarting = agents.some((a) => a.state === 'starting' || a.state === 'reconnecting');
-
- if (hasOnline) {
- el.innerHTML = 'Daemon: running';
- } else if (hasStarting) {
- el.innerHTML = 'Daemon: starting';
- } else if (agents.length > 0) {
- el.innerHTML = 'Daemon: stopped';
- } else {
- el.innerHTML = 'Daemon: offline';
- }
-}
-
-function renderAgentActionLabel(name, isRunning) {
- if (_pendingAgentActions.has(name)) {
- return isRunning ? 'Stopping...' : 'Starting...';
- }
- return isRunning ? 'Stop' : 'Start';
-}
-
-async function updateDaemonStatus() {
- try {
- const agents = await window.api.listAgents() || [];
- updateDaemonStatusFromAgents(agents);
- } catch {
- document.getElementById('daemon-status').innerHTML =
- 'Daemon: offline';
- }
-}
-
-async function toggleAgent(name, currentState) {
- if (_pendingAgentActions.has(name)) return;
- _pendingAgentActions.add(name);
- scheduleRefreshDashboard();
- scheduleRefreshAgentList();
-
- try {
- if (currentState === 'online' || currentState === 'running' || currentState === 'idle') {
- await window.api.stopAgent(name);
- showToast(`Stopping ${name}...`, 'info');
- // Poll until stopped (up to 15s — daemon checks commands every 5s)
- for (let i = 0; i < 5; i++) {
- await new Promise(r => setTimeout(r, 3000));
- const status = await window.api.agentStatus();
- const agent = status[name];
- if (!agent || agent.state === 'stopped') {
- showToast(`${name} stopped`, 'success');
- break;
- }
- scheduleRefreshDashboard();
- scheduleRefreshAgentList();
- }
- } else {
- await window.api.startAgent(name);
- showToast(`Starting ${name}...`, 'info');
- // Poll until running (up to 30s — daemon needs time to connect)
- for (let i = 0; i < 10; i++) {
- await new Promise(r => setTimeout(r, 3000));
- const status = await window.api.agentStatus();
- const agent = status[name];
- if (agent && (agent.state === 'running' || agent.state === 'online')) {
- showToast(`${name} is now running`, 'success');
- break;
- }
- scheduleRefreshDashboard();
- scheduleRefreshAgentList();
- }
- }
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- } finally {
- _pendingAgentActions.delete(name);
- scheduleRefreshDashboard();
- scheduleRefreshAgentList();
- }
-}
-
-// Daemon lifecycle is automatic — tied to Launcher app.
-// Start All / Stop All buttons removed from UI.
-
-// ---- Agent Actions (context menu) ----
-
-function showAgentActions(name, type, state, network) {
- const isRunning = state === 'online' || state === 'running' || state === 'idle';
- const actions = [];
-
- if (isRunning) {
- actions.push(``);
- } else {
- actions.push(``);
- }
-
- actions.push(``);
- actions.push(``);
-
- if (network) {
- actions.push(``);
- actions.push(``);
- } else {
- actions.push(``);
- }
-
- actions.push(``);
-
- showModal(`
- Agent: ${esc(name)}
-
- ${actions.join('')}
-
-
- `);
-}
-
-async function disconnectAgent(name) {
- try {
- await window.api.disconnectWorkspace(name);
- showToast(`Disconnected ${name} from workspace`, 'success');
- window.api.signalReload();
- scheduleRefreshDashboard();
- scheduleRefreshAgentList();
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- }
-}
-
-async function openWorkspaceInBrowser(name) {
- try {
- const agents = await window.api.listAgents();
- const agent = agents.find((a) => a.name === name);
- if (!agent || !agent.network) {
- showToast('No workspace connected', 'warning');
- return;
- }
- // Look up workspace details (slug + token)
- const workspaces = await window.api.listWorkspaces();
- const ws = workspaces.find((w) => w.slug === agent.network || w.id === agent.network);
- const slug = (ws && ws.slug) || agent.network;
- let url = `https://workspace.openagents.org/${slug}`;
- if (ws && ws.token) url += `?token=${encodeURIComponent(ws.token)}`;
- window.api.openExternal(url);
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- }
-}
-
-// ---- Configure Agent Screen ----
-
-async function openConfigureScreen(agentName, agentType) {
- showModal(`Loading configuration...
`);
-
- try {
- const [fields, typeSaved, instanceSaved] = await Promise.all([
- window.api.getEnvFields(agentType),
- window.api.getAgentEnv(agentType),
- agentName ? window.api.getAgentInstanceEnv(agentName) : Promise.resolve({}),
- ]);
- const saved = { ...(typeSaved || {}), ...(instanceSaved || {}) };
-
- if (!fields || fields.length === 0) {
- // Check if agent type requires login (e.g., Claude Code)
- const catalog = await window.api.getCatalog();
- const entry = catalog.find(c => c.name === agentType);
- const checkReady = entry?.check_ready;
-
- if (checkReady?.login_command) {
- // Agent uses login-based auth (not env vars)
- let loggedIn = false;
- try {
- const health = await window.api.healthCheck(agentType);
- loggedIn = health?.ready || false;
- } catch {}
-
- showModal(`
- Configure ${esc(agentName || agentType)}
- This agent uses login-based authentication.
-
-
${loggedIn ? '✅' : '⚠️'}
-
${loggedIn ? 'Logged in' : 'Not logged in'}
- ${!loggedIn ? `
${esc(checkReady.not_ready_message || 'Login required')}
` : ''}
-
-
-
-
-
- `);
-
- document.getElementById('btn-agent-login').addEventListener('click', async () => {
- const cmd = checkReady.login_command;
- showToast(`Opening terminal for ${cmd}... Complete login in the new window.`, 'info');
- try {
- // Open login command in a visible terminal window
- await window.api.openTerminal(cmd);
- // Give user time to complete login, then refresh
- setTimeout(() => openConfigureScreen(agentName, agentType), 5000);
- } catch (err) {
- showToast(`Failed to open terminal: ${err.message}`, 'error');
- }
- });
- return;
- }
-
- showModal(`
- Configure ${esc(agentName || agentType)}
- No configuration required for this agent type.
-
- `);
- return;
- }
-
- const fieldsHtml = fields.map((f) => {
- const current = saved[f.name] || f.default || '';
- const required = f.required ? ' *' : '';
- const inputType = f.password ? 'password' : 'text';
- return `
-
-
-
-
`;
- }).join('');
-
- showModal(`
- Configure ${esc(agentName || agentType)}
- ${agentName ? 'Settings saved for this agent. Type defaults remain available as fallbacks.' : 'Settings saved to ~/.openagents/env/'}
-
- ${fieldsHtml}
-
-
-
-
-
-
-
- `);
- } catch (err) {
- showModal(`
- Error
- ${esc(err.message)}
-
- `);
- }
-}
-
-async function saveConfig(agentName, agentType) {
- const fields = document.querySelectorAll('.configure-form input');
- const env = {};
- fields.forEach((input) => {
- const name = input.id.replace('cfg-', '');
- const val = input.value.trim();
- if (val) env[name] = val;
- });
-
- try {
- if (agentName) {
- await window.api.saveAgentInstanceEnv(agentName, env);
- } else {
- await window.api.saveAgentEnv(agentType, env);
- }
- showToast('Configuration saved', 'success');
- closeModal();
- scheduleRefreshDashboard();
- scheduleRefreshAgentList();
- } catch (err) {
- showToast(`Error saving: ${err.message}`, 'error');
- }
-}
-
-async function testLLMConfig(agentType) {
- const fields = document.querySelectorAll('.configure-form input');
- const env = {};
- fields.forEach((input) => {
- const name = input.id.replace('cfg-', '');
- const val = input.value.trim();
- if (val) env[name] = val;
- });
-
- const resultEl = document.getElementById('test-result');
- if (!resultEl) return;
-
- resultEl.innerHTML = 'Testing...';
-
- try {
- const result = await window.api.testLLM(env);
- if (result.success) {
- resultEl.innerHTML = `OK — model: ${esc(result.model)}, response: "${esc(result.response)}"`;
- } else {
- resultEl.innerHTML = `${esc(result.error)}`;
- }
- } catch (err) {
- resultEl.innerHTML = `${esc(err.message)}`;
- }
-}
-
-// ---- Connect Workspace Screen ----
-
-async function showConnectWorkspace(agentName) {
- showModal(`Loading workspaces...
`);
-
- try {
- const networks = await window.api.listWorkspaces();
-
- let rows = '';
- if (networks && networks.length > 0) {
- rows = networks.map((n) => {
- const display = n.name || n.slug || n.id;
- const url = n.endpoint && (n.endpoint.includes('localhost') || n.endpoint.includes('127.0.0.1'))
- ? `${n.endpoint}/${n.slug || n.id}`
- : `workspace.openagents.org/${n.slug || n.id}`;
- return ``;
- }).join('');
- }
-
- showModal(`
- Connect '${esc(agentName)}' to Workspace
-
- ${rows}
-
-
-
-
- `);
- } catch (err) {
- showModal(`
- Error
- ${esc(err.message)}
-
- `);
- }
-}
-
-async function doConnectWorkspace(agentName, slug) {
- try {
- showToast(`Connecting ${agentName} to workspace...`, 'info');
- await window.api.connectWorkspace(agentName, slug);
- window.api.signalReload();
- showToast(`Connected to ${slug}`, 'success');
- scheduleRefreshDashboard();
- scheduleRefreshAgentList();
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- }
-}
-
-function showCreateWorkspace(agentName) {
- showModal(`
- Create New Workspace
-
-
-
-
-
-
-
-
- `);
- setTimeout(() => { const el = document.getElementById('new-workspace-name'); if (el) el.focus(); }, 100);
-}
-
-async function doCreateWorkspace(agentName) {
- const name = document.getElementById('new-workspace-name')?.value?.trim();
- if (!name) { showToast('Workspace name is required', 'warning'); return; }
-
- closeModal();
- try {
- showToast(`Creating workspace '${name}'...`, 'info');
- const result = await window.api.createWorkspace(name);
- showToast(`Workspace '${name}' created`, 'success');
-
- // Auto-connect the agent using the returned token
- if (result && result.token && agentName) {
- await window.api.connectWorkspace(agentName, result.token);
- window.api.signalReload();
- showToast(`Connected ${agentName} to ${name}`, 'success');
- }
- scheduleRefreshDashboard();
- scheduleRefreshAgentList();
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- }
-}
-
-function showJoinWithToken(agentName) {
- showModal(`
- Join Workspace with Token
-
-
-
-
-
-
-
-
- `);
- setTimeout(() => { const el = document.getElementById('workspace-token'); if (el) el.focus(); }, 100);
-}
-
-async function doJoinWithToken(agentName) {
- const token = document.getElementById('workspace-token')?.value?.trim();
- if (!token) { showToast('Token is required', 'warning'); return; }
-
- closeModal();
- try {
- showToast('Joining workspace...', 'info');
- await window.api.connectWorkspace(agentName, token);
- window.api.signalReload();
- showToast('Joined workspace', 'success');
- scheduleRefreshDashboard();
- scheduleRefreshAgentList();
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- }
-}
-
-// ---- Agents tab ----
-
-document.getElementById('btn-add-agent').addEventListener('click', () => showNewAgentDialog());
-
-async function showNewAgentDialog() {
- // First check which agent types are installed
- showModal(`Loading installed types...
`);
-
- try {
- const [catalog, supportedTypes] = await Promise.all([
- window.api.getCatalog(),
- window.api.getSupportedAgentTypes(),
- ]);
- const supportedSet = new Set(supportedTypes || []);
- const installed = catalog.filter((c) => c.installed);
- const supportedInstalled = installed.filter((c) => supportedSet.has(c.name));
- const unsupportedInstalled = installed.filter((c) => !supportedSet.has(c.name));
-
- if (supportedInstalled.length === 0) {
- const extraHint = unsupportedInstalled.length > 0
- ? `Installed but not supported in Launcher yet: ${esc(unsupportedInstalled.map((c) => c.label || c.name).join(', '))}
`
- : '';
- showModal(`
- New Agent
- No Launcher-supported agent runtimes installed. Install one first.
- ${extraHint}
-
-
-
-
- `);
- return;
- }
-
- const typeOptions = supportedInstalled.map((c) =>
- ``
- ).join('');
-
- showModal(`
- New Agent
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `);
-
- // Auto-generate name
- const nameInput = document.getElementById('new-agent-name');
- const typeSelect = document.getElementById('new-agent-type');
- const generateName = () => {
- const type = typeSelect.value;
- const suffix = Math.random().toString(36).slice(2, 6);
- nameInput.placeholder = `${type}-${suffix}`;
- };
- typeSelect.addEventListener('change', generateName);
- generateName();
- setTimeout(() => nameInput.focus(), 100);
- } catch (err) {
- showModal(`
- Error
- ${esc(err.message)}
-
- `);
- }
-}
-
-async function doAddAgent() {
- const type = document.getElementById('new-agent-type')?.value;
- let name = document.getElementById('new-agent-name')?.value?.trim();
- const agentPath = document.getElementById('new-agent-path')?.value?.trim();
-
- if (!name) {
- name = document.getElementById('new-agent-name')?.placeholder || `${type}-${Math.random().toString(36).slice(2, 6)}`;
- }
-
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
- showToast('Agent name can only contain letters, numbers, hyphens, and underscores', 'warning');
- return;
- }
-
- closeModal();
-
- try {
- await window.api.addAgent({ name, type, path: agentPath || undefined });
- showToast(`Agent '${name}' created`, 'success');
- // Open configure screen for the new agent
- openConfigureScreen(name, type);
- scheduleRefreshAgentList();
- scheduleRefreshDashboard();
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- }
-}
-
-async function refreshAgentList() {
- if (_currentTab !== 'agents') return;
- try {
- const agents = await window.api.listAgents();
- if (_currentTab !== 'agents') return;
- const listEl = document.getElementById('agent-list');
-
- if (!agents || agents.length === 0) {
- listEl.innerHTML = 'No agents configured. Click "+ New Agent" to get started.
';
- return;
- }
-
- listEl.innerHTML = agents.map((a) => {
- const isRunning = a.state === 'online' || a.state === 'running' || a.state === 'idle';
- const slug = a.network || '';
- const wsDisplay = slug ? (a.networkName && a.networkName !== slug ? `${slug} (${a.networkName})` : slug) : '';
- const health = a.health || {};
- const unsupported = isUnsupportedAgent(a);
- const envDisplay = [];
- if (a.env?.LLM_BASE_URL || a.env?.OPENAI_BASE_URL) envDisplay.push(`API: ${a.env.LLM_BASE_URL || a.env.OPENAI_BASE_URL}`);
- if (a.env?.LLM_MODEL || a.env?.OPENCLAW_MODEL) envDisplay.push(`Model: ${a.env.LLM_MODEL || a.env.OPENCLAW_MODEL}`);
- const readyLabel = formatHealthLabel(health);
-
- return `
-
-
-
-
- ${agentIcon(a.type, 28)}
-
${esc(a.name)}
-
-
${esc(a.type)}
-
- ${unsupported ? 'Launcher core update required' : health.ready ? `🔑 ${esc(readyLabel)}` : `⚠ ${esc(readyLabel)}`}
- ${envDisplay.length ? ' · ' + envDisplay.map(esc).join(' · ') : ''}
-
- ${a.lastError ? `
${esc(a.lastError)}` : ''}
-
-
-
- ${esc(displayState(a.state))}
- ${wsDisplay ? `${esc(wsDisplay)}` : 'Not connected'}
-
-
-
-
-
-
- ${a.network
- ? `
- `
- : ``
- }
-
-
-
-
`;
- }).join('');
- } catch (err) {
- console.error('Agent list error:', err);
- showToast('Failed to load agents', 'error');
- }
-}
-
-async function removeAgent(name) {
- if (!confirm(`Remove agent '${name}'? This will stop it if running.`)) return;
- try {
- await window.api.removeAgent(name);
- showToast(`Agent '${name}' removed`, 'success');
- scheduleRefreshAgentList();
- scheduleRefreshDashboard();
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- }
-}
-
-// ---- Install tab ----
-
-async function refreshInstallStatus() {
-
- // Catalog
- refreshCatalog();
-}
-
-async function refreshCatalog() {
- const container = document.getElementById('catalog-table-container');
-
- try {
- const catalog = await window.api.getCatalog();
- const healthByName = {};
- await Promise.all(catalog.map(async (c) => {
- try {
- healthByName[c.name] = await window.api.healthCheck(c.name);
- } catch {
- healthByName[c.name] = null;
- }
- }));
-
- if (!catalog || catalog.length === 0) {
- container.innerHTML = 'No agent runtimes available. Install the SDK first.
';
- return;
- }
-
- const rows = catalog.map((c) => {
- const health = healthByName[c.name] || {};
- const readiness = formatHealthLabel(health);
- return `
-
-
- ${agentIcon(c.name, 28)}
-
- ${esc(c.label || c.name)}
- ${esc(c.description || '')}
- ${esc(readiness)}
-
- ⬇
- 🌐
- 🤝
-
-
-
-
- ${c.installed
- ? (c.managed === false
- ? 'global'
- : 'installed')
- : 'not installed'}
-
-
-
- ${c.installed && c.managed !== false ? `` : ''}
-
-
- `;
- }).join('');
-
- container.innerHTML = `${rows}
`;
- // Apply any existing search filter
- const searchInput = document.getElementById('catalog-search-input');
- if (searchInput && searchInput.value) filterCatalog(searchInput.value);
- } catch (err) {
- container.innerHTML = `Failed to load catalog: ${esc(err.message)}
`;
- }
-}
-
-function filterCatalog(query) {
- const q = query.toLowerCase();
- document.querySelectorAll('.catalog-row').forEach((row) => {
- const text = row.textContent.toLowerCase();
- row.style.display = text.includes(q) ? '' : 'none';
- });
-}
-
-async function installCatalogItem(name, isInstalled) {
- const verb = isInstalled ? 'Update' : 'Install';
-
- // Confirmation modal
- const confirmed = await new Promise((resolve) => {
- showModal(`
-
- ${agentIcon(name, 40)}
-
${verb} ${esc(name)}?
-
This will run npm install -g ${esc(name)}@latest on your system.
-
-
-
-
-
- `);
- document.getElementById('confirm-install-yes').addEventListener('click', () => { closeModal(); resolve(true); });
- document.getElementById('confirm-install-no').addEventListener('click', () => { closeModal(); resolve(false); });
- });
- if (!confirmed) return;
-
- // Switch to dedicated install view — hide tabs, show progress overlay
- const content = document.getElementById('content');
- document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
- // Remove any previous progress view
- const oldProgress = document.getElementById('install-progress-overlay');
- if (oldProgress) oldProgress.remove();
-
- const progressView = document.createElement('div');
- progressView.id = 'install-progress-overlay';
- progressView.className = 'install-progress-view';
- progressView.innerHTML = `
-
- ${agentIcon(name, 32)}
-
-
${verb} ${esc(name)}
-
Full installation log is shown below.
-
-
-
-
-
-
`;
- content.appendChild(progressView);
-
- const logEl = document.getElementById('install-live-log');
- const doneBar = document.getElementById('install-done-bar');
-
- // D22: Check dependencies
- try {
- const catalog = await window.api.getCatalog();
- const entry = catalog.find(c => c.name === name);
- if (entry && entry.requires) {
- for (const dep of entry.requires) {
- const depName = dep === 'nodejs' ? 'node' : dep;
- logEl.textContent += `Checking dependency: ${dep}... `;
- try {
- const check = await window.api.healthCheck(depName);
- if (check && check.installed) {
- logEl.textContent += `OK (${check.version || 'found'})\n`;
- } else {
- logEl.textContent += `NOT FOUND\n\n⚠ Please install ${dep} first.\n`;
- doneBar.style.display = 'block';
- document.getElementById('install-back-btn').addEventListener('click', () => {
- const overlay = document.getElementById('install-progress-overlay');
- if (overlay) overlay.remove();
- // switchTab will re-add .active to the correct tab
- switchTab('install');
- });
- return;
- }
- } catch {
- logEl.textContent += `OK (assumed)\n`;
- }
- }
- }
- } catch {}
-
- logEl.textContent += `\n`;
-
- // Listen for streaming output
- let lastOutputTime = Date.now();
- window.api.onInstallOutput((data) => {
- // Remove progress spinner before appending real output
- if (progressLine && logEl.textContent.endsWith(progressLine)) {
- logEl.textContent = logEl.textContent.slice(0, -progressLine.length);
- }
- logEl.textContent += data;
- logEl.scrollTop = logEl.scrollHeight;
- lastOutputTime = Date.now();
- });
-
- // Show progress inside the log panel while npm is silent
- const spinChars = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
- let spinIdx = 0;
- let progressLine = '';
- const startTime = Date.now();
- const timerInterval = setInterval(() => {
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
- const silentFor = Math.floor((Date.now() - lastOutputTime) / 1000);
- if (silentFor > 2) {
- // Remove previous progress line if present
- if (progressLine && logEl.textContent.endsWith(progressLine)) {
- logEl.textContent = logEl.textContent.slice(0, -progressLine.length);
- }
- spinIdx = (spinIdx + 1) % spinChars.length;
- progressLine = `${spinChars[spinIdx]} Downloading and installing packages... (${elapsed}s elapsed)`;
- logEl.textContent += progressLine;
- logEl.scrollTop = logEl.scrollHeight;
- }
- }, 200);
-
- try {
- await window.api.installAgentTypeStreaming(name);
- logEl.textContent += `\n✓ ${name} installed successfully.\n`;
- } catch (err) {
- logEl.textContent += `\n✗ Error: ${err.message}\n`;
- }
-
- clearInterval(timerInterval);
- if (progressLine && logEl.textContent.endsWith(progressLine)) {
- logEl.textContent = logEl.textContent.slice(0, -progressLine.length);
- }
-
- window.api.removeInstallOutputListener();
- doneBar.style.display = 'block';
- document.getElementById('install-back-btn').addEventListener('click', () => {
- // Remove progress overlay and restore tabs
- const overlay = document.getElementById('install-progress-overlay');
- if (overlay) overlay.remove();
- // switchTab will re-add .active to the correct tab
- switchTab('install');
- });
-}
-
-async function uninstallCatalogItem(name) {
- // Confirmation modal
- const confirmed = await new Promise((resolve) => {
- showModal(`
-
- ${agentIcon(name, 40)}
-
Uninstall ${esc(name)}?
-
This will remove ${esc(name)} from your system.
-
-
-
-
-
- `);
- document.getElementById('confirm-install-yes').addEventListener('click', () => { closeModal(); resolve(true); });
- document.getElementById('confirm-install-no').addEventListener('click', () => { closeModal(); resolve(false); });
- });
- if (!confirmed) return;
-
- const content = document.getElementById('content');
- document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
- const oldProgress = document.getElementById('install-progress-overlay');
- if (oldProgress) oldProgress.remove();
-
- const progressView = document.createElement('div');
- progressView.id = 'install-progress-overlay';
- progressView.className = 'install-progress-view';
- progressView.innerHTML = `
-
- ${agentIcon(name, 32)}
-
-
Uninstalling ${esc(name)}
-
Full uninstallation log is shown below.
-
-
-
-
-
-
`;
- content.appendChild(progressView);
-
- const logEl = document.getElementById('install-live-log');
- const doneBar = document.getElementById('install-done-bar');
-
- let lastOutputTime = Date.now();
- window.api.onInstallOutput((data) => {
- // Remove progress line before appending real output
- if (progressLine && logEl.textContent.endsWith(progressLine)) {
- logEl.textContent = logEl.textContent.slice(0, -progressLine.length);
- }
- logEl.textContent += data;
- logEl.scrollTop = logEl.scrollHeight;
- lastOutputTime = Date.now();
- });
-
- const spinChars = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
- let spinIdx = 0;
- let progressLine = '';
- const startTime = Date.now();
- const timerInterval = setInterval(() => {
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
- const silentFor = Math.floor((Date.now() - lastOutputTime) / 1000);
- if (silentFor > 2) {
- if (progressLine && logEl.textContent.endsWith(progressLine)) {
- logEl.textContent = logEl.textContent.slice(0, -progressLine.length);
- }
- spinIdx = (spinIdx + 1) % spinChars.length;
- progressLine = `${spinChars[spinIdx]} Removing packages... (${elapsed}s elapsed)`;
- logEl.textContent += progressLine;
- logEl.scrollTop = logEl.scrollHeight;
- }
- }, 200);
-
- try {
- await window.api.uninstallAgentTypeStreaming(name);
- logEl.textContent += `\n✓ ${name} uninstalled successfully.\n`;
- } catch (err) {
- logEl.textContent += `\n✗ Error: ${err.message}\n`;
- }
-
- clearInterval(timerInterval);
- if (progressLine && logEl.textContent.endsWith(progressLine)) {
- logEl.textContent = logEl.textContent.slice(0, -progressLine.length);
- }
- window.api.removeInstallOutputListener();
- doneBar.style.display = 'block';
- document.getElementById('install-back-btn').addEventListener('click', () => {
- // Remove progress overlay and restore tabs
- const overlay = document.getElementById('install-progress-overlay');
- if (overlay) overlay.remove();
- // switchTab will re-add .active to the correct tab
- switchTab('install');
- });
-}
-
-// SDK install button removed — agent-connector is bundled with the app
-
-// ---- Logs tab ----
-
-async function refreshLogs() {
- if (_currentTab !== 'logs') return;
- try {
- const filter = document.getElementById('log-agent-filter').value;
- const viewer = document.getElementById('log-viewer');
- const reset = filter !== _logsFilter || _logsOffset === 0;
-
- const result = reset
- ? await window.api.tailAgentLogs(filter, LOGS_INITIAL_LINES, 0)
- : await window.api.tailAgentLogs(filter, LOGS_INITIAL_LINES, _logsOffset);
- if (_currentTab !== 'logs') return;
-
- _logsFilter = filter;
- _logsOffset = result.size || 0;
-
- if (reset) {
- if (result.lines && result.lines.length > 0) {
- viewer.textContent = result.lines.join('\n');
- } else {
- viewer.textContent = 'No logs available.\n\nLogs appear here after the daemon starts.';
- }
- viewer.scrollTop = viewer.scrollHeight;
- } else {
- if (result.lines && result.lines.length > 0) {
- const existing = viewer.textContent ? viewer.textContent.split('\n').filter(Boolean) : [];
- const merged = existing.concat(result.lines).slice(-LOGS_MAX_BUFFER_LINES);
- viewer.textContent = merged.join('\n');
- viewer.scrollTop = viewer.scrollHeight;
- }
- }
- } catch (err) {
- document.getElementById('log-viewer').textContent = 'Error loading logs: ' + err.message;
- }
-
- // Populate agent filter dropdown
- try {
- const agents = await window.api.listAgents();
- if (_currentTab !== 'logs') return;
- const select = document.getElementById('log-agent-filter');
- const current = select.value;
- const existingOptions = new Set();
- select.querySelectorAll('option').forEach((o) => existingOptions.add(o.value));
-
- (agents || []).forEach((a) => {
- if (!existingOptions.has(a.name)) {
- const opt = document.createElement('option');
- opt.value = a.name;
- opt.textContent = a.name;
- if (a.name === current) opt.selected = true;
- select.appendChild(opt);
- }
- });
- } catch {}
-}
-
-function toDateTimeLocalValue(date) {
- const pad = (value) => String(value).padStart(2, '0');
- return [
- date.getFullYear(),
- '-',
- pad(date.getMonth() + 1),
- '-',
- pad(date.getDate()),
- 'T',
- pad(date.getHours()),
- ':',
- pad(date.getMinutes()),
- ].join('');
-}
-
-function showClearLogsModal() {
- const now = new Date();
- const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
- showModal(`
- Clear Logs
- Delete log entries from daemon.log whose timestamps fall inside the selected time range.
-
-
-
-
-
-
-
-
-
-
-
-
-
- `);
-
- const confirmBtn = document.getElementById('confirm-clear-logs');
- confirmBtn.addEventListener('click', clearLogsInRange);
-}
-
-async function clearLogsInRange() {
- if (_clearLogsInFlight) return;
-
- const startEl = document.getElementById('clear-logs-start');
- const endEl = document.getElementById('clear-logs-end');
- const errorEl = document.getElementById('clear-logs-error');
- const confirmBtn = document.getElementById('confirm-clear-logs');
-
- const start = startEl?.value ? new Date(startEl.value) : null;
- const end = endEl?.value ? new Date(endEl.value) : null;
-
- if (!start || Number.isNaN(start.getTime()) || !end || Number.isNaN(end.getTime())) {
- if (errorEl) errorEl.textContent = 'Please select a valid start and end time.';
- return;
- }
- if (start.getTime() > end.getTime()) {
- if (errorEl) errorEl.textContent = 'Start time must be before end time.';
- return;
- }
-
- _clearLogsInFlight = true;
- if (confirmBtn) {
- confirmBtn.disabled = true;
- confirmBtn.textContent = 'Deleting...';
- }
- if (errorEl) errorEl.textContent = '';
-
- try {
- const result = await window.api.clearLogsInRange(start.toISOString(), end.toISOString());
- closeModal();
- _logsOffset = 0;
- await refreshLogs();
- showToast(`Deleted ${result.removed || 0} log lines from the selected range`, 'success');
- } catch (err) {
- if (errorEl) errorEl.textContent = err.message || 'Failed to clear logs.';
- if (confirmBtn) {
- confirmBtn.disabled = false;
- confirmBtn.textContent = 'Delete';
- }
- } finally {
- _clearLogsInFlight = false;
- }
-}
-
-document.getElementById('btn-refresh-logs').addEventListener('click', () => {
- _logsOffset = 0;
- refreshLogs();
-});
-document.getElementById('btn-clear-logs').addEventListener('click', () => {
- showClearLogsModal();
-});
-document.getElementById('btn-copy-logs').addEventListener('click', () => {
- const logs = document.getElementById('log-viewer').textContent;
- navigator.clipboard.writeText(logs).then(() => {
- showToast('Logs copied to clipboard', 'success');
- }).catch(() => {
- showToast('Failed to copy logs', 'error');
- });
-});
-document.getElementById('log-agent-filter').addEventListener('change', () => {
- _logsOffset = 0;
- refreshLogs();
-});
-document.getElementById('catalog-search-input').addEventListener('input', (e) => filterCatalog(e.target.value));
-
-// ---- Settings tab ----
-
-document.getElementById('link-docs').addEventListener('click', (e) => {
- e.preventDefault();
- window.api.openExternal('https://docs.openagents.com');
-});
-
-(async () => {
- try {
- const startOnBoot = await window.api.getSetting('startOnBoot');
- const minimizeToTray = await window.api.getSetting('minimizeToTray');
- if (startOnBoot !== undefined) document.getElementById('setting-start-on-boot').checked = !!startOnBoot;
- if (minimizeToTray !== undefined) document.getElementById('setting-minimize-to-tray').checked = !!minimizeToTray;
- } catch {}
-})();
-
-document.getElementById('setting-start-on-boot').addEventListener('change', (e) => {
- window.api.setSetting('startOnBoot', e.target.checked);
-});
-document.getElementById('setting-minimize-to-tray').addEventListener('change', (e) => {
- window.api.setSetting('minimizeToTray', e.target.checked);
-});
-
-// ---- Utilities ----
-
-function esc(str) {
- if (str == null) return '';
- const div = document.createElement('div');
- div.textContent = String(str);
- return div.innerHTML;
-}
-
-function displayState(state) {
- if (state === 'idle') return 'running';
- return state || 'stopped';
-}
-
-function statusClass(state) {
- if (state === 'online' || state === 'running' || state === 'idle' || state === 'idle') return 'online';
- if (state === 'starting' || state === 'reconnecting') return 'starting';
- return 'offline';
-}
-
-function isUnsupportedAgent(agent) {
- return !!agent?.runtimeMismatch;
-}
-
-// ---- D25: Activity log ----
-
-const activityEntries = [];
-const MAX_ACTIVITY = 50;
-
-function addActivity(msg) {
- const now = new Date();
- const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- activityEntries.unshift({ time, msg });
- if (activityEntries.length > MAX_ACTIVITY) activityEntries.length = MAX_ACTIVITY;
- renderActivity();
-}
-
-function renderActivity() {
- const el = document.getElementById('activity-log');
- if (!el) return;
- if (activityEntries.length === 0) {
- el.innerHTML = 'No activity yet. Start an agent to see events.';
- return;
- }
- el.innerHTML = activityEntries.map(e =>
- `${esc(e.time)}${esc(e.msg)}
`
- ).join('');
-}
-
-// Override showToast to also add to activity log
-const _origShowToast = showToast;
-showToast = function(message, type) {
- _origShowToast(message, type);
- addActivity(message);
-};
-
-// ---- D28: Auto-refresh logs ----
-
-let logAutoRefreshInterval = null;
-
-function startLogAutoRefresh() {
- stopLogAutoRefresh();
- logAutoRefreshInterval = setInterval(() => {
- const autoEl = document.getElementById('log-auto-refresh');
- const activeTab = document.querySelector('.nav-item.active');
- if (autoEl && autoEl.checked && activeTab && activeTab.dataset.tab === 'logs') {
- refreshLogs();
- }
- }, 3000);
-}
-
-function stopLogAutoRefresh() {
- if (logAutoRefreshInterval) { clearInterval(logAutoRefreshInterval); logAutoRefreshInterval = null; }
-}
-
-startLogAutoRefresh();
-
-// ---- D29: Workspace URL display in Settings ----
-
-async function refreshSettingsWorkspaces() {
- if (_currentTab !== 'settings') return;
- const el = document.getElementById('settings-workspaces');
- if (!el) return;
- try {
- const workspaces = await window.api.listWorkspaces();
- if (_currentTab !== 'settings') return;
- if (!workspaces || workspaces.length === 0) {
- el.innerHTML = 'No workspaces configured.';
- return;
- }
- el.innerHTML = `${workspaces.map(w => {
- const slug = w.slug || w.id;
- const name = w.name || slug;
- const url = `https://workspace.openagents.org/${slug}`;
- return `-
- ${esc(name)}
- ${esc(url)}
-
-
`;
- }).join('')}
`;
- } catch {
- el.innerHTML = 'Failed to load workspaces.';
- }
-}
-
-async function refreshSettingsRuntime() {
- if (_currentTab !== 'settings') return;
- try {
- const info = await window.api.runtimeInfo();
- if (_currentTab !== 'settings') return;
- const set = (id, value, color) => {
- const el = document.getElementById(id);
- if (el) { el.textContent = value; el.style.color = color; }
- };
- set('settings-nodejs-version', info.nodeVersion || 'Not installed', info.nodeVersion ? 'var(--success)' : 'var(--danger)');
- set('settings-npm-version', info.npmVersion ? `v${info.npmVersion}` : 'Not installed', info.npmVersion ? 'var(--success)' : 'var(--danger)');
- set('settings-core-version', info.coreVersion ? `v${info.coreVersion}` : 'Not installed', info.coreVersion ? 'var(--success)' : 'var(--danger)');
- if (info.latestVersion) {
- const upToDate = info.coreVersion === info.latestVersion;
- set('settings-core-latest', `v${info.latestVersion}${upToDate ? ' (up to date)' : ' (update available)'}`, upToDate ? 'var(--success)' : 'var(--warning)');
- } else {
- set('settings-core-latest', 'Unable to check', 'var(--text-muted)');
- }
- } catch {}
-}
-
-// ---- Update About version ----
-
-(async () => {
- try {
- const status = await window.api.pythonStatus();
- const aboutEl = document.getElementById('about-version');
- if (aboutEl) aboutEl.textContent = `v${status.sdkVersion}`;
- } catch {}
-})();
-
-// ---- Periodic refresh ----
-
-setInterval(() => {
- const activeTab = document.querySelector('.nav-item.active');
- if (activeTab) {
- const tab = activeTab.dataset.tab;
- if (tab === 'dashboard') scheduleRefreshDashboard();
- if (tab === 'settings') { refreshSettingsWorkspaces(); refreshSettingsRuntime(); }
- }
- updateDaemonStatus();
-}, 5000);
-
-// ---- Core library update banner ----
-if (window.api.onCoreUpdate) {
- window.api.onCoreUpdate(({ current, latest }) => {
- const banner = document.getElementById('update-banner');
- if (!banner) return;
- banner.style.display = 'block';
- banner.innerHTML = `
-
-
Update available
-
v${current} → v${latest}
-
-
`;
- document.getElementById('btn-update-core').addEventListener('click', async () => {
- const btn = document.getElementById('btn-update-core');
- btn.textContent = 'Updating...';
- btn.disabled = true;
- try {
- const result = await window.api.updateCore();
- if (result.success) {
- banner.innerHTML = `Updated to v${result.version}
`;
- document.getElementById('sdk-version').textContent = 'Core: v' + result.version;
- setTimeout(() => { banner.style.display = 'none'; }, 5000);
- } else {
- showToast('Update failed: ' + result.error, 'error');
- btn.textContent = 'Retry';
- btn.disabled = false;
- }
- } catch (err) {
- showToast('Update failed: ' + err.message, 'error');
- btn.textContent = 'Retry';
- btn.disabled = false;
- }
- });
- });
-}
-
-// ---- Delegated click handler ----
-// CSP blocks inline onclick; use data-action attributes + delegation instead.
-
-document.addEventListener('click', (e) => {
- const btn = e.target.closest('[data-action]');
- if (!btn) return;
- if (btn.disabled) return;
-
- const action = btn.dataset.action;
- const name = btn.dataset.name || '';
- const type = btn.dataset.type || '';
- const state = btn.dataset.state || '';
- const network = btn.dataset.network || '';
- const slug = btn.dataset.slug || '';
- const tab = btn.dataset.actionTab || '';
-
- // Close modal for actions triggered from inside a modal
- const inModal = !!btn.closest('.modal');
- const autoClose = ['switch-tab', 'toggle-agent', 'configure', 'disconnect',
- 'open-ws', 'remove-agent', 'connect-workspace', 'do-connect-workspace',
- 'show-create-workspace', 'show-join-token'];
- if (inModal && autoClose.includes(action)) closeModal();
-
- switch (action) {
- case 'switch-tab': switchTab(tab); break;
- case 'toggle-agent': toggleAgent(name, state); break;
- case 'show-agent-actions': showAgentActions(name, type, state, network); break;
- case 'configure': openConfigureScreen(name, type); break;
- case 'disconnect': disconnectAgent(name); break;
- case 'open-ws': openWorkspaceInBrowser(name); break;
- case 'remove-agent': removeAgent(name); break;
- case 'connect-workspace': showConnectWorkspace(name); break;
- case 'do-connect-workspace': doConnectWorkspace(name, slug); break;
- case 'show-create-workspace': showCreateWorkspace(name); break;
- case 'show-join-token': showJoinWithToken(name); break;
- case 'do-create-workspace': doCreateWorkspace(name); break;
- case 'do-join-token': doJoinWithToken(name); break;
- case 'remove-workspace': removeWorkspace(slug); break;
- case 'do-add-agent': doAddAgent(); break;
- case 'save-config': saveConfig(name, type); break;
- case 'test-llm': testLLMConfig(type); break;
- case 'close-modal': closeModal(); break;
- case 'install-catalog': installCatalogItem(name, btn.dataset.installed === 'true'); break;
- case 'uninstall-catalog': uninstallCatalogItem(name); break;
- case 'open-external': window.api.openExternal(btn.dataset.url); break;
- // D23: Login flow
- case 'agent-login': agentLogin(type); break;
- // D24: Daemon toggle
- case 'toggle-daemon': toggleDaemon(); break;
- }
-});
-
-// ---- D23: Agent login flow ----
-
-async function agentLogin(agentType) {
- let cmd = null;
- try {
- const catalog = await window.api.getCatalog();
- const entry = catalog.find((c) => c.name === agentType);
- cmd = entry?.check_ready?.login_command || null;
- } catch {}
- if (!cmd) {
- const loginCommands = {
- claude: 'claude login',
- openclaw: 'openclaw login',
- codex: 'codex login',
- copilot: 'github-copilot login',
- };
- cmd = loginCommands[agentType];
- }
- if (!cmd) {
- showToast(`No login command for ${agentType}. Configure API key instead.`, 'info');
- openConfigureScreen('', agentType);
- return;
- }
- showToast(`Opening ${agentType} login... Follow the prompts in the terminal.`, 'info');
- try {
- await window.api.openTerminal(cmd);
- showToast(`Login terminal opened. Complete login there, then return here.`, 'success');
- } catch (err) {
- showToast(`Failed to open terminal: ${err.message}`, 'error');
- }
-}
-
-// ---- D24: Daemon toggle ----
-
-async function toggleDaemon() {
- const el = document.getElementById('daemon-status');
- const isRunning = el && el.textContent.includes('running');
- try {
- if (isRunning) {
- await window.api.stopAll();
- showToast('Daemon stopped', 'info');
- } else {
- await window.api.startAll();
- showToast('Daemon starting...', 'info');
- }
- setTimeout(() => scheduleRefreshDashboard(), 2000);
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- }
-}
-
-// ---- Workspace Deletion ----
-
-async function removeWorkspace(slug) {
- if (!confirm(`This will remove the workspace locally and attempt to soft-delete it on the server.\nConnected agents will be disconnected.\n\nAre you sure you want to proceed?`)) return;
- try {
- showToast(`Removing workspace...`, 'info');
- await window.api.removeWorkspace(slug);
- showToast(`Workspace removed`, 'success');
- refreshSettingsWorkspaces();
- refreshAgentList();
- refreshDashboard();
- } catch (err) {
- showToast(`Error: ${err.message}`, 'error');
- }
-}
-
-// ---- Initial load ----
-
-scheduleRefreshDashboard();
-renderActivity();
-
diff --git a/packages/launcher/src/renderer/store/agents.ts b/packages/launcher/src/renderer/store/agents.ts
new file mode 100644
index 000000000..b9b8585ba
--- /dev/null
+++ b/packages/launcher/src/renderer/store/agents.ts
@@ -0,0 +1,55 @@
+import { create } from 'zustand'
+import { useShallow } from 'zustand/react/shallow'
+import type { Agent } from '../types'
+
+interface AgentsState {
+ // Agent list — replaces legacy module-level agent array
+ agents: Agent[]
+ setAgents: (agents: Agent[]) => void
+
+ // Pending start/stop — replaces legacy _pendingAgentActions Set
+ pendingAgentActions: Set
+ addPendingAction: (name: string) => void
+ removePendingAction: (name: string) => void
+
+ // Version info (fetched alongside agent list)
+ coreVersion: string | null
+ setCoreVersion: (v: string | null) => void
+ launcherVersion: string | null
+ setLauncherVersion: (v: string | null) => void
+
+ // Core update banner
+ coreUpdateInfo: { current: string; latest: string } | null
+ setCoreUpdateInfo: (info: { current: string; latest: string } | null) => void
+}
+
+export const useAgentsStore = create((set) => ({
+ agents: [],
+ setAgents: (agents) => set({ agents }),
+
+ pendingAgentActions: new Set(),
+ addPendingAction: (name) =>
+ set((state) => ({ pendingAgentActions: new Set(state.pendingAgentActions).add(name) })),
+ removePendingAction: (name) =>
+ set((state) => {
+ const next = new Set(state.pendingAgentActions)
+ next.delete(name)
+ return { pendingAgentActions: next }
+ }),
+
+ coreVersion: null,
+ setCoreVersion: (v) => set({ coreVersion: v }),
+ launcherVersion: null,
+ setLauncherVersion: (v) => set({ launcherVersion: v }),
+
+ coreUpdateInfo: null,
+ setCoreUpdateInfo: (info) => set({ coreUpdateInfo: info }),
+}))
+
+/** Derived selector — computed from agent states, no extra polling needed */
+export function useDaemonStatus(): 'online' | 'offline' | 'starting' {
+ const agents = useAgentsStore(useShallow((s) => s.agents))
+ if (agents.some((a) => ['online', 'running', 'idle'].includes(a.state))) return 'online'
+ if (agents.some((a) => ['starting', 'reconnecting'].includes(a.state))) return 'starting'
+ return 'offline'
+}
diff --git a/packages/launcher/src/renderer/store/catalog.ts b/packages/launcher/src/renderer/store/catalog.ts
new file mode 100644
index 000000000..5e2a42534
--- /dev/null
+++ b/packages/launcher/src/renderer/store/catalog.ts
@@ -0,0 +1,20 @@
+import { create } from 'zustand'
+import type { CatalogEntry } from '../types'
+
+interface CatalogState {
+ // Full catalog — shared by Install page and NewAgent dialog
+ catalog: CatalogEntry[]
+ setCatalog: (catalog: CatalogEntry[]) => void
+
+ // Supported types returned by getSupportedAgentTypes()
+ supportedTypes: string[]
+ setSupportedTypes: (types: string[]) => void
+}
+
+export const useCatalogStore = create((set) => ({
+ catalog: [],
+ setCatalog: (catalog) => set({ catalog }),
+
+ supportedTypes: [],
+ setSupportedTypes: (types) => set({ supportedTypes: types }),
+}))
diff --git a/packages/launcher/src/renderer/store/index.ts b/packages/launcher/src/renderer/store/index.ts
new file mode 100644
index 000000000..a9dc1b97b
--- /dev/null
+++ b/packages/launcher/src/renderer/store/index.ts
@@ -0,0 +1,6 @@
+export { useUiStore } from './ui'
+export { useAgentsStore, useDaemonStatus } from './agents'
+export { useCatalogStore } from './catalog'
+export { useWorkspacesStore } from './workspaces'
+export { useLogsStore } from './logs'
+export { useSettingsStore } from './settings'
diff --git a/packages/launcher/src/renderer/store/install.ts b/packages/launcher/src/renderer/store/install.ts
new file mode 100644
index 000000000..543e84916
--- /dev/null
+++ b/packages/launcher/src/renderer/store/install.ts
@@ -0,0 +1,78 @@
+import { create } from 'zustand'
+import type { AgentUpdateInfo, InstallPhase, InstalledAgentRecord } from '../types'
+
+export interface InstallJob {
+ agent: string
+ verb: 'install' | 'update' | 'uninstall' | 'rollback'
+ phase: InstallPhase
+ detail: string
+ log: string
+ error?: string
+ startedAt: number
+}
+
+interface InstallState {
+ jobs: Record
+ startJob: (job: Omit & {
+ phase?: InstallPhase
+ detail?: string
+ }) => void
+ updateJob: (agent: string, patch: Partial) => void
+ appendLog: (agent: string, chunk: string) => void
+ clearJob: (agent: string) => void
+
+ installed: InstalledAgentRecord[]
+ setInstalled: (recs: InstalledAgentRecord[]) => void
+
+ updates: AgentUpdateInfo[]
+ setUpdates: (updates: AgentUpdateInfo[]) => void
+}
+
+export const useInstallStore = create((set) => ({
+ jobs: {},
+ startJob: (j) =>
+ set((state) => ({
+ jobs: {
+ ...state.jobs,
+ [j.agent]: {
+ agent: j.agent,
+ verb: j.verb,
+ phase: j.phase || 'preparing',
+ detail: j.detail || 'Starting…',
+ log: '',
+ startedAt: Date.now(),
+ },
+ },
+ })),
+ updateJob: (agent, patch) =>
+ set((state) => {
+ const existing = state.jobs[agent]
+ if (!existing) return state
+ return { jobs: { ...state.jobs, [agent]: { ...existing, ...patch } } }
+ }),
+ appendLog: (agent, chunk) =>
+ set((state) => {
+ const existing = state.jobs[agent]
+ if (!existing) return state
+ const next = (existing.log + chunk).slice(-20000)
+ return { jobs: { ...state.jobs, [agent]: { ...existing, log: next } } }
+ }),
+ clearJob: (agent) =>
+ set((state) => {
+ const next = { ...state.jobs }
+ delete next[agent]
+ return { jobs: next }
+ }),
+
+ installed: [],
+ setInstalled: (recs) => set({ installed: recs }),
+
+ updates: [],
+ setUpdates: (updates) => set({ updates }),
+}))
+
+export function hasPendingUpdate(updates: AgentUpdateInfo[], name: string): boolean {
+ const info = updates.find((u) => u.name === name)
+ if (!info || !info.current || !info.latest) return false
+ return info.current !== info.latest
+}
diff --git a/packages/launcher/src/renderer/store/logs.ts b/packages/launcher/src/renderer/store/logs.ts
new file mode 100644
index 000000000..5da3b9c20
--- /dev/null
+++ b/packages/launcher/src/renderer/store/logs.ts
@@ -0,0 +1,28 @@
+import { create } from 'zustand'
+
+interface LogsState {
+ // Active agent filter — replaces legacy _logsFilter
+ agentFilter: string
+ setAgentFilter: (filter: string) => void
+
+ // File read position — replaces legacy _logsOffset (persisted across tab switches)
+ logsOffset: number
+ setLogsOffset: (offset: number) => void
+ resetLogsOffset: () => void
+
+ // Clear operation guard — replaces legacy _clearLogsInFlight
+ clearInFlight: boolean
+ setClearInFlight: (v: boolean) => void
+}
+
+export const useLogsStore = create((set) => ({
+ agentFilter: '',
+ setAgentFilter: (filter) => set({ agentFilter: filter, logsOffset: 0 }),
+
+ logsOffset: 0,
+ setLogsOffset: (offset) => set({ logsOffset: offset }),
+ resetLogsOffset: () => set({ logsOffset: 0 }),
+
+ clearInFlight: false,
+ setClearInFlight: (v) => set({ clearInFlight: v }),
+}))
diff --git a/packages/launcher/src/renderer/store/settings.ts b/packages/launcher/src/renderer/store/settings.ts
new file mode 100644
index 000000000..7ceb89b72
--- /dev/null
+++ b/packages/launcher/src/renderer/store/settings.ts
@@ -0,0 +1,24 @@
+import { create } from 'zustand'
+import type { RuntimeInfo } from '../types'
+
+interface SettingsState {
+ startOnBoot: boolean
+ setStartOnBoot: (v: boolean) => void
+
+ minimizeToTray: boolean
+ setMinimizeToTray: (v: boolean) => void
+
+ runtimeInfo: RuntimeInfo | null
+ setRuntimeInfo: (info: RuntimeInfo | null) => void
+}
+
+export const useSettingsStore = create((set) => ({
+ startOnBoot: false,
+ setStartOnBoot: (v) => set({ startOnBoot: v }),
+
+ minimizeToTray: false,
+ setMinimizeToTray: (v) => set({ minimizeToTray: v }),
+
+ runtimeInfo: null,
+ setRuntimeInfo: (info) => set({ runtimeInfo: info }),
+}))
diff --git a/packages/launcher/src/renderer/store/ui.ts b/packages/launcher/src/renderer/store/ui.ts
new file mode 100644
index 000000000..066259fe0
--- /dev/null
+++ b/packages/launcher/src/renderer/store/ui.ts
@@ -0,0 +1,55 @@
+import { create } from 'zustand'
+
+interface ActivityEntry {
+ time: string
+ msg: string
+}
+
+interface UiState {
+ // Active tab — replaces legacy _currentTab
+ currentTab: string
+ setCurrentTab: (tab: string) => void
+
+ // Deep-link request: when set, the Install page should auto-open this agent's
+ // detail view (used by Dashboard banner click and tray-menu update items).
+ installFocusAgent: string | null
+ setInstallFocusAgent: (name: string | null) => void
+
+ // Bumped each time the user explicitly clicks the Install sidebar tab.
+ // The Install page watches this and clears any open detail view so the
+ // user always lands on the marketplace list when entering via the tab.
+ installListSignal: number
+ goToInstallList: () => void
+
+ // Activity log — replaces legacy activityEntries[]
+ activityLog: ActivityEntry[]
+ addActivity: (msg: string) => void
+
+ // Cached icons directory path — replaces legacy _coreIconsDir
+ coreIconsDir: string | null
+ setCoreIconsDir: (dir: string | null) => void
+}
+
+export const useUiStore = create((set) => ({
+ currentTab: 'dashboard',
+ setCurrentTab: (tab) => set({ currentTab: tab }),
+
+ installFocusAgent: null,
+ setInstallFocusAgent: (name) => set({ installFocusAgent: name }),
+
+ installListSignal: 0,
+ goToInstallList: () =>
+ set((s) => ({ currentTab: 'install', installListSignal: s.installListSignal + 1 })),
+
+ activityLog: [],
+ addActivity: (msg) => {
+ const now = new Date()
+ const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ set((state) => ({
+ activityLog: [{ time, msg }, ...state.activityLog].slice(0, 50),
+ }))
+ },
+
+ coreIconsDir: null,
+ setCoreIconsDir: (dir) => set({ coreIconsDir: dir }),
+}))
diff --git a/packages/launcher/src/renderer/store/workspaces.ts b/packages/launcher/src/renderer/store/workspaces.ts
new file mode 100644
index 000000000..12bc564f9
--- /dev/null
+++ b/packages/launcher/src/renderer/store/workspaces.ts
@@ -0,0 +1,13 @@
+import { create } from 'zustand'
+import type { Workspace } from '../types'
+
+interface WorkspacesState {
+ // Workspace list — shared between Agents page and Settings page
+ workspaces: Workspace[]
+ setWorkspaces: (workspaces: Workspace[]) => void
+}
+
+export const useWorkspacesStore = create((set) => ({
+ workspaces: [],
+ setWorkspaces: (workspaces) => set({ workspaces }),
+}))
diff --git a/packages/launcher/src/renderer/styles.css b/packages/launcher/src/renderer/styles.css
deleted file mode 100644
index 4b11092db..000000000
--- a/packages/launcher/src/renderer/styles.css
+++ /dev/null
@@ -1,1182 +0,0 @@
-/* OpenAgents Connector — Light Theme
- * Clean, polished, Things-inspired
- */
-
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
-
-:root {
- --bg-primary: #f2f2f7;
- --bg-secondary: #ffffff;
- --bg-card: #ffffff;
- --bg-card-hover: #fafafa;
- --bg-input: #eeeef0;
- --bg-sidebar: #ffffff;
-
- --text-primary: #1c1c1e;
- --text-secondary: #636366;
- --text-tertiary: #aeaeb2;
- --text-link: #5856d6;
-
- --accent: #5856d6;
- --accent-hover: #4a48c4;
- --accent-bg: rgba(88, 86, 214, 0.06);
- --accent-border: rgba(88, 86, 214, 0.15);
- --accent-text: #ffffff;
-
- --success: #30d158;
- --success-bg: rgba(48, 209, 88, 0.08);
- --success-text: #248a3d;
- --warning: #ff9f0a;
- --warning-bg: rgba(255, 159, 10, 0.08);
- --warning-text: #c77c0a;
- --danger: #ff3b30;
- --danger-bg: rgba(255, 59, 48, 0.06);
- --danger-text: #d70015;
-
- --border: rgba(0, 0, 0, 0.06);
- --border-hover: rgba(0, 0, 0, 0.12);
- --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
- --shadow-md: 0 2px 10px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
- --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06);
- --radius: 12px;
- --radius-sm: 8px;
- --radius-lg: 16px;
-
- --ease: cubic-bezier(0.25, 0.1, 0.25, 1);
-}
-
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
- background: var(--bg-primary);
- color: var(--text-primary);
- overflow: hidden;
- height: 100vh;
- -webkit-font-smoothing: antialiased;
- font-size: 13px;
- line-height: 1.5;
- letter-spacing: -0.01em;
-}
-
-#app {
- display: flex;
- height: 100vh;
-}
-
-/* ============================================
- Sidebar — Things-style minimal
- ============================================ */
-#sidebar {
- width: 210px;
- background: var(--bg-sidebar);
- border-right: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- -webkit-app-region: drag;
- user-select: none;
-}
-
-.sidebar-header {
- padding: 24px 20px 16px;
-}
-
-.sidebar-header h2 {
- font-size: 15px;
- font-weight: 700;
- letter-spacing: -0.02em;
- color: var(--text-primary);
-}
-
-.sidebar-header .version {
- font-size: 11px;
- color: var(--text-tertiary);
- font-weight: 500;
- margin-top: 1px;
-}
-
-.nav-items {
- list-style: none;
- padding: 0 10px;
- flex: 1;
- -webkit-app-region: no-drag;
-}
-
-.nav-item {
- padding: 8px 12px;
- margin-bottom: 2px;
- cursor: pointer;
- color: var(--text-secondary);
- font-size: 13px;
- font-weight: 500;
- transition: all 0.18s var(--ease);
- display: flex;
- align-items: center;
- gap: 10px;
- border-radius: var(--radius-sm);
-}
-
-.nav-item:hover {
- background: var(--bg-primary);
- color: var(--text-primary);
-}
-
-.nav-item.active {
- background: var(--accent);
- color: #ffffff;
- box-shadow: 0 2px 6px rgba(88, 86, 214, 0.25);
-}
-
-.nav-icon {
- font-size: 14px;
- width: 18px;
- text-align: center;
- opacity: 0.55;
-}
-
-.nav-item.active .nav-icon {
- opacity: 1;
-}
-
-.sidebar-footer {
- padding: 14px 18px;
- border-top: 1px solid var(--border);
-}
-
-.version-info {
- display: flex;
- flex-direction: column;
- gap: 2px;
- margin-bottom: 10px;
-}
-
-.version-line {
- font-size: 10px;
- color: var(--text-tertiary);
- opacity: 0.7;
-}
-
-.daemon-status {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 11px;
- font-weight: 500;
- color: var(--text-tertiary);
-}
-
-.status-dot {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- display: inline-block;
- flex-shrink: 0;
-}
-
-.status-dot.online {
- background: var(--success);
- box-shadow: 0 0 0 3px rgba(48, 209, 88, 0.15);
-}
-
-.status-dot.offline {
- background: var(--text-tertiary);
-}
-
-.status-dot.starting {
- background: var(--warning);
- animation: pulse 1.5s infinite;
-}
-
-/* ============================================
- Main Content
- ============================================ */
-#content {
- flex: 1;
- overflow-y: auto;
- padding: 32px 36px;
- background: var(--bg-primary);
-}
-
-.tab-content {
- display: none;
- animation: fadeIn 0.25s var(--ease);
-}
-
-.tab-content.active {
- display: block;
-}
-
-h1 {
- font-size: 22px;
- font-weight: 700;
- margin-bottom: 24px;
- letter-spacing: -0.03em;
- color: var(--text-primary);
-}
-
-/* ============================================
- Cards
- ============================================ */
-.card {
- background: var(--bg-card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 18px 20px;
- margin-bottom: 12px;
- box-shadow: var(--shadow-sm);
- transition: box-shadow 0.18s var(--ease), border-color 0.18s var(--ease);
-}
-
-.card:hover {
- box-shadow: var(--shadow-md);
- border-color: var(--border-hover);
-}
-
-.card h3 {
- font-size: 13px;
- font-weight: 600;
- margin-bottom: 12px;
- color: var(--text-primary);
-}
-
-.card-grid {
- display: flex;
- flex-direction: column;
- gap: 10px;
- margin-bottom: 20px;
-}
-
-.agent-card {
- background: var(--bg-card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 16px 18px;
- box-shadow: var(--shadow-sm);
- transition: all 0.18s var(--ease);
-}
-
-.agent-card:hover {
- box-shadow: var(--shadow-md);
- border-color: var(--border-hover);
-}
-
-.agent-card-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
- gap: 12px;
-}
-
-.agent-icon {
- flex-shrink: 0;
- border-radius: 6px;
-}
-
-.agent-card-name {
- font-weight: 600;
- font-size: 14px;
- letter-spacing: -0.01em;
-}
-
-.agent-card-type {
- font-size: 11px;
- font-weight: 500;
- color: var(--text-tertiary);
- background: var(--bg-input);
- padding: 2px 10px;
- border-radius: 20px;
-}
-
-.agent-card-status {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- color: var(--text-secondary);
- margin-bottom: 12px;
-}
-
-.agent-card-error {
- font-size: 11px;
- color: var(--danger-text);
- margin-bottom: 10px;
- padding: 8px 12px;
- background: var(--danger-bg);
- border-radius: var(--radius-sm);
- line-height: 1.4;
-}
-
-.agent-card-actions {
- display: flex;
- gap: 8px;
-}
-
-.empty-state {
- text-align: center;
- padding: 48px 24px;
- color: var(--text-tertiary);
-}
-
-.empty-state p {
- margin-bottom: 16px;
- font-size: 13px;
-}
-
-/* ============================================
- Buttons — Things-style
- ============================================ */
-.btn {
- padding: 7px 16px;
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- background: var(--bg-card);
- color: var(--text-primary);
- cursor: pointer;
- font-size: 12px;
- font-weight: 500;
- transition: all 0.15s var(--ease);
- line-height: 1.4;
- box-shadow: var(--shadow-sm);
-}
-
-.btn:hover {
- border-color: var(--border-hover);
- box-shadow: var(--shadow-md);
- background: var(--bg-card-hover);
-}
-
-.btn:active {
- transform: scale(0.97);
- box-shadow: none;
-}
-
-.btn-primary {
- background: var(--accent);
- border-color: transparent;
- color: var(--accent-text);
- font-weight: 600;
- box-shadow: 0 1px 4px rgba(88, 86, 214, 0.2);
-}
-
-.btn-primary:hover {
- background: var(--accent-hover);
- border-color: transparent;
- box-shadow: 0 3px 10px rgba(88, 86, 214, 0.25);
-}
-
-.btn-danger {
- border-color: rgba(255, 59, 48, 0.2);
- color: var(--danger-text);
- background: var(--bg-card);
-}
-
-.btn-danger:hover {
- background: var(--danger-bg);
- border-color: rgba(255, 59, 48, 0.35);
-}
-
-.btn-sm {
- padding: 5px 12px;
- font-size: 11px;
-}
-
-.btn:disabled {
- opacity: 0.35;
- cursor: not-allowed;
- transform: none;
-}
-
-/* ============================================
- Forms
- ============================================ */
-.form-card {
- max-width: 480px;
-}
-
-.form-group {
- margin-bottom: 16px;
-}
-
-.form-group label {
- display: block;
- font-size: 11px;
- font-weight: 600;
- color: var(--text-secondary);
- margin-bottom: 6px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.form-group input[type="text"],
-.form-group input[type="password"],
-.form-group select {
- width: 100%;
- padding: 9px 14px;
- background: var(--bg-input);
- border: 1px solid transparent;
- border-radius: var(--radius-sm);
- color: var(--text-primary);
- font-size: 13px;
- outline: none;
- transition: all 0.18s var(--ease);
-}
-
-.form-group input:focus,
-.form-group select:focus {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-bg);
- background: #ffffff;
-}
-
-.form-group input[type="checkbox"] {
- margin-right: 8px;
- accent-color: var(--accent);
-}
-
-.form-actions {
- display: flex;
- gap: 8px;
- margin-top: 18px;
-}
-
-.required {
- color: var(--danger-text);
-}
-
-.hint {
- font-size: 12px;
- color: var(--text-tertiary);
- margin-bottom: 12px;
-}
-
-/* ============================================
- Status Rows
- ============================================ */
-.status-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 11px 0;
- font-size: 13px;
- border-bottom: 1px solid var(--border);
-}
-
-.status-row:last-of-type {
- border-bottom: none;
- margin-bottom: 8px;
-}
-
-/* ============================================
- Banners
- ============================================ */
-.banner {
- padding: 14px 18px;
- border-radius: var(--radius);
- margin-bottom: 18px;
- font-size: 13px;
-}
-
-.banner.warning {
- background: var(--warning-bg);
- border: 1px solid rgba(255, 159, 10, 0.15);
- color: var(--warning-text);
-}
-
-.banner a {
- color: var(--accent);
- cursor: pointer;
- text-decoration: none;
- font-weight: 500;
-}
-
-.banner a:hover {
- text-decoration: underline;
-}
-
-/* ============================================
- Actions Bar
- ============================================ */
-.actions-bar {
- display: flex;
- gap: 10px;
-}
-
-/* ============================================
- Agents Toolbar & List
- ============================================ */
-.agents-toolbar {
- display: flex;
- gap: 10px;
- margin-bottom: 16px;
-}
-
-.agent-list-item {
- background: var(--bg-card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 16px 18px;
- margin-bottom: 10px;
- display: flex;
- flex-direction: column;
- gap: 12px;
- box-shadow: var(--shadow-sm);
- transition: all 0.18s var(--ease);
-}
-
-.agent-list-top {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 16px;
-}
-
-.agent-list-status {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 4px;
- flex-shrink: 0;
-}
-
-.agent-state-text {
- font-size: 13px;
- font-weight: 600;
-}
-
-.agent-ws-label {
- font-size: 11px;
- color: var(--text-secondary);
-}
-
-.agent-ws-label.muted {
- color: var(--text-tertiary);
-}
-
-.agent-list-name-row {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.agent-list-name-row h4 {
- margin: 0;
-}
-
-.agent-type-label {
- font-size: 12px;
- color: var(--text-secondary);
- display: block;
- margin-bottom: 2px;
-}
-
-.agent-list-bottom {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding-top: 10px;
- border-top: 1px solid var(--border);
-}
-
-.agent-list-item:hover {
- box-shadow: var(--shadow-md);
- border-color: var(--border-hover);
-}
-
-.agent-list-info {
- flex: 1;
- min-width: 0;
-}
-
-.agent-list-info h4 {
- font-size: 14px;
- font-weight: 600;
- margin-bottom: 4px;
-}
-
-.agent-list-info span {
- font-size: 12px;
- color: var(--text-secondary);
- display: block;
- margin-bottom: 2px;
-}
-
-.agent-config-hint {
- font-size: 11px !important;
- color: var(--text-tertiary) !important;
-}
-
-.agent-error {
- font-size: 11px !important;
- color: var(--danger-text) !important;
-}
-
-.text-warning {
- color: var(--warning-text);
-}
-
-.text-danger {
- color: var(--danger-text);
-}
-
-.agent-list-actions {
- display: flex;
- gap: 6px;
- flex-wrap: wrap;
- justify-content: flex-end;
-}
-
-
-/* ============================================
- Logs
- ============================================ */
-.log-controls {
- display: flex;
- gap: 10px;
- margin-bottom: 12px;
- align-items: center;
-}
-
-.log-controls select {
- padding: 7px 12px;
- background: var(--bg-input);
- border: 1px solid transparent;
- border-radius: var(--radius-sm);
- color: var(--text-primary);
- font-size: 12px;
-}
-
-.log-viewer {
- background: #1a1a1e;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 16px 18px;
- font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
- font-size: 11.5px;
- line-height: 1.65;
- max-height: calc(100vh - 200px);
- overflow-y: auto;
- white-space: pre-wrap;
- word-break: break-all;
- color: #d4d4d4;
- box-shadow: var(--shadow-md);
-}
-
-/* ============================================
- Install Tab
- ============================================ */
-.install-output {
- margin-top: 12px;
- background: #1a1a1e;
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 14px 16px;
- font-family: 'SF Mono', monospace;
- font-size: 11.5px;
- max-height: 200px;
- overflow-y: auto;
- white-space: pre-wrap;
- color: #d4d4d4;
-}
-
-/* ============================================
- Catalog — Things-style list
- ============================================ */
-.catalog-search {
- margin-bottom: 14px;
-}
-
-.catalog-search input {
- width: 100%;
- padding: 9px 14px;
- background: var(--bg-input);
- border: 1px solid transparent;
- border-radius: var(--radius-sm);
- color: var(--text-primary);
- font-size: 13px;
- outline: none;
- transition: all 0.18s var(--ease);
-}
-
-.catalog-search input:focus {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-bg);
- background: #ffffff;
-}
-
-.catalog-list {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.catalog-row {
- display: flex;
- align-items: center;
- gap: 14px;
- padding: 12px 16px;
- background: var(--bg-card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- transition: all 0.15s var(--ease);
- box-shadow: var(--shadow-sm);
-}
-
-.catalog-row:hover {
- box-shadow: var(--shadow-md);
- border-color: var(--border-hover);
-}
-
-.catalog-row.installed {
- opacity: 1;
-}
-
-.catalog-info {
- flex: 1;
- min-width: 0;
- display: flex;
- align-items: center;
- gap: 14px;
-}
-
-.catalog-icon {
- width: 32px;
- height: 32px;
- flex-shrink: 0;
- border-radius: 8px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--bg-input);
- padding: 4px;
-}
-
-.catalog-icon img {
- width: 22px;
- height: 22px;
- object-fit: contain;
-}
-
-.catalog-text {
- flex: 1;
- min-width: 0;
-}
-
-.catalog-name {
- font-weight: 600;
- font-size: 13px;
- display: block;
- color: var(--text-primary);
-}
-
-.catalog-desc {
- font-size: 11px;
- color: var(--text-tertiary);
- display: block;
- margin-top: 1px;
-}
-
-.catalog-status {
- flex-shrink: 0;
-}
-
-.catalog-actions {
- flex-shrink: 0;
- display: flex;
- gap: 6px;
-}
-
-.badge {
- font-size: 10px;
- font-weight: 600;
- padding: 3px 10px;
- border-radius: 20px;
- text-transform: uppercase;
- letter-spacing: 0.03em;
-}
-
-.badge-success {
- background: var(--success-bg);
- color: var(--success-text);
-}
-
-.badge-warning {
- background: var(--warning-bg);
- color: var(--warning-text);
-}
-
-.badge-info {
- background: #e0e7ff;
- color: #3730a3;
-}
-
-.support-icons {
- display: inline-flex;
- gap: 4px;
- margin-top: 2px;
-}
-
-.support-icon {
- font-size: 11px;
- line-height: 1;
-}
-
-.support-icon.on {
- opacity: 1;
-}
-
-.support-icon.off {
- opacity: 0.2;
-}
-
-.badge-success-sm, .badge-warning-sm, .badge-muted-sm, .badge-danger-sm {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 4px;
- font-weight: 500;
-}
-.badge-success-sm { background: var(--success-bg); color: var(--success-text); }
-.badge-warning-sm { background: var(--warning-bg); color: var(--warning-text); }
-.badge-muted-sm { background: #f0f0f0; color: #888; }
-.badge-danger-sm { background: var(--danger-bg); color: var(--danger-text); }
-
-.agent-card-info {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin: 6px 0;
-}
-
-.loading-text {
- color: var(--text-tertiary);
- font-size: 13px;
- padding: 24px 0;
-}
-
-.skeleton-card,
-.skeleton-list-item {
- background: var(--bg-card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 16px 18px;
- box-shadow: var(--shadow-sm);
- margin-bottom: 10px;
-}
-
-.skeleton-list {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.skeleton-line {
- height: 10px;
- border-radius: 999px;
- margin-bottom: 10px;
- background: linear-gradient(90deg, #ececf1 25%, #f6f6f8 50%, #ececf1 75%);
- background-size: 200% 100%;
- animation: skeletonShimmer 1.1s linear infinite;
-}
-
-.skeleton-line-lg { width: 62%; }
-.skeleton-line-md { width: 42%; }
-.skeleton-line-sm { width: 26%; margin-bottom: 0; }
-
-@keyframes skeletonShimmer {
- 0% { background-position: 200% 0; }
- 100% { background-position: -200% 0; }
-}
-
-/* ============================================
- Modal — Frosted glass
- ============================================ */
-.modal-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.2);
- backdrop-filter: blur(16px);
- -webkit-backdrop-filter: blur(16px);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1000;
- animation: fadeIn 0.15s var(--ease);
-}
-
-.modal {
- background: var(--bg-secondary);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 28px;
- min-width: 400px;
- max-width: 520px;
- max-height: 80vh;
- overflow-y: auto;
- box-shadow: var(--shadow-lg);
- animation: modalIn 0.22s var(--ease);
-}
-
-.modal h3 {
- font-size: 17px;
- font-weight: 700;
- margin-bottom: 20px;
- letter-spacing: -0.02em;
-}
-
-.modal .form-group {
- margin-bottom: 16px;
-}
-
-.modal .form-group label {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-secondary);
- margin-bottom: 6px;
- display: block;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.modal .form-group input,
-.modal .form-group select {
- width: 100%;
- padding: 9px 14px;
- background: var(--bg-input);
- border: 1px solid transparent;
- border-radius: var(--radius-sm);
- color: var(--text-primary);
- font-size: 13px;
- outline: none;
- transition: all 0.18s var(--ease);
-}
-
-.modal .form-group input:focus,
-.modal .form-group select:focus {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-bg);
- background: #ffffff;
-}
-
-.modal .form-group input[type="datetime-local"] {
- min-height: 40px;
-}
-
-.modal-button-row {
- display: flex;
- gap: 8px;
- margin-top: 20px;
-}
-
-.modal-actions-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin-bottom: 14px;
-}
-
-.modal-action-btn {
- text-align: left;
- padding: 11px 16px;
- font-size: 13px;
- width: 100%;
- border-radius: var(--radius-sm);
-}
-
-.modal-action-btn:hover {
- background: var(--accent-bg);
- border-color: var(--accent-border);
-}
-
-.modal-close-btn {
- margin-top: 10px;
- width: 100%;
- text-align: center;
-}
-
-.configure-form {
- margin-bottom: 14px;
-}
-
-#test-result {
- min-height: 20px;
- margin-bottom: 10px;
- font-size: 12px;
-}
-
-.test-loading { color: var(--text-secondary); }
-.test-success { color: var(--success-text); }
-.test-error { color: var(--danger-text); }
-
-/* ============================================
- Scrollbar — subtle
- ============================================ */
-::-webkit-scrollbar {
- width: 6px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background: rgba(0, 0, 0, 0.1);
- border-radius: 3px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: rgba(0, 0, 0, 0.18);
-}
-
-/* ============================================
- Animations
- ============================================ */
-@keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
-@keyframes modalIn {
- from { opacity: 0; transform: scale(0.97) translateY(8px); }
- to { opacity: 1; transform: scale(1) translateY(0); }
-}
-
-@keyframes pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.4; }
-}
-
-@keyframes spin {
- to { transform: rotate(360deg); }
-}
-
-/* ============================================
- D25: Activity log panel
- ============================================ */
-.activity-log {
- max-height: 180px;
- overflow-y: auto;
- font-size: 11.5px;
- line-height: 1.6;
- color: var(--text-secondary);
-}
-
-.activity-log-entry {
- padding: 3px 0;
- border-bottom: 1px solid var(--border);
- display: flex;
- gap: 8px;
-}
-
-.activity-log-entry:last-child {
- border-bottom: none;
-}
-
-.activity-log-time {
- color: var(--text-tertiary);
- font-size: 10px;
- flex-shrink: 0;
- min-width: 50px;
-}
-
-.activity-log-msg {
- flex: 1;
- min-width: 0;
- word-break: break-word;
-}
-
-/* ============================================
- D26: Responsive agent cards
- ============================================ */
-@media (min-width: 700px) {
- .card-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- }
-}
-
-@media (max-width: 500px) {
- #sidebar {
- width: 54px;
- }
- .sidebar-header h2,
- .sidebar-header .version,
- .nav-item span:not(.nav-icon) {
- display: none;
- }
- .nav-item { justify-content: center; }
- .nav-icon { margin: 0; }
- #content { padding: 20px 16px; }
- .agent-card-header { flex-wrap: wrap; }
- .agent-list-item { flex-direction: column; }
- .agent-list-actions { justify-content: flex-start; }
-}
-
-/* ============================================
- D27: Loading spinner
- ============================================ */
-.spinner {
- display: inline-block;
- width: 16px;
- height: 16px;
- border: 2px solid var(--border);
- border-top-color: var(--accent);
- border-radius: 50%;
- animation: spin 0.6s linear infinite;
- vertical-align: middle;
- margin-right: 6px;
-}
-
-.btn-loading {
- pointer-events: none;
- opacity: 0.7;
-}
-
-.btn-loading::before {
- content: '';
- display: inline-block;
- width: 12px;
- height: 12px;
- border: 2px solid rgba(255,255,255,0.3);
- border-top-color: #fff;
- border-radius: 50%;
- animation: spin 0.6s linear infinite;
- margin-right: 6px;
- vertical-align: middle;
-}
-
-/* ============================================
- D29: Workspace URL in Settings
- ============================================ */
-.workspace-url-list {
- list-style: none;
- padding: 0;
-}
-
-.workspace-url-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 8px 0;
- border-bottom: 1px solid var(--border);
- font-size: 12px;
-}
-
-.workspace-url-item:last-child {
- border-bottom: none;
-}
-
-.workspace-url-name {
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.workspace-url-link {
- color: var(--text-link);
- cursor: pointer;
- font-size: 11px;
-}
diff --git a/packages/launcher/src/renderer/types/index.ts b/packages/launcher/src/renderer/types/index.ts
new file mode 100644
index 000000000..a3ccc1cb2
--- /dev/null
+++ b/packages/launcher/src/renderer/types/index.ts
@@ -0,0 +1,186 @@
+export type AgentState = 'online' | 'running' | 'idle' | 'starting' | 'reconnecting' | 'stopped' | 'error'
+
+export interface HealthCheck {
+ ready: boolean
+ installed?: boolean
+ binary?: string | null
+ version?: string | null
+ message?: string
+ auth_mode?: string
+ execution_mode?: string
+}
+
+export interface Agent {
+ name: string
+ type: string
+ state: AgentState
+ health: HealthCheck | null
+ network?: string | null
+ networkName?: string | null
+ lastError?: string | null
+ runtimeMismatch?: boolean
+ restarts?: number
+ env?: Record
+ path?: string
+}
+
+export interface EnvField {
+ name: string
+ description: string
+ required?: boolean
+ password?: boolean
+ placeholder?: string
+ default?: string
+}
+
+export interface CatalogEntry {
+ name: string
+ label?: string
+ description?: string
+ homepage?: string
+ tags?: string[]
+ featured?: boolean
+ order?: number
+ builtin?: boolean
+ installed: boolean
+ managed?: boolean
+ location?: string
+ support?: {
+ install?: boolean
+ workspace?: boolean
+ collaboration?: boolean
+ }
+ requires?: string[]
+ install?: {
+ binary?: string
+ requires?: (string | null)[]
+ macos?: string
+ linux?: string
+ windows?: string
+ api_only?: boolean
+ }
+ check_ready?: {
+ login_command?: string
+ not_ready_message?: string
+ env_vars?: string[]
+ saved_env_key?: string
+ }
+ env_config?: EnvField[]
+ screenshots?: string[]
+ demo?: string
+ demo_url?: string
+ long_description?: string
+}
+
+export interface InstalledAgentRecord {
+ name: string
+ version: string | null
+ installedAt: string
+ previousVersion?: string | null
+ history?: Array<{ version: string; installedAt: string }>
+}
+
+export interface AgentUpdateInfo {
+ name: string
+ current: string | null
+ latest: string | null
+ changelog?: Array<{ version: string; date?: string }>
+}
+
+export type InstallPhase = 'idle' | 'preparing' | 'downloading' | 'installing' | 'verifying' | 'done' | 'error'
+
+export interface InstallProgressEvent {
+ agent: string
+ verb: 'install' | 'update' | 'uninstall' | 'rollback'
+ phase: InstallPhase
+ detail?: string
+ log?: string
+ error?: string
+}
+
+export interface Workspace {
+ id: string
+ slug: string
+ name?: string
+ endpoint?: string
+ token?: string
+}
+
+export interface RuntimeInfo {
+ nodeVersion: string | null
+ npmVersion: string | null
+ coreVersion: string | null
+ latestVersion: string | null
+}
+
+export interface PythonStatus {
+ pythonPath: string | null
+ pythonFound: boolean
+ sdkInstalled: boolean
+ sdkVersion: string
+ launcherVersion: string
+ runtime: string
+}
+
+declare global {
+ interface Window {
+ api: {
+ pythonStatus(): Promise
+ installSDK(): Promise
+ runtimeInfo(): Promise
+ listAgents(): Promise
+ getSupportedAgentTypes(): Promise
+ getAgentCoreInfo(): Promise
+ addAgent(config: { name: string; type: string; path?: string }): Promise
+ removeAgent(name: string): Promise
+ updateAgent(name: string, config: unknown): Promise
+ startAgent(name: string): Promise
+ stopAgent(name: string): Promise
+ startAll(): Promise
+ stopAll(): Promise
+ agentStatus(): Promise>
+ agentLogs(name: string, lines: number): Promise<{ lines: string[] }>
+ tailAgentLogs(name: string, lines: number, offset: number): Promise<{ lines: string[]; size?: number }>
+ clearLogsInRange(start: string, end: string): Promise<{ removed: number; remaining: number }>
+ installAgentType(type: string): Promise
+ installAgentTypeStreaming(type: string): Promise
+ onInstallOutput(callback: (data: string) => void): void
+ removeInstallOutputListener(): void
+ onInstallProgress(callback: (ev: InstallProgressEvent) => void): void
+ removeInstallProgressListener(): void
+ uninstallAgentType(type: string): Promise
+ uninstallAgentTypeStreaming(type: string): Promise
+ checkAgentType(type: string): Promise<{ installed: boolean; binary: string | null }>
+ getCatalog(): Promise
+ getInstalledAgents(): Promise
+ checkAgentUpdates(): Promise
+ rollbackAgentType(type: string): Promise<{ success: boolean; version?: string | null; error?: string }>
+ getAgentChangelog(type: string): Promise<{ versions: Array<{ version: string; date?: string }>; homepage?: string; error?: string }>
+ getEnvFields(type: string): Promise
+ getAgentEnv(type: string): Promise>
+ saveAgentEnv(type: string, env: Record): Promise
+ getAgentInstanceEnv(name: string): Promise>
+ saveAgentInstanceEnv(name: string, env: Record): Promise
+ testLLM(env: Record): Promise<{ success: boolean; model?: string; response?: string; error?: string }>
+ signalReload(): Promise
+ connectWorkspace(agentName: string, slug: string): Promise
+ disconnectWorkspace(agentName: string): Promise
+ removeWorkspace(slug: string): Promise
+ listWorkspaces(): Promise
+ createWorkspace(name: string): Promise<{ token?: string; slug?: string }>
+ getSetting(key: string): Promise
+ setSetting(key: string, value: unknown): Promise
+ healthCheck(type: string): Promise
+ openExternal(url: string): Promise
+ shellExec(cmd: string): Promise
+ openTerminal(cmd: string): Promise
+ updateCore(): Promise<{ success: boolean; version?: string; error?: string }>
+ onCoreUpdate(cb: (info: { current: string; latest: string }) => void): void
+ onAgentUpdatesChanged(cb: (updates: AgentUpdateInfo[]) => void): void
+ onNavigateToInstall(cb: (agentName: string) => void): void
+ getIconPath(name: string): Promise
+ getIconsDir(): Promise
+ debugEnv(): Promise>
+ }
+ }
+}
diff --git a/packages/launcher/tsconfig.json b/packages/launcher/tsconfig.json
new file mode 100644
index 000000000..155ebaa67
--- /dev/null
+++ b/packages/launcher/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.web.json" }
+ ]
+}
diff --git a/packages/launcher/tsconfig.node.json b/packages/launcher/tsconfig.node.json
new file mode 100644
index 000000000..9c5aa76e7
--- /dev/null
+++ b/packages/launcher/tsconfig.node.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020"],
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "strict": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "skipLibCheck": true,
+ "composite": true,
+ "types": ["electron-vite/node", "node"]
+ },
+ "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"]
+}
diff --git a/packages/launcher/tsconfig.web.json b/packages/launcher/tsconfig.web.json
new file mode 100644
index 000000000..fc9bc2a51
--- /dev/null
+++ b/packages/launcher/tsconfig.web.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "composite": true,
+ "baseUrl": ".",
+ "paths": {
+ "@renderer/*": ["src/renderer/*"]
+ }
+ },
+ "include": ["src/renderer/**/*"]
+}