diff --git a/CHANGELOG.md b/CHANGELOG.md index 8543970..6080f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.2.2 - 2026-05-17 + +### Fixed +- Restored the full governor UI from the prepared release work: digit-only inputs, narrow two-column layout, safe default values, and protection against live refresh wiping unsaved edits. +- Added the missing risk-disclaimer modal linked from the governor acknowledgement text. +- Restored the compact two-line footer with activity summary and color-coded status messages. +- Added bulk tab visibility bridge methods so the header show/hide-all control can use one desktop IPC call when available. +- Made Chrome CDP the first browser backend option in the Control Center. + +### Verified +- Control Center script syntax check passed. +- Public package dry-run excludes private workflows. + ## 0.2.1 - 2026-05-17 ### Fixed diff --git a/main.mjs b/main.mjs index 4ff3e84..0b53838 100644 --- a/main.mjs +++ b/main.mjs @@ -376,6 +376,37 @@ async function main() { return { ok: true }; }); + const setTabsVisible = async (visible) => { + let changed = 0; + for (const tab of tabs.listTabs()) { + try { + const win = tabs.getWindowById(tab.id); + if (visible) { + if (win.isMinimized?.()) win.restore?.(); + win.show?.(); + win.focus?.(); + } else { + win.minimize?.(); + } + changed += 1; + } catch {} + } + emitTabsChanged(); + return { ok: true, changed }; + }; + + ipcMain.handle('agentify:setTabsVisible', async (_evt, args) => { + return await setTabsVisible(!!args?.visible); + }); + + ipcMain.handle('agentify:showAllTabs', async () => { + return await setTabsVisible(true); + }); + + ipcMain.handle('agentify:hideAllTabs', async () => { + return await setTabsVisible(false); + }); + ipcMain.handle('agentify:closeTab', async (_evt, args) => { const tabId = String(args?.tabId || '').trim(); if (!tabId) throw new Error('missing_tabId'); diff --git a/package-lock.json b/package-lock.json index c4ad405..f74af77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentify/desktop", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentify/desktop", - "version": "0.2.1", + "version": "0.2.2", "license": "MPL-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index a9f33c2..c8cef9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentify/desktop", - "version": "0.2.1", + "version": "0.2.2", "description": "Agentify Desktop control center and MCP server for local AI web sessions", "license": "MPL-2.0", "type": "module", diff --git a/ui/control-center.css b/ui/control-center.css index 7b053e5..5f532c2 100644 --- a/ui/control-center.css +++ b/ui/control-center.css @@ -138,6 +138,17 @@ body { grid-template-columns: 1fr 1fr; gap: 8px 12px; } +.governorIntro { + margin-bottom: 14px; +} +.governorGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; +} +.governorInput { + max-width: 112px; + text-align: right; +} .compactNum { max-width: 160px; } @@ -261,16 +272,95 @@ input::placeholder { color: rgba(233,236,242,0.45); } .hint { font-size: 12px; color: rgba(233,236,242,0.66); line-height: 1.35; margin-top: 8px; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } +.linkButton { + border: 0; + padding: 0; + background: transparent; + color: #93c5fd; + cursor: pointer; + font: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} +.linkButton:hover { color: #bfdbfe; } +.modal { + max-width: min(560px, calc(100vw - 32px)); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; + padding: 0; + background: #10131a; + color: #e9ecf2; + box-shadow: 0 22px 70px rgba(0,0,0,0.52); +} +.modal::backdrop { background: rgba(0,0,0,0.58); } +.modalPanel { padding: 14px; } +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.modalTitle { font-weight: 700; } +.modalBody { + margin-top: 12px; + color: rgba(233,236,242,0.78); + font-size: 13px; + line-height: 1.45; +} +.modalBody p { margin: 0 0 10px; } +.modalBody p:last-child { margin-bottom: 0; } +.compactBtn { + flex: 0 0 auto; + padding: 7px 10px; + border-radius: 10px; + line-height: 1; + white-space: nowrap; +} .footer { border-top: 1px solid rgba(255,255,255,0.08); - padding: 10px 14px; + padding: 6px 10px; display: flex; - justify-content: flex-start; + justify-content: space-between; align-items: center; + gap: 12px; background: rgba(0,0,0,0.15); + min-height: 44px; +} +.footerStatus { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.activityLabel { + font-size: 12px; + font-weight: 700; + color: rgba(233,236,242,0.86); +} +.status { + font-size: 12px; + color: rgba(233,236,242,0.7); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.messageLine { + font-size: 11px; + color: #93c5fd; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.messageLine.isWarn { + color: #fbbf24; +} +.messageLine.isError { + color: #fca5a5; +} +.messageLine.isMuted { + color: rgba(233,236,242,0.52); } -.status { font-size: 12px; color: rgba(233,236,242,0.65); } @media (max-width: 760px) { .header { @@ -284,3 +374,9 @@ input::placeholder { color: rgba(233,236,242,0.45); } font-size: 12px; } } + +@media (max-width: 420px) { + .governorGrid { + grid-template-columns: 1fr; + } +} diff --git a/ui/control-center.html b/ui/control-center.html index 31096c7..18ec606 100644 --- a/ui/control-center.html +++ b/ui/control-center.html @@ -141,26 +141,26 @@
-
Automation limits
-
+
Settings (governor)
+
These defaults exist to reduce accidental high-rate automation. Automating web UIs may violate website terms; use responsibly.
-
+
@@ -176,10 +176,10 @@
-
+ +
+
+
Automation risk controls
+ +
+
+

+ Agentify Desktop controls browser tabs on your machine. High-frequency tab automation can accidentally send repeated prompts, + trigger abuse systems, consume account quota, or violate a website's terms of use. +

+

+ The governor reduces those risks by limiting concurrent prompts, capping prompts per minute, spacing requests to the same tab, + and enforcing a short global gap between sends. CAPTCHA, login, and auth challenges pause automation and bring the tab forward + so you can resolve them manually. +

+

+ These controls are guardrails, not permission to ignore service limits. Keep the defaults unless you have a specific reason, + and raise limits only for websites and accounts where that activity is allowed. +

+
+
+
+ diff --git a/ui/control-center.js b/ui/control-center.js index 4054638..9244995 100644 --- a/ui/control-center.js +++ b/ui/control-center.js @@ -53,12 +53,19 @@ function fmtOutcomeStatus(status) { } function num(id, fallback) { - const v = Number(el(id).value); + const raw = String(el(id).value || '').trim(); + if (!raw) return fallback; + const v = Number(raw); return Number.isFinite(v) ? v : fallback; } -function setNum(id, value) { - el(id).value = String(Number(value)); +function setNum(id, value, fallback = 0) { + const next = Number.isFinite(Number(value)) ? Number(value) : fallback; + el(id).value = String(next); +} + +function setValue(id, value) { + el(id).value = String(value ?? ''); } function setChecked(id, value) { @@ -108,7 +115,7 @@ function defaultState() { tabs: [], defaultTabId: null, stateDir: '', - browserBackend: 'electron', + browserBackend: 'chrome-cdp', browser: null, runtime: { inflightQueries: 0, activeQueries: [], lastOutcomes: [] } }; @@ -116,23 +123,31 @@ function defaultState() { function defaultSettings() { return { - browserBackend: 'electron', + browserBackend: 'chrome-cdp', chromeDebugPort: 9222, chromeExecutablePath: null, chromeProfileMode: 'isolated', chromeProfileName: 'Default', maxInflightQueries: 2, maxQueriesPerMinute: 12, - minTabGapMs: 0, - minGlobalGapMs: 0, + minTabGapMs: 1200, + minGlobalGapMs: 200, showTabsByDefault: false, allowAuthPopups: true, acknowledgedAt: null }; } -function statusText(msg) { - el('statusLine').textContent = msg; +function statusText(msg, tone = 'info') { + const line = el('messageLine'); + line.textContent = msg; + line.classList.toggle('isWarn', tone === 'warn'); + line.classList.toggle('isError', tone === 'error'); + line.classList.toggle('isMuted', tone === 'muted'); +} + +function setActivityText(html) { + el('statusLine').innerHTML = html; } function isChromeCdpSelected() { @@ -150,6 +165,55 @@ let refreshInFlight = null; let lastRefreshAt = 0; let hasLiveUpdates = false; let tabsAreHidden = false; +let settingsDirty = false; + +function updateSaveEnabled() { + el('btnSaveSettings').disabled = !settingsDirty || !el('setAcknowledge').checked; +} + +function markSettingsDirty() { + settingsDirty = true; + updateSaveEnabled(); + el('settingsHint').textContent = 'Unsaved changes.'; +} + +function sanitizeIntegerField(input, { clamp = false } = {}) { + const digits = String(input.value || '').replace(/[^\d]/g, ''); + input.value = digits; + if (!clamp || !digits) return; + const min = Number(input.dataset.min || 0); + const max = Number(input.dataset.max || Number.MAX_SAFE_INTEGER); + const next = Math.max(min, Math.min(max, Number(digits))); + input.value = String(next); +} + +function applySettings(settings) { + const s = { ...defaultSettings(), ...(settings || {}) }; + setValue('setBrowserBackend', s.browserBackend || defaultSettings().browserBackend); + setValue('setChromeProfileMode', s.chromeProfileMode || defaultSettings().chromeProfileMode); + setValue('setChromeProfileName', s.chromeProfileName || defaultSettings().chromeProfileName); + setNum('setMaxInflight', s.maxInflightQueries, defaultSettings().maxInflightQueries); + setNum('setQpm', s.maxQueriesPerMinute, defaultSettings().maxQueriesPerMinute); + setNum('setTabGap', s.minTabGapMs, defaultSettings().minTabGapMs); + setNum('setGlobalGap', s.minGlobalGapMs, defaultSettings().minGlobalGapMs); + setChecked('setShowTabsDefault', s.showTabsByDefault); + setChecked('setAllowAuthPopups', s.allowAuthPopups !== false); + setChecked('setAcknowledge', false); + settingsDirty = false; + updateSaveEnabled(); + el('settingsHint').textContent = s.acknowledgedAt ? `Last acknowledged: ${s.acknowledgedAt}` : 'Using safe defaults until you acknowledge changes.'; + syncChromeProfileFields(); +} + +function closeDialog(dialog) { + if (typeof dialog.close === 'function') dialog.close(); + else dialog.removeAttribute('open'); +} + +function openDialog(dialog) { + if (typeof dialog.showModal === 'function') dialog.showModal(); + else dialog.setAttribute('open', 'open'); +} function tabSortWeight(tab, active, outcome) { if (active?.blocked) return 0; @@ -176,7 +240,15 @@ function updateTabsToggle(tabs = []) { async function setAllTabsVisible(visible) { const tabs = Array.isArray(lastState.tabs) ? lastState.tabs : []; if (!tabs.length) { - statusText('No managed tabs are currently open.'); + statusText('No managed tabs are currently open.', 'muted'); + return; + } + const bridge = getBridge(); + if (typeof bridge?.setTabsVisible === 'function') { + const out = await callApi('setTabsVisible', { visible }, { required: true }); + tabsAreHidden = !visible; + updateTabsToggle(tabs); + statusText(`${visible ? 'Showed' : 'Hid'} ${out?.changed ?? tabs.length} managed tab${(out?.changed ?? tabs.length) === 1 ? '' : 's'}.`); return; } let changed = 0; @@ -454,21 +526,12 @@ async function refresh() { const runningSummary = ` • Running: ${activeQueries.length}`; const liveSummary = hasLiveUpdates ? 'Live updates on' : 'Polling every 3s'; const refreshedSummary = lastRefreshAt ? ` • Refreshed ${new Date(lastRefreshAt).toLocaleTimeString()}` : ''; - statusText(`Backend: ${browserSummary} • Tabs: ${tabs.length}${runningSummary} • ${liveSummary}${refreshedSummary} • State: ${lastState.stateDir || ''}`); - - // Settings UI. - el('setBrowserBackend').value = settings.browserBackend || 'electron'; - el('setChromeProfileMode').value = settings.chromeProfileMode || 'isolated'; - el('setChromeProfileName').value = settings.chromeProfileName || 'Default'; - setNum('setMaxInflight', settings.maxInflightQueries); - setNum('setQpm', settings.maxQueriesPerMinute); - setNum('setTabGap', settings.minTabGapMs); - setNum('setGlobalGap', settings.minGlobalGapMs); - setChecked('setShowTabsDefault', settings.showTabsByDefault); - setChecked('setAllowAuthPopups', settings.allowAuthPopups !== false); - setChecked('setAcknowledge', false); - el('btnSaveSettings').disabled = true; - el('settingsHint').textContent = settings.acknowledgedAt ? `Last acknowledged: ${settings.acknowledgedAt}` : 'Not acknowledged yet.'; + const activity = activeQueries.length + ? activeQueries.map((item) => `${item.tabId || 'tab'} ${String(item.phase || 'working').replace(/_/g, ' ')}`).join(' • ') + : 'Idle'; + setActivityText(`Activity: ${activity} • Backend: ${browserSummary} • Tabs: ${tabs.length}${runningSummary} • ${liveSummary}${refreshedSummary}`); + + if (!settingsDirty) applySettings(settings); })().finally(() => { refreshInFlight = null; }); @@ -568,16 +631,40 @@ async function main() { syncChromeProfileFields(); }; - const updateSaveEnabled = () => { - el('btnSaveSettings').disabled = !el('setAcknowledge').checked; - }; el('setAcknowledge').onchange = updateSaveEnabled; + for (const id of ['setMaxInflight', 'setQpm', 'setTabGap', 'setGlobalGap']) { + const input = el(id); + input.addEventListener('input', () => { + sanitizeIntegerField(input); + markSettingsDirty(); + }); + input.addEventListener('blur', () => { + sanitizeIntegerField(input, { clamp: true }); + markSettingsDirty(); + }); + } + for (const id of ['setShowTabsDefault', 'setAllowAuthPopups', 'setBrowserBackend', 'setChromeProfileMode', 'setChromeProfileName']) { + const input = el(id); + input.addEventListener('input', markSettingsDirty); + input.addEventListener('change', markSettingsDirty); + } syncChromeProfileFields(); + const riskModal = el('riskModal'); + el('btnRiskDetails').onclick = (event) => { + event.preventDefault(); + openDialog(riskModal); + }; + el('btnCloseRiskModal').onclick = () => closeDialog(riskModal); + riskModal.addEventListener('click', (event) => { + if (event.target === riskModal) el('btnCloseRiskModal').click(); + }); + el('btnResetSettings').onclick = async () => { el('settingsHint').textContent = ''; try { await callApi('setSettings', { reset: true }, { required: true }); + settingsDirty = false; el('settingsHint').textContent = 'Reset to defaults.'; await refresh(); } catch (e) { @@ -606,9 +693,11 @@ async function main() { { required: true } ); const backendChanged = String(saved?.browserBackend || 'electron') !== String(lastState.browserBackend || 'electron'); + settingsDirty = false; el('settingsHint').textContent = `Saved.${saved?.acknowledgedAt ? ` ${saved.acknowledgedAt}` : ''}${backendChanged ? ' Restart Agentify Desktop to apply backend changes.' : ''}`; setChecked('setAcknowledge', false); - el('btnSaveSettings').disabled = true; + updateSaveEnabled(); + await refresh(); } catch (e) { el('settingsHint').textContent = `Save failed: ${e?.message || String(e)}`; } @@ -621,12 +710,12 @@ async function main() { b?.onTabsChanged?.(() => refresh().catch(() => {})); } catch (e) { hasLiveUpdates = false; - statusText(`Tabs listener unavailable: ${e?.message || String(e)}`); + statusText(`Live updates unavailable: ${e?.message || String(e)}. Refresh still works.`, 'warn'); setInterval(() => refresh().catch(() => {}), 3000); } } else { hasLiveUpdates = false; - statusText('Tabs listener unavailable (compat mode). Auto-refresh every 3s.'); + statusText('Live updates unavailable in this window. Refresh still works.', 'warn'); setInterval(() => refresh().catch(() => {}), 3000); } diff --git a/ui/preload.cjs b/ui/preload.cjs index 5e86d75..472fe47 100644 --- a/ui/preload.cjs +++ b/ui/preload.cjs @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld('agentifyDesktop', { createTab: (args) => ipcRenderer.invoke('agentify:createTab', args || {}), showTab: (args) => ipcRenderer.invoke('agentify:showTab', args || {}), hideTab: (args) => ipcRenderer.invoke('agentify:hideTab', args || {}), + setTabsVisible: (args) => ipcRenderer.invoke('agentify:setTabsVisible', args || {}), + showAllTabs: () => ipcRenderer.invoke('agentify:showAllTabs'), + hideAllTabs: () => ipcRenderer.invoke('agentify:hideAllTabs'), closeTab: (args) => ipcRenderer.invoke('agentify:closeTab', args || {}), stopQuery: (args) => ipcRenderer.invoke('agentify:stopQuery', args || {}), openStateDir: () => ipcRenderer.invoke('agentify:openStateDir'), diff --git a/ui/preload.mjs b/ui/preload.mjs index 754a784..e308ae2 100644 --- a/ui/preload.mjs +++ b/ui/preload.mjs @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld('agentifyDesktop', { createTab: (args) => ipcRenderer.invoke('agentify:createTab', args || {}), showTab: (args) => ipcRenderer.invoke('agentify:showTab', args || {}), hideTab: (args) => ipcRenderer.invoke('agentify:hideTab', args || {}), + setTabsVisible: (args) => ipcRenderer.invoke('agentify:setTabsVisible', args || {}), + showAllTabs: () => ipcRenderer.invoke('agentify:showAllTabs'), + hideAllTabs: () => ipcRenderer.invoke('agentify:hideAllTabs'), closeTab: (args) => ipcRenderer.invoke('agentify:closeTab', args || {}), stopQuery: (args) => ipcRenderer.invoke('agentify:stopQuery', args || {}), openStateDir: () => ipcRenderer.invoke('agentify:openStateDir'),