From 6091816f74ef58027fc655cd4a176ebe861ba0a8 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Wed, 28 Oct 2026 19:40:23 +0100 Subject: [PATCH 1/4] Switches for artifacts, integrations, messaging and Android phones Off for new installs. Migration 105 keeps a feature on where it is already in use (artifacts, API tools / MCP servers / Composio, a connected bot, a phone seen over adb), and also runs after a restore. While a feature is off, agents don't get its tools: artifact_*, android_devices_list/android_assign, API tools (MCP tools, env keys, prompt) and custom/Composio MCP servers; messaging bots stop. --- .../android/android-overview.tsx} | 0 .../artifacts/artifacts-overview.tsx} | 0 .../integrations/integrations-overview.tsx} | 0 .../messaging/messaging-overview.tsx} | 0 packages/core/src/backup/backup.ts | 4 +- packages/core/src/cloud/scope.ts | 2 +- packages/core/src/db/migrations.ts | 22 +++ packages/core/src/integrations/apiTools.ts | 4 +- packages/core/src/integrations/mcpServers.ts | 4 +- packages/core/src/mcp/tools.ts | 14 +- packages/core/src/messaging/service.ts | 11 +- packages/core/src/runner/runner.ts | 2 +- packages/core/src/services/agentSettings.ts | 2 + packages/core/src/services/artifacts.ts | 4 +- packages/core/src/services/settings.ts | 5 +- packages/core/src/services/settingsPatch.ts | 4 + packages/core/test/android.test.ts | 2 +- packages/core/test/api-tools.test.ts | 3 +- packages/core/test/artifacts.test.ts | 2 + packages/core/test/composio.test.ts | 3 +- packages/core/test/feature-switches.test.ts | 184 ++++++++++++++++++ packages/core/test/mcp-servers.test.ts | 3 +- packages/core/test/mcp.test.ts | 1 + packages/core/test/messaging.test.ts | 10 + packages/core/test/voice.test.ts | 7 +- packages/shared/src/models.ts | 22 +++ 26 files changed, 296 insertions(+), 19 deletions(-) rename apps/desktop/src/{pages/android/android-page.tsx => components/android/android-overview.tsx} (100%) rename apps/desktop/src/{pages/artifacts/artifacts-page.tsx => components/artifacts/artifacts-overview.tsx} (100%) rename apps/desktop/src/{pages/integrations/integrations-page.tsx => components/integrations/integrations-overview.tsx} (100%) rename apps/desktop/src/{pages/messaging/messaging-page.tsx => components/messaging/messaging-overview.tsx} (100%) create mode 100644 packages/core/test/feature-switches.test.ts diff --git a/apps/desktop/src/pages/android/android-page.tsx b/apps/desktop/src/components/android/android-overview.tsx similarity index 100% rename from apps/desktop/src/pages/android/android-page.tsx rename to apps/desktop/src/components/android/android-overview.tsx diff --git a/apps/desktop/src/pages/artifacts/artifacts-page.tsx b/apps/desktop/src/components/artifacts/artifacts-overview.tsx similarity index 100% rename from apps/desktop/src/pages/artifacts/artifacts-page.tsx rename to apps/desktop/src/components/artifacts/artifacts-overview.tsx diff --git a/apps/desktop/src/pages/integrations/integrations-page.tsx b/apps/desktop/src/components/integrations/integrations-overview.tsx similarity index 100% rename from apps/desktop/src/pages/integrations/integrations-page.tsx rename to apps/desktop/src/components/integrations/integrations-overview.tsx diff --git a/apps/desktop/src/pages/messaging/messaging-page.tsx b/apps/desktop/src/components/messaging/messaging-overview.tsx similarity index 100% rename from apps/desktop/src/pages/messaging/messaging-page.tsx rename to apps/desktop/src/components/messaging/messaging-overview.tsx diff --git a/packages/core/src/backup/backup.ts b/packages/core/src/backup/backup.ts index 2127233b..2db11868 100644 --- a/packages/core/src/backup/backup.ts +++ b/packages/core/src/backup/backup.ts @@ -25,7 +25,7 @@ import { config, VERSION } from "../config"; import { all, get, getDb, run as exec } from "../db"; import { recoverInterruptedRuns } from "../runner/runner"; import { leaveInterruptedWork } from "../services/resume"; -import { SPEND_BACKFILL_SQL, SPEND_TRACKING_SQL, TEAM_BACKFILL_SQL, TICKET_FACTS_SQL } from "../db/migrations"; +import { FEATURE_SWITCHES_SQL, SPEND_BACKFILL_SQL, SPEND_TRACKING_SQL, TEAM_BACKFILL_SQL, TICKET_FACTS_SQL } from "../db/migrations"; import { startBudgets, stopBudgets } from "../services/budgets"; import { repairReportingLines } from "../agents/service"; import { bus } from "../events/bus"; @@ -789,6 +789,8 @@ export function importBackup(file: Uint8Array, passphrase: string, actor = "user getDb().run(SPEND_BACKFILL_SQL); // A backup from before spend tracking: on where it had budgets. getDb().run(SPEND_TRACKING_SQL); + // A backup from before the feature switches: on for the features it uses. + getDb().run(FEATURE_SWITCHES_SQL); exec("UPDATE automation_events SET status = 'skipped', note = 'Restored from a backup' WHERE status = 'pending'"); exec("DELETE FROM followups WHERE due_at <= ?", new Date().toISOString()); // Paused runs come back paused; none continues by itself after a restore. diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts index 6469b9ed..e1f15ea0 100644 --- a/packages/core/src/cloud/scope.ts +++ b/packages/core/src/cloud/scope.ts @@ -32,7 +32,7 @@ const S: Rule = "secrets"; const refused = (message: string): Rule => ({ refused: message }); /** Settings groups a cloud user may change. Machine, access and security settings stay on the computer; a new group is refused until it is added here. */ -const CLOUD_SETTINGS_GROUPS = new Set(["general", "runner", "browser", "computer", "vm", "voice", "memory", "diagnostics", "onboardingComplete"]); +const CLOUD_SETTINGS_GROUPS = new Set(["general", "runner", "browser", "computer", "vm", "artifacts", "integrations", "messaging", "voice", "memory", "diagnostics", "onboardingComplete"]); /** The voice provider's address is where the vault's OpenAI key is sent: pointing it elsewhere needs allowSecrets. */ const settingsGroups: Rule = (body) => { diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts index d6582cd5..442a1369 100644 --- a/packages/core/src/db/migrations.ts +++ b/packages/core/src/db/migrations.ts @@ -57,6 +57,23 @@ SELECT id, agent_id, agent_name, trigger, at, MAX(0, own), duration_ms, failed F ) WHERE id NOT IN (SELECT run_id FROM spend) AND (own > 0 OR duration_ms > 0); `; +/** + * Artifacts, integrations, messaging and Android phones are off for new installs. An install that already uses one + * keeps it on: artifacts exist; API tools, MCP servers, Composio apps or a Composio key are set up; a bot is connected; + * a phone was seen (Android was on by default). A section saved before keeps its switch. Idempotent (only settings + * without the switch); also run after a restore, because a backup from before migration 105 has no switches. + * Migration 105 embeds it. + */ +export const FEATURE_SWITCHES_SQL = /* sql */ ` +INSERT OR IGNORE INTO settings (key, value) SELECT 'artifacts', '{"enabled":true}' WHERE EXISTS (SELECT 1 FROM artifacts); +INSERT OR IGNORE INTO settings (key, value) SELECT 'integrations', '{"enabled":true}' WHERE EXISTS (SELECT 1 FROM api_tools) + OR EXISTS (SELECT 1 FROM mcp_servers) OR EXISTS (SELECT 1 FROM composio_connections) OR EXISTS (SELECT 1 FROM secrets WHERE key = 'composio_api_key'); +INSERT OR IGNORE INTO settings (key, value) SELECT 'messaging', '{"enabled":true}' WHERE EXISTS (SELECT 1 FROM messaging_connections); +INSERT OR IGNORE INTO settings (key, value) SELECT 'android', '{"enabled":true}' WHERE EXISTS (SELECT 1 FROM android_devices); +UPDATE settings SET value = json_set(value, '$.enabled', json(CASE WHEN EXISTS (SELECT 1 FROM android_devices) THEN 'true' ELSE 'false' END)) +WHERE key = 'android' AND json_valid(value) AND json_type(value, '$.enabled') IS NULL; +`; + /** * Ordered, append-only SQL migrations. Never edit a shipped migration — add a new one. * JSON columns are stored as TEXT. Encrypted columns end with `_enc` and hold vault ciphertext. @@ -1714,4 +1731,9 @@ CREATE TABLE IF NOT EXISTS self_repair_tickets ( ); `, }, + { + id: 105, + name: "feature_switches", + sql: FEATURE_SWITCHES_SQL, + }, ]; diff --git a/packages/core/src/integrations/apiTools.ts b/packages/core/src/integrations/apiTools.ts index d3569182..e9be58a7 100644 --- a/packages/core/src/integrations/apiTools.ts +++ b/packages/core/src/integrations/apiTools.ts @@ -11,6 +11,7 @@ import { all, bool, get, insert, run, update } from "../db"; import { bus } from "../events/bus"; import { logger } from "../log"; import { audit } from "../services/audit"; +import { getSettings } from "../services/settings"; import * as vault from "../vault/vault"; import { badRequest, HttpError, newId, notFound, now, parseJson, slugify } from "../util"; @@ -370,10 +371,11 @@ export function apiToolInAgentScope(tool: { workspaceId: string | null; agentId: const specificity = (t: ApiTool) => (t.agentId ? 2 : t.workspaceId ? 1 : 0); /** - * Enabled tools `agent` may use, most general first. Shared (global / workspace) ones only when the agent inherits + * Enabled tools `agent` may use, most general first (none while integrations are off). Shared (global / workspace) ones only when the agent inherits * shared integrations (`inheritMcp`); tools pinned to the agent always. */ export function apiToolsForAgent(agent: Pick): ApiTool[] { + if (!getSettings().integrations.enabled) return []; return all("SELECT * FROM api_tools WHERE enabled = 1 ORDER BY name COLLATE NOCASE, created_at") .map(toModel) .filter((t) => apiToolInAgentScope(t, agent) && (t.agentId === agent.id || agent.inheritMcp)) diff --git a/packages/core/src/integrations/mcpServers.ts b/packages/core/src/integrations/mcpServers.ts index 801c2f4a..4c3d0096 100644 --- a/packages/core/src/integrations/mcpServers.ts +++ b/packages/core/src/integrations/mcpServers.ts @@ -13,6 +13,7 @@ import { all, bool, get, insert, run, update } from "../db"; import { bus } from "../events/bus"; import { logger } from "../log"; import { audit } from "../services/audit"; +import { getSettings } from "../services/settings"; import * as vault from "../vault/vault"; import { badRequest, HttpError, newId, notFound, now, parseJson, slugify } from "../util"; import { COMPOSIO_SERVER_NAMES, composioMcpServersForAgent } from "./composio"; @@ -377,13 +378,14 @@ export function mcpServerJson(server: McpServer, secrets: { env: Record> { const out: Record = {}; + if (!getSettings().integrations.enabled) return out; const taken = new Set(RESERVED_NAMES); for (const server of mcpServerRowsForAgent(agent)) { let secrets: { env: Record; headers: Record }; diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index 37a899da..6672ab19 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -920,8 +920,14 @@ function isDreamRun(ctx: RunContext): boolean { } } -/** Artifacts belong to a chat: not in condition checks, dreams or connected apps, and not on a runner (its chats show on the controller). */ -const canArtifact = (_agent: Agent, ctx: RunContext) => config().role !== "runner" && !!ctx.conversationId && !isCheckRun(ctx) && !isDreamRun(ctx); +/** + * Artifacts belong to a chat: not in condition checks, dreams or connected apps, and not on a runner (its chats show on + * the controller). Only while the human has artifacts on. + */ +const canArtifact = (_agent: Agent, ctx: RunContext) => + getSettings().artifacts.enabled && config().role !== "runner" && !!ctx.conversationId && !isCheckRun(ctx) && !isDreamRun(ctx); + +const androidOn = () => getSettings().android.enabled; /** Agents change their own artifacts; managers anyone's. */ function artifactRefusal(agent: Agent, id: string): string | null { @@ -3489,7 +3495,7 @@ Make it look like a polished 2026 product: clean layout, real hierarchy, readabl description: "Android phones connected to this computer over adb (and ones seen before): id, name, model, Android version, connected or not, battery, and which agents and chats control them. Also phones waiting for the human to allow USB debugging.", schema: z.object({}), - when: isManager, + when: (agent) => isManager(agent) && androidOn(), run: async () => { const [status, devices] = await Promise.all([androidStatus(), freshDevices()]); if (!status.adb.installed) return "adb isn't installed on this computer; the human can install it on the Android phones page."; @@ -3517,7 +3523,7 @@ Make it look like a polished 2026 product: clean layout, real hierarchy, readabl deviceId: z.string(), agentId: z.string().describe("The agent that gets the phone"), }), - when: managesSetup, + when: (agent) => managesSetup(agent) && androidOn(), run: async ({ deviceId, agentId }, { agent, ctx }) => { if (lockedVm(ctx)) return fail("This task runs in a virtual machine and is kept off the human's computer, where the phones are plugged in."); if (agentId === agent.id) return fail("You can't give yourself a phone. Ask the human: they pick phones for a chat in its message box, or for an agent in its settings."); diff --git a/packages/core/src/messaging/service.ts b/packages/core/src/messaging/service.ts index 618aa98e..f094bd60 100644 --- a/packages/core/src/messaging/service.ts +++ b/packages/core/src/messaging/service.ts @@ -26,6 +26,7 @@ import { all, bool, get, insert, run as exec, update } from "../db"; import { bus } from "../events/bus"; import { logger } from "../log"; import { audit } from "../services/audit"; +import { getSettings } from "../services/settings"; import { notify } from "../services/notifications"; import * as vault from "../vault/vault"; import { sha256 } from "../vault/crypto"; @@ -111,6 +112,8 @@ const notified = new Map(); let unsubscribe: (() => void) | null = null; let syncing: Promise | null = null; let syncAgain = false; +/** The messaging switch (Settings → Messaging) as the last sync saw it: off, no bot runs. */ +let switchedOn: boolean | null = null; const secretsContext = (id: string) => `messaging_connections.secrets:${id}`; @@ -598,17 +601,18 @@ async function stopRuntime(id: string) { } async function syncOnce() { + const on = (switchedOn = getSettings().messaging.enabled); const rows = all("SELECT * FROM messaging_connections"); const unlocked = vault.isUnlocked(); - const wanted = new Map(rows.filter((r) => bool(r.enabled) && unlocked).map((r) => [r.id, r])); + const wanted = new Map(rows.filter((r) => on && bool(r.enabled) && unlocked).map((r) => [r.id, r])); // Keep running adapters when the vault locks: their tokens are already in memory, and people keep talking. for (const id of [...runtimes.keys()]) { const row = rows.find((r) => r.id === id); - if (!row || !bool(row.enabled) || (unlocked && runtimes.get(id)!.fingerprint !== fingerprintOf(row))) await stopRuntime(id); + if (!on || !row || !bool(row.enabled) || (unlocked && runtimes.get(id)!.fingerprint !== fingerprintOf(row))) await stopRuntime(id); } for (const id of wanted.keys()) { const row = connectionRow(id); - if (runtimes.has(id) || !row || !bool(row.enabled)) continue; + if (!getSettings().messaging.enabled || runtimes.has(id) || !row || !bool(row.enabled)) continue; try { await startRuntime(row); startErrors.delete(id); @@ -644,6 +648,7 @@ function requestSync() { function onBusEvent(event: ServerEvent) { if (event.type === "vault.status") requestSync(); + if (event.type === "entity.changed" && event.entity === "settings" && getSettings().messaging.enabled !== switchedOn) requestSync(); if (event.type === "question.updated" && event.question.answer) { void followAnsweredRun(event.question).catch((err) => log.warn("could not follow an answered run", err)); } diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index 70c4b6dd..eca9e27b 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -2215,7 +2215,7 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise = { browser: ["headless", "keepAliveMinutes", "stealth", "muteAudio"], computer: ["agentCursor", "liveViewFps", "screenshotMaxSize"], vm: ["onQuit", "idleStopMinutes"], + // A dashboard the human asks for needs artifacts; integrations, messaging and phones reach outside: the human's. + artifacts: ["enabled"], voice: ["enabled", "autoSpeak", "language", "sttModel", "ttsModel", "ttsVoice", "elevenlabsVoiceId", "browserVoice", "rate"], // Agents may set calls up (switch, number, the owner's phone); quiet hours, model, length and what the voice tells // callers stay the human's. diff --git a/packages/core/src/services/artifacts.ts b/packages/core/src/services/artifacts.ts index 75be0d23..599d2dd4 100644 --- a/packages/core/src/services/artifacts.ts +++ b/packages/core/src/services/artifacts.ts @@ -19,9 +19,10 @@ import type { import { MAX_ARTIFACT_DATA, MAX_ARTIFACT_HTML, MAX_ARTIFACT_VERSIONS } from "@godmode/shared"; import { all, get, insert, run as sql, tx } from "../db"; import { bus } from "../events/bus"; -import { badRequest, newId, notFound, now, parseJson } from "../util"; +import { badRequest, conflict, newId, notFound, now, parseJson } from "../util"; import { getDefaultAgentId } from "../agents/service"; import { audit } from "./audit"; +import { getSettings } from "./settings"; import { conversationExists, startChat } from "./conversations"; import { hasQueued, submitMessage } from "./messageQueue"; import { activeRunForConversation } from "../runner/runner"; @@ -377,6 +378,7 @@ export async function refreshArtifact(id: string): Promise { + if (!getSettings().artifacts.enabled) throw conflict("Artifacts are turned off. Turn them on in Settings → Artifacts."); const prompt = input.prompt.trim(); if (!prompt) throw badRequest("Describe what to build"); const started = await startChat({ diff --git a/packages/core/src/services/settings.ts b/packages/core/src/services/settings.ts index dbed9af2..49102cb5 100644 --- a/packages/core/src/services/settings.ts +++ b/packages/core/src/services/settings.ts @@ -68,10 +68,13 @@ export const DEFAULT_SETTINGS: Settings = { tartPath: "", }, android: { - enabled: true, + enabled: false, vaultFill: false, adbPath: "", }, + artifacts: { enabled: false }, + integrations: { enabled: false }, + messaging: { enabled: false }, voice: { enabled: true, sttProvider: "browser", diff --git a/packages/core/src/services/settingsPatch.ts b/packages/core/src/services/settingsPatch.ts index 6df74475..231c0725 100644 --- a/packages/core/src/services/settingsPatch.ts +++ b/packages/core/src/services/settingsPatch.ts @@ -114,6 +114,10 @@ export function checkSettingsPatch(patch: Record, needsGrant: ( throw badRequest("The adb binary must be an absolute path (or empty)"); } } + for (const feature of ["artifacts", "integrations", "messaging"] as const) { + const value = patch[feature] as Record | undefined; + if (isObject(value) && value.enabled !== undefined && typeof value.enabled !== "boolean") throw badRequest(`${feature}.enabled must be true or false`); + } const maintenance = patch.maintenance as Record | undefined; if (maintenance !== undefined) { if (typeof maintenance !== "object" || maintenance === null || Array.isArray(maintenance)) throw badRequest("Invalid upkeep settings"); diff --git a/packages/core/test/android.test.ts b/packages/core/test/android.test.ts index b34b69c6..150b9f0f 100644 --- a/packages/core/test/android.test.ts +++ b/packages/core/test/android.test.ts @@ -73,7 +73,7 @@ beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), "godmode-fake-adb-")); process.env.FAKE_ADB_DIR = dir; writeState("", {}); - updateSettings({ android: { adbPath: FAKE_ADB } }); + updateSettings({ android: { adbPath: FAKE_ADB, enabled: true } }); agent = await makeAgent({ name: "Phone Operator" }); }); diff --git a/packages/core/test/api-tools.test.ts b/packages/core/test/api-tools.test.ts index 3199ad74..0c57efca 100644 --- a/packages/core/test/api-tools.test.ts +++ b/packages/core/test/api-tools.test.ts @@ -13,7 +13,7 @@ import { createWorkspace, deleteWorkspace } from "../src/services/workspaces"; import { updateAgent } from "../src/agents/service"; import { buildEnv } from "../src/runner/runner"; import { buildSystemPrompt, resumeContextPrefix } from "../src/runner/prompt"; -import { getSettings } from "../src/services/settings"; +import { getSettings, updateSettings } from "../src/services/settings"; import { apiToolEnv, apiToolKey, @@ -78,6 +78,7 @@ async function gateway(agent: Agent, name: string, args: unknown = {}) { beforeAll(async () => { env = await setupEnv("godmode-api-tools-"); await vault.setup(PASSPHRASE, false); + updateSettings({ integrations: { enabled: true } }); api = Bun.serve({ hostname: "127.0.0.1", port: 0, diff --git a/packages/core/test/artifacts.test.ts b/packages/core/test/artifacts.test.ts index c77e8bbe..d2b256e1 100644 --- a/packages/core/test/artifacts.test.ts +++ b/packages/core/test/artifacts.test.ts @@ -6,6 +6,7 @@ import { deleteConversation, getConversation, startChat } from "../src/services/ import { applyEdits, getArtifact, listArtifacts, listArtifactVersions } from "../src/services/artifacts"; import { listActiveRuns, listRuns, waitForRun } from "../src/runner/runner"; import { getAccessToken } from "../src/server/auth"; +import { updateSettings } from "../src/services/settings"; let env: TestEnv; let agent: Agent; @@ -13,6 +14,7 @@ let other: Agent; beforeAll(async () => { env = await setupEnv("godmode-artifacts-"); + updateSettings({ artifacts: { enabled: true } }); agent = await makeAgent({ name: "Builder" }); other = await makeAgent({ name: "Other" }); }); diff --git a/packages/core/test/composio.test.ts b/packages/core/test/composio.test.ts index 91149b9b..f05604a6 100644 --- a/packages/core/test/composio.test.ts +++ b/packages/core/test/composio.test.ts @@ -7,7 +7,7 @@ import { Hono } from "hono"; import { loadConfig } from "../src/config"; import { closeDb, get, getMeta, insert, openDb, run } from "../src/db"; import { setLogLevel } from "../src/log"; -import { resetSettingsCache } from "../src/services/settings"; +import { resetSettingsCache, updateSettings } from "../src/services/settings"; import * as vault from "../src/vault/vault"; import * as composio from "../src/integrations/composio"; import { createMcpServer, mcpServersForAgent } from "../src/integrations/mcpServers"; @@ -122,6 +122,7 @@ beforeAll(async () => { composio.resetComposioState(); vault.lock(); await vault.setup(PASSPHRASE, false); + updateSettings({ integrations: { enabled: true } }); const ts = new Date().toISOString(); insert("workspaces", { id: "ws_sales", name: "Sales", slug: "sales", created_at: ts, updated_at: ts }); insert("agents", { id: "agt_x", workspace_id: "ws_sales", name: "X", slug: "x", repo_path: join(dataDir, "agents", "x"), created_at: ts, updated_at: ts }); diff --git a/packages/core/test/feature-switches.test.ts b/packages/core/test/feature-switches.test.ts new file mode 100644 index 00000000..5b72629d --- /dev/null +++ b/packages/core/test/feature-switches.test.ts @@ -0,0 +1,184 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Agent } from "@godmode/shared"; +import { makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness"; +import { getDb, insert, run as sql } from "../src/db"; +import { FEATURE_SWITCHES_SQL, MIGRATIONS } from "../src/db/migrations"; +import { getAccessToken } from "../src/server/auth"; +import { getSettings, resetSettingsCache, updateSettings } from "../src/services/settings"; +import { callTool, listToolsFor } from "../src/mcp/tools"; +import { createApiTool, deleteApiTool } from "../src/integrations/apiTools"; +import { mcpServersForAgent } from "../src/integrations/mcpServers"; +import * as vault from "../src/vault/vault"; +import { newId, now } from "../src/util"; + +let env: TestEnv; +let manager: Agent; + +const FEATURES = ["artifacts", "integrations", "messaging", "android"] as const; +type Feature = (typeof FEATURES)[number]; + +const api = (method: string, path: string, body?: unknown) => + fetch(`${env.baseUrl}${path}`, { + method, + headers: { Authorization: `Bearer ${getAccessToken()}`, "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + +const switches = () => Object.fromEntries(FEATURES.map((f) => [f, getSettings()[f].enabled])) as Record; +const allOff = { artifacts: false, integrations: false, messaging: false, android: false }; + +/** Runs the migration again on whatever the tables hold now, the way an upgrade or a restore does. */ +function migrate(): Record { + getDb().run(FEATURE_SWITCHES_SQL); + resetSettingsCache(); + return switches(); +} + +function clear() { + for (const table of ["artifacts", "api_tools", "mcp_servers", "composio_connections", "messaging_connections", "android_devices"]) sql(`DELETE FROM ${table}`); + sql("DELETE FROM secrets WHERE key = 'composio_api_key'"); + sql("DELETE FROM settings WHERE key IN ('artifacts', 'integrations', 'messaging', 'android')"); + resetSettingsCache(); +} + +function chatRun(agent: Agent) { + const conversationId = newId("cnv"); + insert("conversations", { id: conversationId, agent_id: agent.id, title: "x", origin: "chat", created_at: now(), updated_at: now() }); + const runId = newId("run"); + insert("runs", { id: runId, agent_id: agent.id, conversation_id: conversationId, trigger: "chat", status: "running", prompt: "x", created_at: now() }); + return { runId, agentId: agent.id, conversationId, workspaceId: null, depth: 0 }; +} + +const toolNames = (agent: Agent) => listToolsFor(agent, chatRun(agent)).map((t) => t.name); + +beforeAll(async () => { + env = await setupEnv("godmode-features-"); + await vault.setup("correct horse battery staple", false); + manager = await makeAgent({ name: "Boss", permissions: { canManageAgents: true, allowDelegation: true } }); +}); + +afterAll(async () => { + await env.close(); +}); + +describe("new installs", () => { + test("start with every optional feature off", () => { + clear(); + expect(switches()).toEqual(allOff); + expect(migrate()).toEqual(allOff); + }); +}); + +describe("existing installs", () => { + test("migration 105 is the backfill", () => { + expect(MIGRATIONS.find((m) => m.name === "feature_switches")!.sql).toBe(FEATURE_SWITCHES_SQL); + expect(MIGRATIONS.find((m) => m.name === "feature_switches")!.id).toBe(105); + }); + + test("keep artifacts on when there are artifacts", () => { + clear(); + insert("artifacts", { id: "art_1", title: "Sales", html: "

hi

", created_at: now(), updated_at: now() }); + expect(migrate()).toEqual({ ...allOff, artifacts: true }); + }); + + test("keep integrations on for API tools, MCP servers, Composio apps or a Composio key", () => { + const cases: [string, () => void][] = [ + ["api tool", () => insert("api_tools", { id: "api_1", name: "Gemini", created_at: now(), updated_at: now() })], + ["mcp server", () => insert("mcp_servers", { id: "mcp_1", name: "Linear", created_at: now(), updated_at: now() })], + ["composio app", () => insert("composio_connections", { id: "cmp_1", connected_account_id: "ca_1", toolkit: "gmail", user_id: "u", created_at: now(), updated_at: now() })], + ["composio key", () => insert("secrets", { key: "composio_api_key", value_enc: "sealed", updated_at: now() })], + ]; + for (const [what, seed] of cases) { + clear(); + seed(); + expect({ what, ...migrate() }).toEqual({ what, ...allOff, integrations: true }); + } + }); + + test("keep messaging on when a bot is connected", () => { + clear(); + insert("messaging_connections", { id: "msg_1", provider: "telegram", name: "Bot", created_at: now(), updated_at: now() }); + expect(migrate()).toEqual({ ...allOff, messaging: true }); + }); + + test("keep Android on when a phone was seen", () => { + clear(); + insert("android_devices", { id: "adr_1", serial: "R58M", name: "Pixel", created_at: now(), updated_at: now() }); + expect(migrate()).toEqual({ ...allOff, android: true }); + expect(getSettings().android.adbPath).toBe(""); + }); + + test("a switch the human set stays as it is, and the backfill is idempotent", () => { + clear(); + insert("artifacts", { id: "art_1", title: "Sales", html: "

hi

", created_at: now(), updated_at: now() }); + insert("android_devices", { id: "adr_1", serial: "R58M", name: "Pixel", created_at: now(), updated_at: now() }); + updateSettings({ artifacts: { enabled: false }, android: { enabled: false, adbPath: "/opt/adb" } }); + expect(migrate()).toEqual(allOff); + expect(migrate()).toEqual(allOff); + expect(getSettings().android.adbPath).toBe("/opt/adb"); + }); + + test("an Android section saved without the switch gets it from the phones seen", () => { + clear(); + sql("INSERT INTO settings (key, value) VALUES ('android', ?)", JSON.stringify({ adbPath: "/opt/adb" })); + expect(migrate().android).toBe(false); + sql("DELETE FROM settings WHERE key = 'android'"); + sql("INSERT INTO settings (key, value) VALUES ('android', ?)", JSON.stringify({ adbPath: "/opt/adb" })); + insert("android_devices", { id: "adr_1", serial: "R58M", name: "Pixel", created_at: now(), updated_at: now() }); + expect(migrate().android).toBe(true); + }); +}); + +describe("agents only get a feature's tools while it is on", () => { + test("artifacts", async () => { + clear(); + expect(toolNames(manager).filter((n) => n.startsWith("artifact_"))).toEqual([]); + const refused = await callTool(chatRun(manager), "artifact_list", {}); + expect(refused.isError).toBe(true); + updateSettings({ artifacts: { enabled: true } }); + expect(toolNames(manager).filter((n) => n.startsWith("artifact_")).sort()).toEqual(["artifact_create", "artifact_get", "artifact_list", "artifact_update"]); + }); + + test("Android phones", () => { + clear(); + expect(toolNames(manager).filter((n) => n.startsWith("android_"))).toEqual([]); + updateSettings({ android: { enabled: true } }); + expect(toolNames(manager).filter((n) => n.startsWith("android_")).sort()).toEqual(["android_assign", "android_devices_list"]); + }); + + test("integrations: API tools and MCP servers", async () => { + clear(); + const tool = createApiTool({ name: "Gemini", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "gemini-key-123" }); + insert("mcp_servers", { id: "mcp_1", name: "Linear", transport: "http", url: "https://mcp.linear.app/mcp", created_at: now(), updated_at: now() }); + try { + expect(toolNames(manager).filter((n) => n.startsWith("api_tool"))).toEqual([]); + expect(await mcpServersForAgent(manager)).toEqual({}); + updateSettings({ integrations: { enabled: true } }); + expect(toolNames(manager).filter((n) => n.startsWith("api_tool")).sort()).toEqual(["api_tool_docs", "api_tool_request", "api_tools_list"]); + expect(Object.keys(await mcpServersForAgent(manager))).toEqual(["linear"]); + } finally { + deleteApiTool(tool.id); + } + }); +}); + +describe("the human switches them", () => { + test("in the settings, as true or false", async () => { + clear(); + for (const feature of ["artifacts", "integrations", "messaging"] as const) { + expect((await api("PUT", "/api/settings", { [feature]: { enabled: "yes" } })).status).toBe(400); + expect((await api("PUT", "/api/settings", { [feature]: { enabled: true } })).status).toBe(200); + expect(getSettings()[feature].enabled).toBe(true); + } + const boot = (await (await api("GET", "/api/bootstrap")).json()) as { settings: Record }; + expect(boot.settings.artifacts.enabled).toBe(true); + expect(boot.settings.messaging.enabled).toBe(true); + }); + + test("starting an artifact needs artifacts on", async () => { + clear(); + const off = await api("POST", "/api/artifacts/start", { prompt: "A sales dashboard" }); + expect(off.status).toBe(409); + expect(((await off.json()) as { error: string }).error).toContain("Settings → Artifacts"); + }); +}); diff --git a/packages/core/test/mcp-servers.test.ts b/packages/core/test/mcp-servers.test.ts index d98c7413..34902ea4 100644 --- a/packages/core/test/mcp-servers.test.ts +++ b/packages/core/test/mcp-servers.test.ts @@ -7,7 +7,7 @@ import { SECRET_MASK } from "@godmode/shared"; import { loadConfig } from "../src/config"; import { closeDb, get, insert, openDb } from "../src/db"; import { setLogLevel } from "../src/log"; -import { resetSettingsCache } from "../src/services/settings"; +import { resetSettingsCache, updateSettings } from "../src/services/settings"; import * as vault from "../src/vault/vault"; import { createMcpServer, @@ -104,6 +104,7 @@ beforeAll(async () => { resetComposioState(); vault.lock(); await vault.setup(PASSPHRASE, false); + updateSettings({ integrations: { enabled: true } }); seedWorkspace("ws_one"); seedWorkspace("ws_two"); seedAgent("agt_a", "ws_one"); diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index b8b3d138..e568a93c 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -1554,6 +1554,7 @@ describe("settings tools", () => { "computer.screenshotMaxSize", "vm.onQuit", "vm.idleStopMinutes", + "artifacts.enabled", "voice.enabled", "voice.autoSpeak", "voice.language", diff --git a/packages/core/test/messaging.test.ts b/packages/core/test/messaging.test.ts index 27695f64..87b58964 100644 --- a/packages/core/test/messaging.test.ts +++ b/packages/core/test/messaging.test.ts @@ -6,6 +6,7 @@ import { all, get } from "../src/db"; import { getRun, waitForRun } from "../src/runner/runner"; import { openQuestionOf, listQuestions } from "../src/services/questions"; import * as vault from "../src/vault/vault"; +import { updateSettings } from "../src/services/settings"; import { createApiTool, deleteApiTool } from "../src/integrations/apiTools"; import { issueGrant } from "../src/server/grants"; import { createApp } from "../src/server/app"; @@ -161,6 +162,7 @@ beforeAll(async () => { installFetchMock(); fakeTelegram(); fakeElevenLabs(); + updateSettings({ messaging: { enabled: true }, integrations: { enabled: true } }); startMessaging(); }); @@ -243,6 +245,14 @@ describe("Telegram bot", () => { expect(row.secrets_enc).not.toContain(TG_TOKEN); }); + test("turning messaging off stops the bots, turning it on starts them again", async () => { + const { runtimeOf } = await import("../src/messaging/service"); + updateSettings({ messaging: { enabled: false } }); + await until(() => runtimeOf(connectionId) === null, 5000, "bot stopped"); + updateSettings({ messaging: { enabled: true } }); + await until(() => runtimeOf(connectionId) !== null && listConnections()[0]?.status.state === "connected", 5000, "bot running again"); + }); + test("the same bot can't be connected twice", async () => { await expect(createConnection({ credentials: { provider: "telegram", botToken: TG_TOKEN }, agentIds: [helper.id] })).rejects.toThrow(/already connected/); }); diff --git a/packages/core/test/voice.test.ts b/packages/core/test/voice.test.ts index 7c83ea4b..20cb340a 100644 --- a/packages/core/test/voice.test.ts +++ b/packages/core/test/voice.test.ts @@ -262,6 +262,7 @@ describe("transcribeRecording (voice messages)", () => { test("an API tool the agent may use lends its key, only to the provider's own address", async () => { const elsewhere = createApiTool({ name: "Proxy", baseUrl: "https://api.openai.com.example.net", apiKey: "sk-proxy-123456" }); const insecure = createApiTool({ name: "Plain", baseUrl: "http://api.openai.com", apiKey: "sk-plain-123456" }); + updateSettings({ integrations: { enabled: true } }); try { await expectHttpError(() => transcribeRecording(audio, { agent }), 400); expect(captured).toHaveLength(0); @@ -272,9 +273,12 @@ describe("transcribeRecording (voice messages)", () => { expect(captured[0]!.url).toBe("https://api.elevenlabs.io/v1/speech-to-text"); expect(captured[0]!.headers.get("xi-api-key")).toBe("xi-tool-key-123"); - // Nobody to borrow from: no agent, or an agent that doesn't get shared tools. + // Nobody to borrow from: no agent, an agent that doesn't get shared tools, or integrations turned off. await expectHttpError(() => transcribeRecording(audio), 400); await expectHttpError(() => transcribeRecording(audio, { agent: { ...agent, inheritMcp: false } }), 400); + updateSettings({ integrations: { enabled: false } }); + await expectHttpError(() => transcribeRecording(audio, { agent }), 400); + updateSettings({ integrations: { enabled: true } }); expect(captured).toHaveLength(1); deleteApiTool(tool.id); @@ -289,6 +293,7 @@ describe("transcribeRecording (voice messages)", () => { } finally { deleteApiTool(elsewhere.id); deleteApiTool(insecure.id); + updateSettings({ integrations: { enabled: false } }); } }); diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 129bcd96..7d0608b7 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -1537,6 +1537,21 @@ export interface VmSettings { tartPath: string; } +/** Small web apps agents build in a chat (artifacts.ts): the Artifacts page and the artifact_* tools. */ +export interface ArtifactSettings { + enabled: boolean; +} + +/** Composio apps, API tools and MCP servers: agents get them only while this is on. */ +export interface IntegrationSettings { + enabled: boolean; +} + +/** Bots on Slack, Telegram and Teams that people use to talk to agents: they only run while this is on. */ +export interface MessagingSettings { + enabled: boolean; +} + export interface AndroidSettings { /** Godmode looks for Android phones connected over adb, and agents may control the phones assigned to them. */ enabled: boolean; @@ -1606,6 +1621,13 @@ export interface Settings { computer: ComputerSettings; vm: VmSettings; android: AndroidSettings; + /** + * Optional features, off for new installs (Settings → Features). An install that already used one before the switch + * existed starts with it on (migration 105). + */ + artifacts: ArtifactSettings; + integrations: IntegrationSettings; + messaging: MessagingSettings; voice: VoiceSettings; security: SecuritySettings; server: ServerSettings; From b4a130cc288bf994a84733c7d1b92785c7e2f11c Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Wed, 28 Oct 2026 19:40:23 +0100 Subject: [PATCH 2/4] Move optional features into Settings, one Passwords screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artifacts, Integrations, Messaging and Android phones live in Settings → Features with an on/off switch each; their old URLs redirect there. API keys get their own Settings section. Logins, 2FA codes and passkeys share one Passwords screen with tabs; the sidebar keeps Passwords and Cards in the main list and drops the Access section. --- apps/desktop/src/App.tsx | 23 +- .../src/components/agents/agent-form.tsx | 37 ++-- .../src/components/android/android-chip.tsx | 8 +- .../components/android/android-overview.tsx | 58 ++--- .../src/components/android/phone-panel.tsx | 2 +- .../components/artifacts/artifact-view.tsx | 2 +- .../artifacts/artifacts-overview.tsx | 84 ++++---- .../automations/app-trigger-fields.tsx | 4 +- .../components/browser/profile-use-card.tsx | 2 +- apps/desktop/src/components/chat/turn-end.tsx | 2 +- .../integrations/integrations-overview.tsx | 75 +++---- .../src/components/layout/app-shell.tsx | 54 +++-- .../components/layout/claude-signed-out.tsx | 2 +- .../src/components/layout/command-palette.tsx | 48 +++-- .../components/layout/shortcuts-dialog.tsx | 2 +- .../messaging/messaging-overview.tsx | 22 +- .../components/settings/android-section.tsx | 200 +++++++++--------- .../components/settings/api-keys-section.tsx | 13 ++ .../components/settings/artifacts-section.tsx | 25 +++ .../src/components/settings/calls-section.tsx | 2 +- .../components/settings/feature-section.tsx | 88 ++++++++ .../settings/integrations-section.tsx | 25 +++ .../components/settings/messaging-section.tsx | 25 +++ .../src/components/settings/voice-section.tsx | 2 +- .../src/components/vault/passwords-header.tsx | 57 +++++ apps/desktop/src/lib/mode.ts | 5 - .../src/pages/android/android-device-page.tsx | 4 +- .../src/pages/artifacts/artifact-page.tsx | 4 +- .../src/pages/settings/settings-page.tsx | 49 ++++- apps/desktop/src/pages/vault/logins-page.tsx | 8 +- .../desktop/src/pages/vault/passkeys-page.tsx | 8 +- apps/desktop/src/pages/vault/totp-page.tsx | 8 +- 32 files changed, 592 insertions(+), 356 deletions(-) create mode 100644 apps/desktop/src/components/settings/api-keys-section.tsx create mode 100644 apps/desktop/src/components/settings/artifacts-section.tsx create mode 100644 apps/desktop/src/components/settings/feature-section.tsx create mode 100644 apps/desktop/src/components/settings/integrations-section.tsx create mode 100644 apps/desktop/src/components/settings/messaging-section.tsx create mode 100644 apps/desktop/src/components/vault/passwords-header.tsx diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 673e9750..aac1d7ee 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -25,7 +25,6 @@ const AgentsPage = lazy(() => import("@/pages/agents/agents-page")); const AgentNewPage = lazy(() => import("@/pages/agents/agent-new")); const AgentDetailPage = lazy(() => import("@/pages/agents/agent-detail")); const FilesPage = lazy(() => import("@/pages/files/files-page")); -const ArtifactsPage = lazy(() => import("@/pages/artifacts/artifacts-page")); const ArtifactPage = lazy(() => import("@/pages/artifacts/artifact-page")); const AutomationsPage = lazy(() => import("@/pages/automations/automations-page")); const PluginsPage = lazy(() => import("@/pages/plugins/plugins-page")); @@ -36,13 +35,10 @@ const LoginsPage = lazy(() => import("@/pages/vault/logins-page")); const TotpPage = lazy(() => import("@/pages/vault/totp-page")); const CardsPage = lazy(() => import("@/pages/vault/cards-page")); const PasskeysPage = lazy(() => import("@/pages/vault/passkeys-page")); -const IntegrationsPage = lazy(() => import("@/pages/integrations/integrations-page")); -const MessagingPage = lazy(() => import("@/pages/messaging/messaging-page")); const BrowserPage = lazy(() => import("@/pages/browser/browser-page")); const ComputerPage = lazy(() => import("@/pages/computer/computer-page")); const VmsPage = lazy(() => import("@/pages/vms/vms-page")); const SshPage = lazy(() => import("@/pages/ssh/ssh-page")); -const AndroidPage = lazy(() => import("@/pages/android/android-page")); const AndroidDevicePage = lazy(() => import("@/pages/android/android-device-page")); const CallsPage = lazy(() => import("@/pages/calls/calls-page")); const RunnersPage = lazy(() => import("@/pages/runners/runners-page")); @@ -204,8 +200,8 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> + : } /> } /> } /> } /> @@ -221,14 +217,14 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> } /> - } /> - } /> + } /> + : } /> } /> } /> } /> @@ -275,6 +271,13 @@ function NotFoundRedirect() { /** Links from before Mods were Plugins (notifications, chat notes): /mods?mod= → /plugins?plugin=. */ /** My tasks became the Inbox's board: old links (notifications, tickets) open the same task there. */ +/** A page that moved into Settings: its old address, with its query (?tab=, ?connection=), opens the section. */ +function ToSettings({ section }: { section: string }) { + const { search } = useLocation(); + if (section === "integrations" && new URLSearchParams(search).get("tab") === "api-keys") return ; + return ; +} + function MyTasksRedirect() { const params = new URLSearchParams(useLocation().search); if (!params.has("task")) params.set("view", "board"); diff --git a/apps/desktop/src/components/agents/agent-form.tsx b/apps/desktop/src/components/agents/agent-form.tsx index f7c7b884..bcb1c5de 100644 --- a/apps/desktop/src/components/agents/agent-form.tsx +++ b/apps/desktop/src/components/agents/agent-form.tsx @@ -367,7 +367,10 @@ export function AgentForm({ const inherited = useInheritedInstructions(values.workspaceId, undefined, values.projectId); const vmChoices = useVmChoices(); const androidOn = !!boot?.settings.android.enabled; - const sections = SECTIONS.filter((s) => (s.id !== "vm" || vmChoices.available) && (s.id !== "android" || androidOn)); + const integrationsOn = !!boot?.settings.integrations.enabled; + const sections = SECTIONS.filter( + (s) => (s.id !== "vm" || vmChoices.available) && (s.id !== "android" || androidOn) && (s.id !== "tools" || integrationsOn), + ); const { hash } = useLocation(); // Again once the VM or Android section shows up: they wait for the VM status and the settings (links from the VMs page go to #vm). @@ -761,20 +764,22 @@ export function AgentForm({ )} - -
- } - title="Inherit shared integrations" - description="Also use the global and workspace tools, MCP servers and Composio apps." - checked={values.inheritMcp} - onChange={(v) => set("inheritMcp", v)} - /> - - set("mcpServerIds", v)} /> -
-
+ {integrationsOn && ( + +
+ } + title="Inherit shared integrations" + description="Also use the global and workspace tools, MCP servers and Composio apps." + checked={values.inheritMcp} + onChange={(v) => set("inheritMcp", v)} + /> + + set("mcpServerIds", v)} /> +
+
+ )}
- + Manage tools
diff --git a/apps/desktop/src/components/android/android-chip.tsx b/apps/desktop/src/components/android/android-chip.tsx index f4e126db..a0ebf9ce 100644 --- a/apps/desktop/src/components/android/android-chip.tsx +++ b/apps/desktop/src/components/android/android-chip.tsx @@ -114,7 +114,7 @@ export function AndroidChip({ {!isLoading && devices.length === 0 ? (

No phones yet.

-
@@ -160,7 +160,7 @@ export function AndroidChip({
-
@@ -180,7 +180,7 @@ export function AndroidField({ value, onChange }: { value: string[]; onChange: (

No phones yet — connect one and this agent can use it.

@@ -194,7 +194,7 @@ export function AndroidField({ value, onChange }: { value: string[]; onChange: ( Can control - + Manage phones diff --git a/apps/desktop/src/components/android/android-overview.tsx b/apps/desktop/src/components/android/android-overview.tsx index 955e2828..c76a2941 100644 --- a/apps/desktop/src/components/android/android-overview.tsx +++ b/apps/desktop/src/components/android/android-overview.tsx @@ -7,13 +7,13 @@ import type { AndroidDevice } from "@godmode/shared"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; -import { EmptyState, PageBody, PageHeader } from "@/components/common"; +import { EmptyState } from "@/components/common"; import { ConnectWifiDialog, EditPhoneDialog, ForgetPhoneDialog } from "@/components/android/android-dialogs"; import { CONNECTION_LABEL } from "@/components/android/android-parts"; import { DeviceCard } from "@/components/android/device-card"; import { toastApiError } from "@/components/vault/vault-utils"; import { api, errorMessage } from "@/lib/api"; -import { useAndroidDevices, useAndroidStatus, useSettings } from "@/lib/hooks"; +import { useAndroidDevices, useAndroidStatus } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; const STEPS = [ @@ -38,7 +38,7 @@ function SetupSteps() { function DeviceGrid({ devices, onEdit, onForget }: { devices: AndroidDevice[]; onEdit: (d: AndroidDevice) => void; onForget: (d: AndroidDevice) => void }) { return ( -
+
{devices.map((device, i) => ( (null); @@ -86,35 +85,24 @@ export default function AndroidPage() { const adb = status.data?.adb; const pending = status.data?.pending ?? []; - const off = settings?.android.enabled === false; const connected = devices.filter((d) => d.state === "connected").length; return ( -
- } - title="Android phones" - description="Phones connected to this computer over USB or Wi-Fi. Agents see the screen, tap, type, open apps and run commands — you watch live and can take over any time." - actions={ - <> - - - - } - /> - - {off ? ( - } - title="Android phones are turned off" - description="Turn them on in Settings → Android phones to let Godmode look for phones and agents control them." - /> - ) : status.isLoading || list.isLoading ? ( -
+
+
+

{devices.length ? `${connected} of ${devices.length} connected` : "Phones"}

+
+ + +
+
+
+ {status.isLoading || list.isLoading ? ( +
{[0, 1].map((i) => ( ))} @@ -126,7 +114,7 @@ export default function AndroidPage() { description={ status.data?.canInstall ? "adb is Android's free debugging bridge from Google. Godmode installs its own copy (about 16 MB) — nothing else on your computer changes." - : "Install Android's platform-tools (adb) and make sure it's on your PATH, or set its path in Settings → Android phones." + : "Install Android's platform-tools (adb) and make sure it's on your PATH, or set its path below." } action={ status.data?.canInstall && ( @@ -196,10 +184,6 @@ export default function AndroidPage() { /> ) : ( <> -

- {connected} of {devices.length} connected · adb {adb?.version ?? ""} - {adb?.managed ? " (Godmode's copy)" : ""} -

d.state !== "disconnected")} onEdit={setEditing} onForget={setForgetting} /> {devices.some((d) => d.state === "disconnected") && (
@@ -211,7 +195,7 @@ export default function AndroidPage() { )}
)} - +
setEditing(null)} /> diff --git a/apps/desktop/src/components/android/phone-panel.tsx b/apps/desktop/src/components/android/phone-panel.tsx index 1c23ddee..5ab4cb6f 100644 --- a/apps/desktop/src/components/android/phone-panel.tsx +++ b/apps/desktop/src/components/android/phone-panel.tsx @@ -131,7 +131,7 @@ export function PhonePanel({ phones, activity, onHide }: { phones: AndroidDevice
diff --git a/apps/desktop/src/components/artifacts/artifact-view.tsx b/apps/desktop/src/components/artifacts/artifact-view.tsx index 2bf1d8c6..60c542fa 100644 --- a/apps/desktop/src/components/artifacts/artifact-view.tsx +++ b/apps/desktop/src/components/artifacts/artifact-view.tsx @@ -383,7 +383,7 @@ export function ArtifactView({ onOpenChange={setDeleting} onDeleted={() => { onClose?.(); - if (variant === "page") actions.navigate("/artifacts"); + if (variant === "page") actions.navigate("/settings/artifacts"); }} />
diff --git a/apps/desktop/src/components/artifacts/artifacts-overview.tsx b/apps/desktop/src/components/artifacts/artifacts-overview.tsx index 9bd534b4..8ebdc284 100644 --- a/apps/desktop/src/components/artifacts/artifacts-overview.tsx +++ b/apps/desktop/src/components/artifacts/artifacts-overview.tsx @@ -6,7 +6,7 @@ import { toast } from "sonner"; import type { ArtifactSummary } from "@godmode/shared"; import { ARTIFACT_STARTERS, MASCOT_CHARACTER, MASCOT_COLOR } from "@godmode/shared"; import { AppWindow, ArrowUp, MoreHorizontal, Pin, PinOff, Search, Trash2 } from "lucide-react"; -import { AgentAvatar, EmptyState, PageBody, PageHeader } from "@/components/common"; +import { AgentAvatar, EmptyState } from "@/components/common"; import { Character } from "@/components/character"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -22,7 +22,8 @@ import { useAgents, useArtifact, useArtifacts } from "@/lib/hooks"; import { cn } from "@/lib/utils"; import { useUi } from "@/stores/ui"; -export default function ArtifactsPage() { +/** Dashboards and small apps the agents built: a line to ask for a new one, then the gallery. */ +export function ArtifactsOverview() { const { data: artifacts, isLoading } = useArtifacts(); const [query, setQuery] = useState(""); const list = useMemo(() => { @@ -33,52 +34,45 @@ export default function ArtifactsPage() { const empty = !isLoading && (artifacts?.length ?? 0) === 0; return ( -
- } - title="Artifacts" - description="Dashboards and small apps your agents build for you. Open one, use it, and ask for changes in plain words." - /> - - +
+ - {isLoading ? ( -
- {Array.from({ length: 3 }, (_, i) => ( - - ))} -
- ) : empty ? ( - } - title="Nothing built yet" - description="Describe a dashboard or a tool above, or pick a starting point. Agents also build artifacts when you ask for one in any chat." - /> - ) : ( -
-
-

- Your artifacts {artifacts!.length} -

- {(artifacts?.length ?? 0) > 6 && ( -
- - setQuery(e.target.value)} placeholder="Search" className="h-8 pl-8 text-sm" /> -
- )} -
- {list.length ? ( -
- {list.map((a, i) => ( - - ))} + {isLoading ? ( +
+ {Array.from({ length: 2 }, (_, i) => ( + + ))} +
+ ) : empty ? ( + } + title="Nothing built yet" + description="Describe a dashboard or a tool above, or pick a starting point. Agents also build artifacts when you ask for one in any chat." + /> + ) : ( +
+
+

+ Your artifacts {artifacts!.length} +

+ {(artifacts?.length ?? 0) > 6 && ( +
+ + setQuery(e.target.value)} placeholder="Search" className="h-8 pl-8 text-sm" />
- ) : ( -

Nothing matches “{query}”.

)} -
- )} - +
+ {list.length ? ( +
+ {list.map((a, i) => ( + + ))} +
+ ) : ( +

Nothing matches “{query}”.

+ )} +
+ )}
); } diff --git a/apps/desktop/src/components/automations/app-trigger-fields.tsx b/apps/desktop/src/components/automations/app-trigger-fields.tsx index 4dbbb4bc..5e6e27e2 100644 --- a/apps/desktop/src/components/automations/app-trigger-fields.tsx +++ b/apps/desktop/src/components/automations/app-trigger-fields.tsx @@ -159,7 +159,7 @@ export function AppTriggerFields({ - + Connect another app
@@ -262,7 +262,7 @@ function ConnectHint({ title, description, action }: { title: string; descriptio

{description}

diff --git a/apps/desktop/src/components/browser/profile-use-card.tsx b/apps/desktop/src/components/browser/profile-use-card.tsx index 7b8a3d31..a4db0033 100644 --- a/apps/desktop/src/components/browser/profile-use-card.tsx +++ b/apps/desktop/src/components/browser/profile-use-card.tsx @@ -118,7 +118,7 @@ export function ProfileUseCard() { s.hasApiKey ? ( "browser_use_api_key set" ) : ( - + Add browser-use API key ) diff --git a/apps/desktop/src/components/chat/turn-end.tsx b/apps/desktop/src/components/chat/turn-end.tsx index 8e0b262b..47cfccf3 100644 --- a/apps/desktop/src/components/chat/turn-end.tsx +++ b/apps/desktop/src/components/chat/turn-end.tsx @@ -218,7 +218,7 @@ function SignInAgain({ run, name, action, onSignedIn }: { run: Run; name: string Claude refused the Anthropic API key. Check it, then try again. diff --git a/apps/desktop/src/components/integrations/integrations-overview.tsx b/apps/desktop/src/components/integrations/integrations-overview.tsx index 8f46651e..53b7fc7a 100644 --- a/apps/desktop/src/components/integrations/integrations-overview.tsx +++ b/apps/desktop/src/components/integrations/integrations-overview.tsx @@ -1,22 +1,18 @@ import type { ReactNode } from "react"; -import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "react-router"; import { motion } from "motion/react"; -import { Boxes, KeyRound, Plug, Server, Wrench } from "lucide-react"; +import { Boxes, Server, Wrench } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { PageBody, PageHeader } from "@/components/common"; -import { ApiKeysTab } from "@/components/integrations/api-keys-tab"; import { ApiToolsTab, useApiTools } from "@/components/integrations/api-tools-tab"; import { ComposioTab, useComposioConnections, useComposioStatus } from "@/components/integrations/composio-tab"; import { McpServersTab, useMcpServers } from "@/components/integrations/mcp-servers-tab"; import { NoConnectorsNote } from "@/components/integrations/no-connectors-note"; -import { api } from "@/lib/api"; -import { qk } from "@/lib/queryKeys"; -const TABS = ["composio", "tools", "mcp", "api-keys"] as const; +const TABS = ["composio", "tools", "mcp"] as const; type Tab = (typeof TABS)[number]; -export default function IntegrationsPage() { +/** Composio apps, API tools and MCP servers, a tab each (`?tab=`). */ +export function IntegrationsOverview() { const [params, setParams] = useSearchParams(); const raw = params.get("tab"); const tab: Tab = (TABS as readonly string[]).includes(raw ?? "") ? (raw as Tab) : "composio"; @@ -37,53 +33,38 @@ export default function IntegrationsPage() { const connections = useComposioConnections(composioReady); const servers = useMcpServers(); const tools = useApiTools(); - const secrets = useQuery({ queryKey: qk.appSecrets, queryFn: api.vault.secrets.list }); const counts: Record = { composio: connections.data?.filter((c) => c.status.toUpperCase() === "ACTIVE").length, tools: tools.data?.length, mcp: servers.data?.filter((s) => s.source === "custom").length, - "api-keys": secrets.data?.filter((s) => s.set).length, }; return ( -
- } - title="Integrations" - description="Optional: your agents already sign in to websites with your saved logins and 2FA codes. Add Composio for instant triggers, tools for any API with a key, MCP servers for anything else." - /> - - - - - } label="Composio" count={counts.composio} /> - } label="Tools" count={counts.tools} /> - } label="MCP servers" count={counts.mcp} /> - } label="API keys" count={counts["api-keys"]} /> - - - - - - - - - - - - - - - - - - - setTab("composio")} onOpenTools={() => setTab("tools")} /> - - - - +
+ + + + } label="Composio" count={counts.composio} /> + } label="Tools" count={counts.tools} /> + } label="MCP servers" count={counts.mcp} /> + + + + + + + + + + + + + + + + +
); } diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx index 6bcf45e1..7537b31f 100644 --- a/apps/desktop/src/components/layout/app-shell.tsx +++ b/apps/desktop/src/components/layout/app-shell.tsx @@ -9,14 +9,11 @@ import { Box, ChevronRight, CreditCard, - Fingerprint, - AppWindow, Globe, MonitorUp, Inbox, KeyRound, Lock, - MessageCircle, MessageSquarePlus, MessagesSquare, MonitorSmartphone, @@ -24,13 +21,10 @@ import { PanelLeftClose, PanelLeftOpen, Phone, - Plug, Puzzle, Search, Server, - Smartphone, Settings, - ShieldCheck, SquareKanban, Workflow, X, @@ -109,8 +103,12 @@ interface NavItem { icon: ReactNode; badge?: number; end?: boolean; + /** Other paths the item stands for (the tabs of one screen). */ + also?: readonly string[]; } +const isActive = (item: NavItem, pathname: string) => [item.to, ...(item.also ?? [])].some((to) => pathname.startsWith(to)); + export function AppShell({ children }: { children: ReactNode }) { const { data: boot } = useBootstrap(); const navigate = useNavigate(); @@ -188,51 +186,37 @@ export function AppShell({ children }: { children: ReactNode }) { { to: "/inbox", label: "Inbox", icon: , badge: inboxCount || undefined }, { to: "/tasks", label: "Tasks", icon: , badge: (attention?.review ?? 0) + (attention?.blocked ?? 0) || undefined }, { to: "/agents", label: "Agents", icon: , badge: runningCount || undefined }, - { to: "/artifacts", label: "Artifacts", icon: }, { to: "/automations", label: "Automations", icon: , badge: attention?.automation || undefined }, { to: "/activity", label: "Activity", icon: }, { to: "/calls", label: "Calls", icon: }, { to: "/plugins", label: "Plugins", icon: , badge: modsWaiting || undefined }, - ]; - const accessNav: NavItem[] = [ - { to: "/vault/logins", label: "Logins", icon: }, - { to: "/vault/2fa", label: "2FA Codes", icon: }, - { to: "/vault/passkeys", label: "Passkeys", icon: }, + { to: "/vault/logins", label: "Passwords", icon: , also: ["/vault/2fa", "/vault/passkeys"] }, { to: "/vault/cards", label: "Cards", icon: }, - { to: "/integrations", label: "Integrations", icon: }, - { to: "/messaging", label: "Messaging", icon: , badge: boot?.counts.messagingRequests || undefined }, ]; const machineNav: NavItem[] = [ { to: "/browser", label: "Browser", icon: }, { to: "/computer", label: "Computer", icon: }, { to: "/vms", label: "Virtual machines", icon: }, { to: "/ssh", label: "SSH servers", icon: }, - { to: "/android", label: "Android phones", icon: }, { to: "/runners", label: "Runners", icon: }, ]; // Easy mode: the everyday pages in plain words up front, every other page under More. - const byPath = new Map([...workNav, ...accessNav, ...machineNav].map((item) => [item.to, item])); + const byPath = new Map([...workNav, ...machineNav].map((item) => [item.to, item])); const easyItem = (to: string, label: string): NavItem => ({ ...byPath.get(to)!, label }); const easyNav: NavItem[] = [ easyItem("/inbox", "Inbox"), easyItem("/agents", "Agents"), easyItem("/automations", "Automations"), - easyItem("/artifacts", "Dashboards & apps"), easyItem("/tasks", "Task board"), easyItem("/vault/logins", "Passwords"), - easyItem("/integrations", "Connected apps"), ]; const easyMore: NavItem[] = [ easyItem("/activity", "History"), easyItem("/calls", "Phone calls"), - easyItem("/messaging", "Chat apps"), - easyItem("/vault/2fa", "2FA codes"), - easyItem("/vault/passkeys", "Passkeys"), easyItem("/vault/cards", "Payment cards"), easyItem("/plugins", "Add-ons"), easyItem("/browser", "Browser"), easyItem("/computer", "Computer control"), - easyItem("/android", "Android phones"), easyItem("/vms", "Virtual machines"), easyItem("/ssh", "Servers"), easyItem("/runners", "Other computers"), @@ -294,7 +278,6 @@ export function AppShell({ children }: { children: ReactNode }) { ) : ( <> - )} @@ -454,7 +437,7 @@ function NavSection({ id, label, items, folded = "icons" }: { id: string; label: ); if (folded === "list") { - const current = items.find((item) => pathname.startsWith(item.to)); + const current = items.find((item) => isActive(item, pathname)); const waiting = items.reduce((sum, item) => sum + (item.badge ?? 0), 0); return ( @@ -477,7 +460,7 @@ function NavSection({ id, label, items, folded = "icons" }: { id: string; label: {header}
{items.map((item) => { - const active = pathname.startsWith(item.to); + const active = isActive(item, pathname); return ( @@ -507,7 +490,7 @@ function NavSection({ id, label, items, folded = "icons" }: { id: string; label: function NavMenuItem({ item }: { item: NavItem }) { const location = useLocation(); - const active = item.end ? location.pathname === item.to || location.pathname.startsWith("/chat/") : location.pathname.startsWith(item.to); + const active = item.end ? location.pathname === item.to || location.pathname.startsWith("/chat/") : isActive(item, location.pathname); return ( s.connected); const qc = useQueryClient(); const lock = useMutation({ @@ -622,13 +611,20 @@ function FooterBar() { - - Settings ({modKey},) + {requests ? `Settings · ${requests} waiting in Messaging` : `Settings (${modKey},)`}
); diff --git a/apps/desktop/src/components/layout/claude-signed-out.tsx b/apps/desktop/src/components/layout/claude-signed-out.tsx index 10c89bd0..5b0757a5 100644 --- a/apps/desktop/src/components/layout/claude-signed-out.tsx +++ b/apps/desktop/src/components/layout/claude-signed-out.tsx @@ -65,7 +65,7 @@ export function ClaudeSignedOutBanner() {

{out.apiKey ? ( ) : !maySignIn ? null : state !== "waiting" ? ( - } - > - - set({ enabled })} /> - - - - } - description="Let agents sign in to apps and websites on a phone with the logins and 2FA codes from your vault." - > - - void setVaultFill(on)} /> - -
    - }>Only agents that may see passwords - }>Passwords only into password fields - }>Every fill audited - } caution> - Not bound to an app - - } caution> - Readable through the shell - -
-
- - } - description="Android Debug Bridge, from Google's platform-tools. Godmode reaches the phones through it." - actions={ - s && !s.adb.installed && s.canInstall ? ( - - ) : undefined - } - > - - {s ? ( - - {s.adb.installed - ? [s.adb.version ? `adb ${s.adb.version}` : "Installed", s.adb.managed ? "Godmode's own copy" : "found on this computer"].join(" · ") - : s.canInstall - ? "Not installed — Godmode can download it from Google" - : "Not installed — install Android's platform-tools"} - - ) : ( - Checking… + } + points={[ + { icon: , text: "Phones and emulators over USB or Wi-Fi" }, + { icon: , text: "Watch live and take over any time" }, + { icon: , text: "Only the agents and chats you give a phone" }, + ]} + > +
+ +
+ {s?.error && ( + + {s.error} + )} - - {s?.adb.path && ( - - {short(s.adb.path)} - - )} - - - - -
+ } + description="Let agents sign in to apps and websites on a phone with the logins and 2FA codes from your vault." + > + + void setVaultFill(on)} /> + +
    + }>Only agents that may see passwords + }>Passwords only into password fields + }>Every fill audited + } caution> + Not bound to an app + + } caution> + Readable through the shell + +
+
+ + } + description="Android Debug Bridge, from Google's platform-tools. Godmode reaches the phones through it." + actions={ + s && !s.adb.installed && s.canInstall ? ( + + ) : undefined + } + > + + {s ? ( + + {s.adb.installed + ? [s.adb.version ? `adb ${s.adb.version}` : "Installed", s.adb.managed ? "Godmode's own copy" : "found on this computer"].join(" · ") + : s.canInstall + ? "Not installed — Godmode can download it from Google" + : "Not installed — install Android's platform-tools"} + + ) : ( + Checking… + )} + + {s?.adb.path && ( + + {short(s.adb.path)} + + )} + + + + +
+
+ ); } diff --git a/apps/desktop/src/components/settings/api-keys-section.tsx b/apps/desktop/src/components/settings/api-keys-section.tsx new file mode 100644 index 00000000..43423cfa --- /dev/null +++ b/apps/desktop/src/components/settings/api-keys-section.tsx @@ -0,0 +1,13 @@ +import { useNavigate } from "react-router"; +import { ApiKeysTab } from "@/components/integrations/api-keys-tab"; +import { SectionHeading } from "./settings-kit"; + +export function ApiKeysSection() { + const navigate = useNavigate(); + return ( +
+ + navigate("/settings/integrations")} onOpenTools={() => navigate("/settings/integrations?tab=tools")} /> +
+ ); +} diff --git a/apps/desktop/src/components/settings/artifacts-section.tsx b/apps/desktop/src/components/settings/artifacts-section.tsx new file mode 100644 index 00000000..19532a5c --- /dev/null +++ b/apps/desktop/src/components/settings/artifacts-section.tsx @@ -0,0 +1,25 @@ +import { AppWindow, History, LayoutDashboard, PanelRight } from "lucide-react"; +import type { Settings } from "@godmode/shared"; +import { ArtifactsOverview } from "@/components/artifacts/artifacts-overview"; +import { useEasyMode } from "@/lib/mode"; +import { FeatureSection } from "./feature-section"; + +export function ArtifactsSection({ settings }: { settings: Settings }) { + const easy = useEasyMode(); + return ( + } + points={[ + { icon: , text: "Dashboards, trackers, planners and calculators" }, + { icon: , text: "Opens next to the chat it was built in" }, + { icon: , text: "Every change is a version you can go back to" }, + ]} + > + + + ); +} diff --git a/apps/desktop/src/components/settings/calls-section.tsx b/apps/desktop/src/components/settings/calls-section.tsx index c4ad8228..8fe7c0f4 100644 --- a/apps/desktop/src/components/settings/calls-section.tsx +++ b/apps/desktop/src/components/settings/calls-section.tsx @@ -192,7 +192,7 @@ function SetupStatus({ setup }: { setup: ReturnType }) { ) : ( <> No ElevenLabs key yet. Add one under{" "} - + Integrations → API keys . diff --git a/apps/desktop/src/components/settings/feature-section.tsx b/apps/desktop/src/components/settings/feature-section.tsx new file mode 100644 index 00000000..d60b7fd5 --- /dev/null +++ b/apps/desktop/src/components/settings/feature-section.tsx @@ -0,0 +1,88 @@ +import { useId, type ReactNode } from "react"; +import { motion } from "motion/react"; +import type { Settings } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { useSettingsPatch } from "./settings-kit"; + +export const FEATURES = ["artifacts", "integrations", "messaging", "android"] as const; +export type Feature = (typeof FEATURES)[number]; + +export const featureOn = (settings: Pick | undefined, feature: Feature) => !!settings?.[feature].enabled; + +/** + * An optional feature's home in Settings: its switch and what it does at the top, then the feature itself — or, while + * it is off, what it would bring and a way to turn it on. + */ +export function FeatureSection({ + feature, + settings, + title, + summary, + icon, + points, + children, +}: { + feature: Feature; + settings: Settings; + title: string; + summary: string; + icon: ReactNode; + points: { icon: ReactNode; text: string }[]; + children: ReactNode; +}) { + const { patch } = useSettingsPatch(); + const on = featureOn(settings, feature); + const id = useId(); + const set = (enabled: boolean) => patch({ [feature]: { enabled } }); + const off = `${title} ${title.endsWith("s") ? "are" : "is"} off`; + + return ( +
+
+
+

{title}

+

{summary}

+
+ +
+ + {on ? ( + + {children} + + ) : ( + +
{icon}
+

{off}

+

Agents don't get its tools while it's off. Turn it on whenever you need it.

+
    + {points.map((p) => ( +
  • + {p.icon} + {p.text} +
  • + ))} +
+ +
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/settings/integrations-section.tsx b/apps/desktop/src/components/settings/integrations-section.tsx new file mode 100644 index 00000000..4ca83cec --- /dev/null +++ b/apps/desktop/src/components/settings/integrations-section.tsx @@ -0,0 +1,25 @@ +import { Boxes, Plug, Server, Wrench } from "lucide-react"; +import type { Settings } from "@godmode/shared"; +import { IntegrationsOverview } from "@/components/integrations/integrations-overview"; +import { useEasyMode } from "@/lib/mode"; +import { FeatureSection } from "./feature-section"; + +export function IntegrationsSection({ settings }: { settings: Settings }) { + const easy = useEasyMode(); + return ( + } + points={[ + { icon: , text: "Gmail, Slack, Notion and more through Composio" }, + { icon: , text: "Any API with a key, without agents seeing it" }, + { icon: , text: "Your own MCP servers" }, + ]} + > + + + ); +} diff --git a/apps/desktop/src/components/settings/messaging-section.tsx b/apps/desktop/src/components/settings/messaging-section.tsx new file mode 100644 index 00000000..cc4d97b7 --- /dev/null +++ b/apps/desktop/src/components/settings/messaging-section.tsx @@ -0,0 +1,25 @@ +import { KeyRound, MessageCircle, MessagesSquare, UserCheck } from "lucide-react"; +import type { Settings } from "@godmode/shared"; +import { MessagingOverview } from "@/components/messaging/messaging-overview"; +import { useEasyMode } from "@/lib/mode"; +import { FeatureSection } from "./feature-section"; + +export function MessagingSection({ settings }: { settings: Settings }) { + const easy = useEasyMode(); + return ( + } + points={[ + { icon: , text: "Slack, Telegram and Microsoft Teams" }, + { icon: , text: "Private: you approve who may write" }, + { icon: , text: "Bot tokens sealed in your vault" }, + ]} + > + + + ); +} diff --git a/apps/desktop/src/components/settings/voice-section.tsx b/apps/desktop/src/components/settings/voice-section.tsx index b5b41d1e..c5c13f46 100644 --- a/apps/desktop/src/components/settings/voice-section.tsx +++ b/apps/desktop/src/components/settings/voice-section.tsx @@ -294,7 +294,7 @@ export function VoiceSection({ settings }: { settings: Settings }) { {(usesOpenAi || v.ttsProvider === "elevenlabs") && ( } title="API keys live in the vault"> Add your {[usesOpenAi && "OpenAI", v.ttsProvider === "elevenlabs" && "ElevenLabs"].filter(Boolean).join(" and ")} key under{" "} - + Integrations → API keys . Keys are encrypted and never shown again. diff --git a/apps/desktop/src/components/vault/passwords-header.tsx b/apps/desktop/src/components/vault/passwords-header.tsx new file mode 100644 index 00000000..37871b7f --- /dev/null +++ b/apps/desktop/src/components/vault/passwords-header.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react"; +import { NavLink } from "react-router"; +import { useQuery } from "@tanstack/react-query"; +import { Fingerprint, KeyRound, ShieldCheck } from "lucide-react"; +import { PageHeader } from "@/components/common"; +import { api } from "@/lib/api"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; + +export const PASSWORD_TABS = ["/vault/logins", "/vault/2fa", "/vault/passkeys"] as const; + +/** One screen for what agents sign in with: the title, a tab each for logins, 2FA codes and passkeys, and the tab's actions. */ +export function PasswordsHeader({ actions }: { actions?: ReactNode }) { + const logins = useQuery({ queryKey: qk.credentialList("all", ""), queryFn: () => api.credentials.list({ workspaceId: "all" }) }); + const codes = useQuery({ queryKey: qk.totpList("all"), queryFn: () => api.totp.list({ workspaceId: "all" }) }); + const passkeys = useQuery({ queryKey: qk.passkeyList("all"), queryFn: () => api.passkeys.list({ workspaceId: "all" }) }); + const tabs = [ + { to: PASSWORD_TABS[0], label: "Logins", icon: , count: logins.data?.length }, + { to: PASSWORD_TABS[1], label: "2FA codes", icon: , count: codes.data?.length }, + { to: PASSWORD_TABS[2], label: "Passkeys", icon: , count: passkeys.data?.length }, + ]; + return ( + <> + } + title="Passwords" + description="The logins, 2FA codes and passkeys your agents sign in with. Encrypted on this computer; agents never see the secrets." + actions={actions} + className="pb-4 @2xl:pb-5" + /> +
+ +
+ + ); +} diff --git a/apps/desktop/src/lib/mode.ts b/apps/desktop/src/lib/mode.ts index 0acfc57a..0adfef2e 100644 --- a/apps/desktop/src/lib/mode.ts +++ b/apps/desktop/src/lib/mode.ts @@ -20,18 +20,13 @@ export function useInterfaceMode(): InterfaceMode { /** Easy mode titles and descriptions for the top-level pages, by path (the sidebar uses the same names). */ export const EASY_PAGES: Record = { - "/artifacts": { title: "Dashboards & apps" }, "/tasks": { title: "Task board" }, "/automations": { description: "Let your agents start work by themselves: on a schedule, or when something happens in your apps." }, "/files": { description: "What your agents keep: their files, notes and memory. Look through it, tidy it up, and put back what you deleted." }, "/activity": { title: "History", description: "Everything your agents did." }, "/calls": { title: "Phone calls" }, "/plugins": { title: "Add-ons", description: "Extra skills for your agents. Add one, switch it off or remove it any time." }, - "/vault/logins": { title: "Passwords", description: "The logins your agents may use. Encrypted on this computer; agents never see the passwords." }, - "/vault/2fa": { title: "2FA codes" }, "/vault/cards": { title: "Payment cards" }, - "/integrations": { title: "Connected apps", description: "Optional: your agents already sign in to your websites with your saved logins and 2FA codes. Connect an app only so they react the moment something arrives." }, - "/messaging": { title: "Chat apps", description: "Talk to your agents from Slack, Telegram or Microsoft Teams." }, "/computer": { title: "Computer control" }, "/ssh": { title: "Servers" }, "/runners": { title: "Other computers" }, diff --git a/apps/desktop/src/pages/android/android-device-page.tsx b/apps/desktop/src/pages/android/android-device-page.tsx index 7d3895ad..5f44584c 100644 --- a/apps/desktop/src/pages/android/android-device-page.tsx +++ b/apps/desktop/src/pages/android/android-device-page.tsx @@ -208,7 +208,7 @@ export default function AndroidDevicePage() { description={list.isError ? errorMessage(list.error) : "It may have been forgotten. Plug it in again and it shows up as a new phone."} action={ @@ -223,7 +223,7 @@ export default function AndroidDevicePage() {
diff --git a/apps/desktop/src/pages/artifacts/artifact-page.tsx b/apps/desktop/src/pages/artifacts/artifact-page.tsx index dc7d47b4..e85d6eaf 100644 --- a/apps/desktop/src/pages/artifacts/artifact-page.tsx +++ b/apps/desktop/src/pages/artifacts/artifact-page.tsx @@ -15,13 +15,13 @@ export default function ArtifactPage() {
- navigate("/artifacts")} footer={} /> + navigate("/settings/artifacts")} footer={} />
); diff --git a/apps/desktop/src/pages/settings/settings-page.tsx b/apps/desktop/src/pages/settings/settings-page.tsx index 7975db01..80876f16 100644 --- a/apps/desktop/src/pages/settings/settings-page.tsx +++ b/apps/desktop/src/pages/settings/settings-page.tsx @@ -3,6 +3,7 @@ import { Navigate, NavLink, useParams } from "react-router"; import { AnimatePresence, motion } from "motion/react"; import { Activity, + AppWindow, AudioLines, BrainCircuit, Box, @@ -15,10 +16,13 @@ import { Globe, HeartPulse, Info, + KeyRound, KeySquare, Laptop, + MessageCircle, MonitorUp, Phone, + Plug, RefreshCw, ScrollText, Settings2, @@ -32,6 +36,8 @@ import { EmptyState, PageBody, PageHeader } from "@/components/common"; import { AboutSection } from "@/components/settings/about-section"; import { AiSection } from "@/components/settings/ai-section"; import { AndroidSection } from "@/components/settings/android-section"; +import { ApiKeysSection } from "@/components/settings/api-keys-section"; +import { ArtifactsSection } from "@/components/settings/artifacts-section"; import { BackupSection } from "@/components/settings/backup-section"; import { BillingSection } from "@/components/settings/billing-section"; import { BrowserSection } from "@/components/settings/browser-section"; @@ -41,12 +47,15 @@ import { CloudSection } from "@/components/settings/cloud-section"; import { ComputerSection } from "@/components/settings/computer-section"; import { ConnectSection } from "@/components/settings/connect-section"; import { DevicesSection } from "@/components/settings/devices-section"; +import { FEATURES, featureOn } from "@/components/settings/feature-section"; import { FilesSection } from "@/components/settings/files-section"; import { GeneralSection } from "@/components/settings/general-section"; import { InstructionsSection } from "@/components/settings/instructions-section"; +import { IntegrationsSection } from "@/components/settings/integrations-section"; import { LicenseSection } from "@/components/settings/license-section"; import { LogsSection } from "@/components/settings/logs-section"; import { MemorySection } from "@/components/settings/memory-section"; +import { MessagingSection } from "@/components/settings/messaging-section"; import { PhoneSection } from "@/components/settings/phone-section"; import { SecuritySection } from "@/components/settings/security-section"; import { SystemSection } from "@/components/settings/system-section"; @@ -66,11 +75,15 @@ const SECTIONS = [ { id: "browser", label: "Browser", icon: , group: "Preferences" }, { id: "computer", label: "Computer", icon: , group: "Preferences" }, { id: "vms", label: "Virtual machines", icon: , group: "Preferences" }, - { id: "android", label: "Android phones", icon: , group: "Preferences" }, { id: "voice", label: "Voice", icon: , group: "Preferences" }, { id: "calls", label: "Phone calls", icon: , group: "Preferences" }, { id: "memory", label: "Memory", icon: , group: "Preferences" }, + { id: "artifacts", label: "Artifacts", icon: , group: "Features" }, + { id: "integrations", label: "Integrations", icon: , group: "Features" }, + { id: "messaging", label: "Messaging", icon: , group: "Features" }, + { id: "android", label: "Android phones", icon: , group: "Features" }, { id: "files", label: "Files", icon: , group: "Data & privacy" }, + { id: "api-keys", label: "API keys", icon: , group: "Data & privacy" }, { id: "security", label: "Security", icon: , group: "Data & privacy" }, { id: "backup", label: "Backup", icon: , group: "Data & privacy" }, { id: "phone", label: "Phone", icon: , group: "App" }, @@ -96,6 +109,10 @@ const EASY: Partial> = { voice: { label: "Voice", group: "Everyday" }, calls: { label: "Phone calls", group: "Everyday" }, phone: { label: "Phone app", group: "Everyday" }, + artifacts: { label: "Dashboards & apps", group: "Extras" }, + integrations: { label: "Connected apps", group: "Extras" }, + messaging: { label: "Chat apps", group: "Extras" }, + android: { label: "Android phones", group: "Extras" }, security: { label: "Security", group: "Account" }, backup: { label: "Backup", group: "Account" }, license: { label: "License", group: "Account" }, @@ -110,7 +127,7 @@ const EASY_ADVANCED_LABEL: Partial> = { }; /** Sections that render without the settings document (they use their own endpoints). */ -const STANDALONE: SectionId[] = ["files", "backup", "devices", "connect", "cloud", "license", "billing", "system", "cleanup", "about"]; +const STANDALONE: SectionId[] = ["files", "api-keys", "backup", "devices", "connect", "cloud", "license", "billing", "system", "cleanup", "about"]; export default function SettingsPage() { const { section } = useParams(); @@ -145,11 +162,15 @@ export default function SettingsPage() { browser: () => , computer: () => , vms: () => , + artifacts: () => , + integrations: () => , + messaging: () => , android: () => , voice: () => , calls: () => , memory: () => , files: () => , + "api-keys": () => , security: () => , backup: () => , phone: () => , @@ -191,11 +212,29 @@ export default function SettingsPage() { } type NavEntry = { id: SectionId; label: string; icon: ReactNode; group: string }; +type Marks = Partial>; + +/** Next to the optional features: "Off", or what waits for the human there (people who asked to talk to a bot). */ +function useMarks(): Marks { + const { data: boot } = useBootstrap(); + if (!boot) return {}; + const marks: Marks = {}; + for (const feature of FEATURES) if (!featureOn(boot.settings, feature)) marks[feature] = Off; + const requests = featureOn(boot.settings, "messaging") ? boot.counts.messagingRequests : 0; + if (requests) + marks.messaging = ( + + {requests} + + ); + return marks; +} function SectionNav({ active }: { active: SectionId }) { const easy = useEasyMode(); const [showAdvanced, setShowAdvanced] = useState(false); - if (!easy) return ; + const marks = useMarks(); + if (!easy) return ; const everyday: NavEntry[] = (Object.keys(EASY) as SectionId[]).map((id) => ({ ...SECTIONS.find((s) => s.id === id)!, ...EASY[id]! })); const advanced: NavEntry[] = SECTIONS.flatMap((s) => EASY[s.id] ? [] : [{ ...s, label: EASY_ADVANCED_LABEL[s.id] ?? s.label, group: "Advanced" }], @@ -205,6 +244,7 @@ function SectionNav({ active }: { active: SectionId }) { {s.icon} {s.label} + {marks[s.id] && {marks[s.id]}}
); diff --git a/apps/desktop/src/pages/vault/logins-page.tsx b/apps/desktop/src/pages/vault/logins-page.tsx index 1d70fbdf..29af12d2 100644 --- a/apps/desktop/src/pages/vault/logins-page.tsx +++ b/apps/desktop/src/pages/vault/logins-page.tsx @@ -7,12 +7,13 @@ import type { Credential, TotpEntry } from "@godmode/shared"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; -import { EmptyState, Kbd, PageBody, PageHeader } from "@/components/common"; +import { EmptyState, Kbd, PageBody } from "@/components/common"; import { usePageScroll } from "@/components/layout/page-scroll"; import { CredentialDialog, type CredentialPrefill } from "@/components/vault/credential-dialog"; import { CREDENTIAL_GRID, CredentialRow } from "@/components/vault/credential-row"; import { ConfirmDeleteDialog } from "@/components/vault/confirm-dialog"; import { PasswordImportDialog } from "@/components/vault/password-import-dialog"; +import { PasswordsHeader } from "@/components/vault/passwords-header"; import { ScopeFilterSelect } from "@/components/vault/workspace-select"; import { useDebouncedValue } from "@/components/vault/use-debounced-value"; import { isVaultLocked, toastApiError } from "@/components/vault/vault-utils"; @@ -118,10 +119,7 @@ export default function LoginsPage() { return (
- } - title="Logins" - description="Website logins your agents can use — encrypted in your local vault." +