diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js new file mode 100644 index 0000000000..104fc66a86 --- /dev/null +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -0,0 +1,156 @@ +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const AgentMemory = require('../../models/AgentMemory'); +const manageSettings = require('../../routes/registry/manage-settings'); +const podAgents = require('../../routes/registry/pod-agents'); + +const SECRET = 'agent-manage-settings-test'; +const token = (user) => jwt.sign({ id: String(user._id) }, SECRET); +const config = (installation) => Object.fromEntries(installation.config || []); + +describe('per-installation agent settings', () => { + let app; let owner; let other; let admin; let podA; let podB; + + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + await setupMongoDb(); + app = express(); + app.use(express.json()); + app.use('/api/registry', manageSettings); + app.use('/api/registry', podAgents); + owner = await User.create({ username: 'settings-owner', email: 'settings-owner@test.com', password: 'password123' }); + other = await User.create({ username: 'settings-other', email: 'settings-other@test.com', password: 'password123' }); + admin = await User.create({ username: 'settings-admin', email: 'settings-admin@test.com', password: 'password123', role: 'admin' }); + podA = await Pod.create({ name: 'Settings A', type: 'chat', createdBy: owner._id, members: [owner._id, other._id] }); + podB = await Pod.create({ name: 'Settings B', type: 'chat', createdBy: other._id, members: [owner._id, other._id] }); + }); + + afterAll(async () => closeMongoDb()); + + beforeEach(async () => { + await AgentInstallation.deleteMany({ agentName: 'settings-agent' }); + await AgentInstallation.create([ + { agentName: 'settings-agent', instanceId: 'same', podId: podA._id, version: '1', installedBy: owner._id, + config: new Map([['wakeOnMessage', { enabled: true, humanOnly: true }], ['boardWake', { enabled: false }], ['runtime', { runtimeType: 'wrapper' }]]) }, + { agentName: 'settings-agent', instanceId: 'same', podId: podB._id, version: '1', installedBy: other._id, + config: new Map([['wakeOnMessage', { enabled: false }], ['runtime', { runtimeType: 'wrapper' }]]) }, + ]); + }); + + it('lists only installations the caller may manage', async () => { + const response = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(200); + expect(response.body.installations).toEqual([expect.objectContaining({ + podId: String(podA._id), status: 'active', wakeOnMessage: true, humanOnly: true, boardWake: false, + heartbeatEnabled: false, heartbeatMinutes: 60, + })]); + expect(response.body.activePodCount).toBe(2); + expect(response.body.podCount).toBe(2); + expect(response.body.turnCap).toBe(30); + expect(response.body.writablePodId).toBe(String(podA._id)); + const otherResponse = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(other)}`); + expect(otherResponse.body.writablePodId).toBe(String(podB._id)); + }); + + it('counts active connections separately from installed pod rows', async () => { + await AgentInstallation.updateOne({ podId: podB._id, agentName: 'settings-agent' }, { status: 'paused' }); + const response = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(admin)}`); + expect(response.status).toBe(200); + expect(response.body.podCount).toBe(2); + expect(response.body.activePodCount).toBe(1); + expect(response.body.installations.find((entry) => entry.podId === String(podB._id)).status).toBe('paused'); + }); + + it('writes one installation and preserves its human-only default and runtime', async () => { + const response = await request(app) + .patch(`/api/registry/agents/settings-agent/manage-settings/pods/${podA._id}?instanceId=same`) + .set('Authorization', `Bearer ${token(owner)}`) + .send({ wakeOnMessage: false, humanOnly: false, boardWake: true, + heartbeatEnabled: true, heartbeatMinutes: 90 }); + expect(response.status).toBe(200); + const [a, b] = await Promise.all([ + AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' }), + AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' }), + ]); + expect(config(a).wakeOnMessage).toEqual({ enabled: false, humanOnly: false }); + expect(config(a).boardWake).toEqual({ enabled: true }); + expect(config(a).heartbeat).toEqual({ enabled: true, everyMinutes: 90 }); + expect(config(a).runtime).toEqual({ runtimeType: 'wrapper' }); + expect(config(b).wakeOnMessage).toEqual({ enabled: false }); + expect(config(b).heartbeat).toBeUndefined(); + }); + + it('refuses another pod member and invalid intervals before writing', async () => { + const url = `/api/registry/agents/settings-agent/manage-settings/pods/${podA._id}?instanceId=same`; + const body = { wakeOnMessage: false, humanOnly: true, boardWake: true, + heartbeatEnabled: true, heartbeatMinutes: 30 }; + const denied = await request(app).patch(url).set('Authorization', `Bearer ${token(other)}`).send(body); + const malformed = await request(app).patch(url).set('Authorization', `Bearer ${token(owner)}`) + .send({ ...body, heartbeatMinutes: 0 }); + expect(denied.status).toBe(403); + expect(malformed.status).toBe(400); + const unchanged = await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' }); + expect(config(unchanged).wakeOnMessage).toEqual({ enabled: true, humanOnly: true }); + }); + + it('lets an instance admin manage an installation they did not create', async () => { + const listed = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') + .set('Authorization', `Bearer ${token(admin)}`); + expect(listed.status).toBe(200); + expect(listed.body.installations).toHaveLength(2); + const saved = await request(app) + .patch(`/api/registry/agents/settings-agent/manage-settings/pods/${podB._id}?instanceId=same`) + .set('Authorization', `Bearer ${token(admin)}`) + .send({ wakeOnMessage: true, humanOnly: false, boardWake: false, + heartbeatEnabled: true, heartbeatMinutes: 120 }); + expect(saved.status).toBe(200); + expect(config(await AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' })).heartbeat) + .toEqual({ enabled: true, everyMinutes: 120 }); + }); + + it('removes one pod installation while keeping the agent identity and memory', async () => { + const bot = await User.create({ + username: 'settings-agent-same', email: 'settings-agent@test.com', password: 'password123', + isBot: true, botMetadata: { agentName: 'settings-agent', instanceId: 'same' }, + }); + const memory = await AgentMemory.create({ + agentName: 'settings-agent', instanceId: 'same', + sections: { long_term: { content: 'Remember this', visibility: 'private', byteSize: 13 } }, + }); + const response = await request(app) + .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=same`) + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(200); + expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' })).status).toBe('uninstalled'); + expect((await AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' })).status).toBe('active'); + expect(await User.exists({ _id: bot._id })).toBeTruthy(); + expect(await AgentMemory.exists({ _id: memory._id })).toBeTruthy(); + }); + + it('keeps the pod creator’s authority to remove an installation', async () => { + await AgentInstallation.updateOne({ podId: podA._id, agentName: 'settings-agent' }, { installedBy: other._id }); + const response = await request(app) + .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=same`) + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(200); + expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' })).status).toBe('uninstalled'); + }); + + it('never removes another instance when the requested instance is missing', async () => { + const response = await request(app) + .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=missing`) + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(404); + expect((await AgentInstallation.findOne({ + podId: podA._id, agentName: 'settings-agent', instanceId: 'same', + })).status).toBe('active'); + }); +}); diff --git a/backend/__tests__/unit/routes/registry.helpers.normalize-instance-id.test.js b/backend/__tests__/unit/routes/registry.helpers.normalize-instance-id.test.js new file mode 100644 index 0000000000..005374fa35 --- /dev/null +++ b/backend/__tests__/unit/routes/registry.helpers.normalize-instance-id.test.js @@ -0,0 +1,7 @@ +const { normalizeInstanceId } = require('../../../routes/registry/helpers'); + +it('collapses separators and trims the edges of an instance ID', () => { + expect(normalizeInstanceId(' ---Machine -- 2--- ')).toBe('machine-2'); + expect(normalizeInstanceId('---')).toBe('default'); + expect(normalizeInstanceId(null)).toBe('default'); +}); diff --git a/backend/__tests__/unit/routes/registry.list-agents-config.test.js b/backend/__tests__/unit/routes/registry.list-agents-config.test.js index 67bd9bcf23..d4758ea892 100644 --- a/backend/__tests__/unit/routes/registry.list-agents-config.test.js +++ b/backend/__tests__/unit/routes/registry.list-agents-config.test.js @@ -113,6 +113,8 @@ describe('registry list pod agents config payload', () => { installedBy: 'user-1', config: new Map(Object.entries({ heartbeat: { enabled: true, everyMinutes: 60 }, + wakeOnMessage: { enabled: true, humanOnly: true }, + boardWake: { enabled: false }, autonomy: { autoJoinAgentOwnedPods: true }, errorRouting: { ownerDm: true }, heartbeatChecklist: '- Check updates', @@ -154,6 +156,8 @@ describe('registry list pod agents config payload', () => { customizations: null, environment: {}, heartbeat: { enabled: true, everyMinutes: 60 }, + wakeOnMessage: { enabled: true, humanOnly: true }, + boardWake: { enabled: false }, autonomy: { autoJoinAgentOwnedPods: true }, errorRouting: { ownerDm: true }, heartbeatChecklist: '- Check updates', diff --git a/backend/__tests__/unit/routes/registry.wake-settings-projection.test.js b/backend/__tests__/unit/routes/registry.wake-settings-projection.test.js new file mode 100644 index 0000000000..317999df85 --- /dev/null +++ b/backend/__tests__/unit/routes/registry.wake-settings-projection.test.js @@ -0,0 +1,19 @@ +const { buildAgentInstallationPayload } = require('../../../routes/registry/helpers'); + +it('projects wake and heartbeat settings for the shared Settings reader', () => { + const payload = buildAgentInstallationPayload({ + agentName: 'test-agent', instanceId: 'default', podId: 'pod-1', installedBy: 'owner', + config: new Map([ + ['wakeOnMessage', { enabled: true, humanOnly: true }], + ['boardWake', { enabled: false }], + ['heartbeat', { everyMinutes: 90 }], + ['runtime', { runtimeType: 'wrapper', webhookSecret: 'never-return' }], + ]), + }); + expect(payload.config).toEqual(expect.objectContaining({ + wakeOnMessage: { enabled: true, humanOnly: true }, + boardWake: { enabled: false }, + heartbeat: { everyMinutes: 90 }, + })); + expect(JSON.stringify(payload)).not.toContain('never-return'); +}); diff --git a/backend/routes/registry.ts b/backend/routes/registry.ts index 669667b759..df9bb3b352 100644 --- a/backend/routes/registry.ts +++ b/backend/routes/registry.ts @@ -35,6 +35,8 @@ router.use(require('./registry/provision')); // eslint-disable-next-line global-require router.use(require('./registry/agent-config')); // eslint-disable-next-line global-require +router.use(require('./registry/manage-settings')); +// eslint-disable-next-line global-require router.use(require('./registry/publish')); module.exports = router; diff --git a/backend/routes/registry/helpers.ts b/backend/routes/registry/helpers.ts index f1dcf10d17..2d810332f8 100644 --- a/backend/routes/registry/helpers.ts +++ b/backend/routes/registry/helpers.ts @@ -469,6 +469,8 @@ const buildAgentInstallationPayload = (installation: any, { presetId: normalizedConfig.presetId || null, customizations: normalizedConfig.customizations || null, heartbeat: normalizedConfig.heartbeat || null, + wakeOnMessage: normalizedConfig.wakeOnMessage || null, + boardWake: normalizedConfig.boardWake || null, autonomy: normalizedConfig.autonomy || null, errorRouting: normalizedConfig.errorRouting || null, heartbeatChecklist: normalizedConfig.heartbeatChecklist || '', @@ -506,12 +508,13 @@ const getPluginSpecBase = (spec: any) => { }; const normalizeInstanceId = (raw: any) => { - const normalized = String(raw || '') + let normalized = String(raw || '') .trim() .toLowerCase() .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-+|-+$/g, ''); + .replace(/-+/g, '-'); + if (normalized.startsWith('-')) normalized = normalized.slice(1); + if (normalized.endsWith('-')) normalized = normalized.slice(0, -1); return normalized || 'default'; }; diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts new file mode 100644 index 0000000000..98eb5f802a --- /dev/null +++ b/backend/routes/registry/manage-settings.ts @@ -0,0 +1,108 @@ +// Owner/admin controls for one agent installation at a time. +import rateLimit from 'express-rate-limit'; +import { cloudflareIpRateLimitKeyGenerator } from '../../middleware/ipRateLimit'; +import { hostedCapsForOwner } from '../../services/hostedRuntimeService'; +const express = require('express'); +const auth = require('../../middleware/auth'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const Pod = require('../../models/Pod'); +const User = require('../../models/User'); +const { getUserId, normalizeConfigMap, normalizeInstanceId } = require('./helpers'); + +const router = express.Router(); +const settingsRateLimit = rateLimit({ + windowMs: 60_000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.NODE_ENV === 'test', + keyGenerator: (req: unknown) => cloudflareIpRateLimitKeyGenerator(req as never), + handler: (_req: unknown, res: any) => res.status(429).json({ code: 'rate_limited' }), +}); + +const canManage = (installation: any, userId: string, isAdmin: boolean) => ( + isAdmin || String(installation.installedBy) === String(userId) +); + +const settings = (installation: any, podName: string) => { + const config = normalizeConfigMap(installation.config) || {}; + const wakeOnMessage = config.wakeOnMessage?.enabled === true; + return { + podId: String(installation.podId), + podName, + status: installation.status, + wakeOnMessage, + humanOnly: config.wakeOnMessage?.humanOnly === true, + boardWake: typeof config.boardWake?.enabled === 'boolean' + ? config.boardWake.enabled : wakeOnMessage, + heartbeatEnabled: config.heartbeat?.enabled === true, + heartbeatMinutes: Number.isFinite(Number(config.heartbeat?.everyMinutes)) + && Number(config.heartbeat?.everyMinutes) > 0 + ? Math.max(1, Math.min(1440, Math.trunc(Number(config.heartbeat.everyMinutes)))) : 60, + }; +}; + +router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: any, res: any) => { + try { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const isAdmin = (await User.findById(userId).select('role').lean())?.role === 'admin'; + const installations = await AgentInstallation.find({ + agentName: String(req.params.name).toLowerCase(), + instanceId: normalizeInstanceId(req.query.instanceId), + status: { $ne: 'uninstalled' }, + }).lean(); + const manageable = installations.filter((entry: any) => canManage(entry, userId, isAdmin)); + const pods = await Pod.find({ _id: { $in: manageable.map((entry: any) => entry.podId) } }) + .select('name').lean(); + const names = new Map(pods.map((pod: any) => [String(pod._id), String(pod.name)])); + const visible = manageable.filter((entry: any) => names.has(String(entry.podId))); + const writable = visible.find((entry: any) => entry.status === 'active') || visible[0]; + const { turnsPerDay } = await hostedCapsForOwner(userId); + return res.json({ turnCap: turnsPerDay, podCount: installations.length, + activePodCount: installations.filter((entry: any) => entry.status === 'active').length, + writablePodId: writable ? String(writable.podId) : null, + installations: visible + .map((entry: any) => settings(entry, names.get(String(entry.podId)) || 'Pod')) }); + } catch (error) { + console.error('Error loading agent settings:', error); + return res.status(500).json({ error: 'Failed to load agent settings' }); + } +}); + +router.patch('/agents/:name/manage-settings/pods/:podId', settingsRateLimit, auth, async (req: any, res: any) => { + try { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const { wakeOnMessage, humanOnly, boardWake, heartbeatEnabled, heartbeatMinutes } = req.body || {}; + if (typeof wakeOnMessage !== 'boolean' || typeof boardWake !== 'boolean' + || typeof humanOnly !== 'boolean' || typeof heartbeatEnabled !== 'boolean' + || !Number.isInteger(heartbeatMinutes) || heartbeatMinutes < 1 || heartbeatMinutes > 1440) { + return res.status(400).json({ error: 'Invalid wake settings' }); + } + const installation = await AgentInstallation.findOne({ + agentName: String(req.params.name).toLowerCase(), + instanceId: normalizeInstanceId(req.query.instanceId), + podId: req.params.podId, + status: { $ne: 'uninstalled' }, + }); + if (!installation) return res.status(404).json({ error: 'Agent not installed in this pod' }); + const isAdmin = (await User.findById(userId).select('role').lean())?.role === 'admin'; + if (!canManage(installation, userId, isAdmin)) return res.status(403).json({ error: 'Installer or admin required' }); + const config = normalizeConfigMap(installation.config) || {}; + installation.config = new Map(Object.entries({ + ...config, + wakeOnMessage: { ...config.wakeOnMessage, enabled: wakeOnMessage, humanOnly }, + boardWake: { ...config.boardWake, enabled: boardWake }, + heartbeat: { ...config.heartbeat, enabled: heartbeatEnabled, everyMinutes: heartbeatMinutes }, + })); + await installation.save(); + return res.json({ settings: settings(installation, '') }); + } catch (error) { + console.error('Error saving agent settings:', error); + return res.status(500).json({ error: 'Failed to save agent settings' }); + } +}); + +module.exports = router; +export {}; diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts index d0735dbdd3..1086b9f2e2 100644 --- a/backend/routes/registry/pod-agents.ts +++ b/backend/routes/registry/pod-agents.ts @@ -14,6 +14,7 @@ const { revokeRuntimeTokensAfterLastUninstall } = require('./tokens'); const { getUserId, isGlobalAdminUser, + normalizeInstanceId, normalizeDisplayName, resolveInstallation, buildAgentProfileId, @@ -29,10 +30,12 @@ const podAgentsRouter = express.Router(); podAgentsRouter.delete('/agents/:name/pods/:podId', auth, async (req: any, res: any) => { try { const { name, podId } = req.params; - const { installation, instanceId } = await resolveInstallation({ - agentName: name, - podId, - instanceId: req.query.instanceId, + // Destructive writes must never use resolveInstallation's single-install + // fallback: a stale instanceId could otherwise remove a different agent. + const instanceId = normalizeInstanceId(req.query.instanceId); + const installation = await AgentInstallation.findOne({ + agentName: String(name).toLowerCase(), podId, instanceId, + status: { $ne: 'uninstalled' }, }); const userId = getUserId(req); if (!userId) { diff --git a/cli/__tests__/detach.test.mjs b/cli/__tests__/detach.test.mjs index f5d1acc268..b28db37c04 100644 --- a/cli/__tests__/detach.test.mjs +++ b/cli/__tests__/detach.test.mjs @@ -65,7 +65,7 @@ describe('performDetach', () => { client, agentName: 'my-bot', podId: 'pod-1', }); - expect(del).toHaveBeenCalledWith('/api/registry/agents/my-bot/pods/pod-1'); + expect(del).toHaveBeenCalledWith('/api/registry/agents/my-bot/pods/pod-1?instanceId=default'); expect(result.backend.skipped).toBe(false); expect(result.backend.alreadyGone).toBeUndefined(); expect(result.localCleaned).toBe(true); @@ -75,6 +75,17 @@ describe('performDetach', () => { expect(fs.existsSync(sessionFilePath('my-bot'))).toBe(false); }); + test('targets the saved nondefault installation when detaching', async () => { + seedAgentState('my-bot'); + const del = jest.fn().mockResolvedValue({ success: true }); + + await performDetach({ + client: { del }, agentName: 'my-bot', podId: 'pod-1', instanceId: 'machine-2', + }); + + expect(del).toHaveBeenCalledWith('/api/registry/agents/my-bot/pods/pod-1?instanceId=machine-2'); + }); + test('treats backend 404 as "already uninstalled" and still cleans local state', async () => { seedAgentState('stale-bot'); diff --git a/cli/package-lock.json b/cli/package-lock.json index d52f3c8cf6..5adcfa88de 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.122", + "version": "0.1.132", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.122", + "version": "0.1.132", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 9b2aa00f99..5d514599d2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.122", + "version": "0.1.132", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 4a362e63cd..c1221fda67 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -2681,6 +2681,7 @@ export const performDetach = async ({ client, agentName, podId, + instanceId = 'default', skipBackend = false, log = () => {}, }) => { @@ -2696,7 +2697,7 @@ export const performDetach = async ({ let backendResult = { skipped: true }; if (!skipBackend) { try { - const body = await client.del(`/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}`); + const body = await client.del(`/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}?instanceId=${encodeURIComponent(instanceId)}`); backendResult = { skipped: false, body }; log(`backend: uninstalled '${agentName}' from pod ${podId}`); } catch (err) { @@ -3279,7 +3280,7 @@ Docs: console.error( `No local state for '${name}'. If the agent is still installed on ` + `the backend, uninstall it via the Agent Hub UI or:\n` - + ` curl -X DELETE /api/registry/agents/${name}/pods/ \\\n` + + ` curl -X DELETE '/api/registry/agents/${name}/pods/?instanceId=' \\\n` + ` -H "Authorization: Bearer "`, ); process.exit(1); @@ -3307,6 +3308,7 @@ Docs: client, agentName: record.agentName, podId: record.podId, + instanceId: record.instanceId || 'default', skipBackend: !!opts.force, log: (line) => console.warn(`[detach] ${line}`), }); diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 2787310a5f..ab9b6675f5 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -1209,11 +1209,12 @@ const ChatRoom = () => { } }; - const handleRemovePodAgent = async (agentName) => { + const handleRemovePodAgent = async (agentName, instanceId) => { const token = localStorage.getItem('token'); if (!token || !agentName || !roomId) return; try { await axios.delete(`/api/registry/agents/${agentName}/pods/${roomId}`, { + params: { instanceId: instanceId || 'default' }, headers: { Authorization: `Bearer ${token}` } }); await refreshPodAgents(); @@ -3000,7 +3001,7 @@ const ChatRoom = () => { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8bdad2dcd8..1cf9c8351f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2255,6 +2255,29 @@ "sentenceSeparator": " " }, "agentSettings": { + "wakeTitle": "Wake", + "mentionsAlwaysWake": "Mentions always wake this agent.", + "wakeOnMessage": "Wake on messages", + "humanOnly": "Human messages only", + "boardWake": "Wake on board changes", + "heartbeat": "Heartbeat", + "heartbeatInterval": "Interval", + "turnsPerDay": "{{count}} turns/day · {{cap}} available", + "capUnavailable": "{{count}} turns/day · plan cap unavailable", + "overCap": "This exceeds the daily cap.", + "saveInterval": "Save interval", + "everyMinutes_one": "Every minute", + "everyMinutes_other": "Every {{count}} minutes", + "podsTitle": "Pods", + "remove": "Remove from pod", + "removeConfirm": "Remove this agent from {{podName}}? Its identity and memory stay intact.", + "removeLastConfirm": "Remove this agent from its last pod, {{podName}}? Every key will be revoked and the agent will disconnect. Its identity and memory remain.", + "removeDisconnectConfirm": "Remove this agent from {{podName}}? No active installation will remain, so every key will be revoked and the agent will disconnect. Its identity and memory remain.", + "forbidden": "Only the installer or an admin can change this setting.", + "noPods": "No pods to manage", + "loadError": "Could not load settings.", + "saveError": "Could not save wake settings.", + "removeError": "Could not remove the agent from this pod.", "loading": "Loading agent settings…", "unavailable": "Agent settings unavailable.", "yourTeam": "Your team", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index d8fd38c82b..f3b824e790 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2246,6 +2246,29 @@ "sentenceSeparator": "" }, "agentSettings": { + "wakeTitle": "唤醒", + "mentionsAlwaysWake": "提及始终会唤醒此智能体。", + "wakeOnMessage": "收到消息时唤醒", + "humanOnly": "仅限真人消息", + "boardWake": "看板变更时唤醒", + "heartbeat": "心跳间隔", + "heartbeatInterval": "间隔", + "turnsPerDay": "每天{{count}}次 · 上限{{cap}}次", + "capUnavailable": "每天{{count}}次 · 套餐上限暂不可用", + "overCap": "已超过每日上限。", + "saveInterval": "保存间隔", + "everyMinutes_one": "每分钟", + "everyMinutes_other": "每{{count}}分钟", + "podsTitle": "Pod", + "remove": "从 Pod 中移除", + "removeConfirm": "从 {{podName}} 移除此智能体?身份和记忆会保留。", + "removeLastConfirm": "从最后一个 Pod「{{podName}}」移除此智能体?所有密钥都会撤销,智能体会断开连接。身份和记忆仍会保留。", + "removeDisconnectConfirm": "从「{{podName}}」移除此智能体?移除后没有活跃安装,所有密钥都会撤销,智能体会断开连接。身份和记忆仍会保留。", + "forbidden": "只有安装者或管理员可以更改此设置。", + "noPods": "没有可管理的 Pod", + "loadError": "无法加载设置。", + "saveError": "无法保存唤醒设置。", + "removeError": "无法从此 Pod 移除智能体。", "loading": "正在加载智能体设置…", "unavailable": "无法打开智能体设置。", "yourTeam": "你的团队", diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx new file mode 100644 index 0000000000..a3ccb6e1ba --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import V2AgentWakePods from '../agents/V2AgentWakePods'; +import i18n, { i18nReady } from '../../i18n'; + +jest.mock('../../utils/axiosConfig', () => ({ + __esModule: true, + default: { get: jest.fn(), patch: jest.fn(), delete: jest.fn() }, +})); + +const client = (jest.requireMock('../../utils/axiosConfig') as { + default: { get: jest.Mock; patch: jest.Mock; delete: jest.Mock }; +}).default; +const updatePod = jest.fn().mockResolvedValue({}); +const renderSettings = () => render( + , +); + +describe('per-agent Wake and Pods settings', () => { + beforeAll(async () => { + await i18nReady; + await i18n.changeLanguage('en'); + }); + + beforeEach(() => { + jest.clearAllMocks(); + updatePod.mockResolvedValue({}); + client.get.mockResolvedValue({ data: { turnCap: 30, activePodCount: 1, podCount: 1, installations: [{ + podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: true, humanOnly: true, + boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, + }] } }); + client.patch.mockResolvedValue({ data: {} }); + client.delete.mockResolvedValue({ data: {} }); + }); + + it('writes settings to the selected installation with the instance identity', async () => { + renderSettings(); + fireEvent.click(await screen.findByLabelText('Wake on board changes')); + await waitFor(() => expect(updatePod).toHaveBeenCalledWith( + 'pod-1', + { wakeOnMessage: true, humanOnly: true, boardWake: true, heartbeatEnabled: false, heartbeatMinutes: 60 }, + )); + }); + + it('removes a pod installation and leaves the settings view open', async () => { + const confirm = jest.spyOn(window, 'confirm').mockReturnValue(true); + renderSettings(); + fireEvent.click(await screen.findByRole('button', { name: 'Remove from pod' })); + await waitFor(() => expect(client.delete).toHaveBeenCalledWith( + '/api/registry/agents/test-agent/pods/pod-1?instanceId=my-machine', + )); + expect(await screen.findByText('No pods to manage')).toBeInTheDocument(); + confirm.mockRestore(); + }); + + it('does not show or restore the previous agent’s pod after identity changes', async () => { + let finishOld: (value: unknown) => void = () => {}; + client.get.mockImplementation((url: string) => url.includes('old-agent') + ? new Promise((resolve) => { finishOld = resolve; }) + : Promise.resolve({ data: { turnCap: 30, installations: [{ + podId: 'pod-new', podName: 'New pod', status: 'active', wakeOnMessage: false, humanOnly: false, + boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 30, + }] } })); + const view = render( + , + ); + view.rerender( + , + ); + expect((await screen.findAllByText('New pod')).length).toBe(2); + await act(async () => { finishOld({ data: { installations: [{ + podId: 'pod-old', podName: 'Old pod', wakeOnMessage: true, boardWake: true, heartbeatMinutes: 60, + }] } }); }); + expect(screen.queryByText('Old pod')).not.toBeInTheDocument(); + expect(screen.getAllByText('New pod')).toHaveLength(2); + }); + + it('previews heartbeat turns against the plan cap before saving', async () => { + renderSettings(); + expect(await screen.findByText('24 turns/day · 30 available')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Interval'), { target: { value: '30' } }); + expect(screen.getByText(/48 turns\/day · 30 available/)).toBeInTheDocument(); + expect(screen.getByText(/This exceeds the daily cap/)).toBeInTheDocument(); + expect(updatePod).not.toHaveBeenCalled(); + }); + + it('states the daily turn count clearly when the cap is unavailable', async () => { + client.get.mockResolvedValue({ data: { activePodCount: 1, podCount: 1, installations: [{ + podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: false, humanOnly: false, + boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, + }] } }); + renderSettings(); + expect(await screen.findByText('24 turns/day · plan cap unavailable')).toBeInTheDocument(); + }); + + it('warns about key revocation when removing the only active installation beside a paused one', async () => { + client.get.mockResolvedValue({ data: { turnCap: 30, activePodCount: 1, podCount: 2, installations: [ + { podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: true, + humanOnly: true, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }, + { podId: 'pod-2', podName: 'Paused', status: 'paused', wakeOnMessage: false, + humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }, + ] } }); + const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false); + renderSettings(); + fireEvent.click((await screen.findAllByRole('button', { name: 'Remove from pod' }))[0]); + expect(confirm).toHaveBeenCalledWith(expect.stringContaining('No active installation will remain')); + expect(client.delete).not.toHaveBeenCalled(); + confirm.mockRestore(); + }); +}); diff --git a/frontend/src/v2/__tests__/useAgentSettings.test.tsx b/frontend/src/v2/__tests__/useAgentSettings.test.tsx index bfeaec3f8f..84cacbe4c8 100644 --- a/frontend/src/v2/__tests__/useAgentSettings.test.tsx +++ b/frontend/src/v2/__tests__/useAgentSettings.test.tsx @@ -21,8 +21,9 @@ const Probe = () => { Save + wakeOnMessage: true, humanOnly: true, boardWake: false, + heartbeatEnabled: true, heartbeatMinutes: 60, + }); }}>Save Wake {podId} ; }; @@ -45,10 +46,11 @@ test('reads one accessible installation and patches it with the instance id', as { displayName: 'Maya Updated', instanceId: 'default' }, )); expect(screen.getByTestId('pod')).toHaveTextContent('pod-1'); - fireEvent.click(screen.getByRole('button', { name: 'Save pod' })); + fireEvent.click(screen.getByRole('button', { name: 'Save Wake' })); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( '/api/registry/agents/maya-claude/manage-settings/pods/pod-2', - { wakeOnMessage: true, boardWake: false, heartbeatMinutes: 60 }, + { wakeOnMessage: true, humanOnly: true, boardWake: false, + heartbeatEnabled: true, heartbeatMinutes: 60 }, { params: { instanceId: 'default' } }, )); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index f7521f2a46..fcb3b54174 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4681,3 +4681,14 @@ describe('GitHub gig card fits the chat column', () => { expect(phone).toContain('padding-left: 0'); }); }); + +describe('agent Wake and Pods controls', () => { + const css = read('../agents/v2-agent-wake-pods.css'); + test('action buttons keep a visible surface over the v2 button reset', () => { + const save = ruleBody(css, '.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod > button'); + const remove = ruleBody(css, '.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod-row button'); + expect(save).toContain('background: var(--v2-surface)'); + expect(remove).toContain('background: var(--v2-surface)'); + expect(remove).toContain('border: 1px solid var(--v2-border)'); + }); +}); diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx index 47bb79126c..eef443167f 100644 --- a/frontend/src/v2/agents/V2AgentProfile.tsx +++ b/frontend/src/v2/agents/V2AgentProfile.tsx @@ -168,7 +168,7 @@ const V2AgentProfile: React.FC = () => { setRemoving(podId); try { await getClient().delete( - `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}`, + `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}?instanceId=${encodeURIComponent(instanceId || 'default')}`, { headers: { Authorization: `Bearer ${token}` } }, ); await Promise.all([fetchProfile(), fetchMemoryIndex()]); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 5c54408f5b..b0d6c50224 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import V2PageHead from '../components/V2PageHead'; import { useAgentSettings } from './useAgentSettings'; +import V2AgentWakePods from './V2AgentWakePods'; import './v2-agent-settings.css'; interface RuntimeKey { @@ -30,7 +31,8 @@ const errorLabel = (error: unknown, fallback: string): string => ( const V2AgentSettingsPage: React.FC = () => { const { t } = useTranslation(); const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>(); - const { access, installation, podId, loading, error: loadError } = useAgentSettings(agentName, instanceId); + const { access, installation, podId, loading, error: loadError, refresh, updatePod } = useAgentSettings(agentName, instanceId); + const [removedLast, setRemovedLast] = useState(false); const [keys, setKeys] = useState([]); const [keysReadyFor, setKeysReadyFor] = useState(null); const [error, setError] = useState(null); @@ -118,7 +120,17 @@ const V2AgentSettingsPage: React.FC = () => { } }; + const onRemoved = (_podId: string, wasLastPod: boolean) => { + if (!access) return; + setKeys([]); + setPendingAction(null); + setOneTimeKey(null); + if (wasLastPod) setRemovedLast(true); + else void refresh().catch((requestError) => setError(errorLabel(requestError, t('agentSettings.unavailable')))); + }; + if (loading) return
{t('agentSettings.loading')}
; + if (removedLast) return
{t('agentSettings.noPod')}
; if (!access || !podId) return
{error || errorLabel(loadError, t('agentSettings.unavailable'))}
; @@ -193,6 +205,7 @@ const V2AgentSettingsPage: React.FC = () => { )} + ); }; diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx new file mode 100644 index 0000000000..96b4ee20f6 --- /dev/null +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import axios from '../../utils/axiosConfig'; +import { AgentPodWakePatch } from './useAgentSettings'; +import './v2-agent-wake-pods.css'; + +interface InstallationSettings { + podId: string; + podName: string; + status: string; + wakeOnMessage: boolean; + humanOnly: boolean; + boardWake: boolean; + heartbeatEnabled: boolean; + heartbeatMinutes: number; +} + +const V2AgentWakePods: React.FC<{ + agentName: string; + instanceId: string; + updatePod: (podId: string, patch: AgentPodWakePatch) => Promise; + onRemoved?: (podId: string, wasLastPod: boolean) => void; +}> = ({ agentName, instanceId, updatePod, onRemoved }) => { + const { t } = useTranslation(); + const [installations, setInstallations] = useState([]); + const [busyPod, setBusyPod] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [activePodCount, setActivePodCount] = useState(0); + const [podCount, setPodCount] = useState(0); + const [turnCap, setTurnCap] = useState(null); + const [draftMinutes, setDraftMinutes] = useState>({}); + const [loadedIdentity, setLoadedIdentity] = useState(null); + const query = `instanceId=${encodeURIComponent(instanceId)}`; + const base = `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`; + const identity = `${agentName}:${instanceId}`; + const identityRef = useRef(identity); + identityRef.current = identity; + + useEffect(() => { + let active = true; + setLoading(true); + setInstallations([]); + setBusyPod(null); + setError(null); + setDraftMinutes({}); + const load = async () => { + try { + const settingsResult = await axios.get<{ + installations: InstallationSettings[]; activePodCount: number; podCount: number; turnCap?: number; + }>(`${base}?${query}`); + if (!active) return; + const rows = Array.isArray(settingsResult.data.installations) ? settingsResult.data.installations : []; + setInstallations(rows); + setActivePodCount(settingsResult.data.activePodCount ?? rows.filter((row) => row.status === 'active').length); + setPodCount(settingsResult.data.podCount ?? rows.length); + setDraftMinutes(Object.fromEntries(rows.map((row) => [row.podId, row.heartbeatMinutes]))); + setTurnCap(settingsResult.data.turnCap ?? null); + } catch (requestError) { + if (active) setError((requestError as { response?: { status?: number } })?.response?.status === 403 + ? t('agentSettings.forbidden') : t('agentSettings.loadError')); + } finally { + if (active) { + setLoadedIdentity(identity); + setLoading(false); + } + } + }; + void load(); + return () => { active = false; }; + }, [base, query, identity, t]); + + const save = async (entry: InstallationSettings, changes: Partial) => { + const requestedIdentity = identity; + setBusyPod(entry.podId); + setError(null); + try { + const next = { ...entry, ...changes }; + await updatePod(entry.podId, { + wakeOnMessage: next.wakeOnMessage, + boardWake: next.boardWake, + humanOnly: next.humanOnly, + heartbeatEnabled: next.heartbeatEnabled, + heartbeatMinutes: next.heartbeatMinutes, + }); + if (identityRef.current === requestedIdentity) { + setInstallations((current) => current.map((item) => item.podId === entry.podId ? next : item)); + } + } catch (requestError) { + if (identityRef.current === requestedIdentity) setError((requestError as { response?: { status?: number } })?.response?.status === 403 + ? t('agentSettings.forbidden') : t('agentSettings.saveError')); + } finally { + if (identityRef.current === requestedIdentity) setBusyPod(null); + } + }; + + const remove = async (entry: InstallationSettings) => { + // eslint-disable-next-line no-alert + const willRevokeKeys = activePodCount <= (entry.status === 'active' ? 1 : 0); + const confirmKey = podCount === 1 ? 'agentSettings.removeLastConfirm' + : willRevokeKeys ? 'agentSettings.removeDisconnectConfirm' : 'agentSettings.removeConfirm'; + if (!window.confirm(t(confirmKey, + { podName: entry.podName }))) return; + const requestedIdentity = identity; + setBusyPod(entry.podId); + setError(null); + try { + await axios.delete( + `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(entry.podId)}?${query}`, + ); + if (identityRef.current === requestedIdentity) { + setInstallations((current) => current.filter((item) => item.podId !== entry.podId)); + setPodCount((count) => Math.max(0, count - 1)); + if (entry.status === 'active') setActivePodCount((count) => Math.max(0, count - 1)); + onRemoved?.(entry.podId, podCount === 1); + } + } catch (requestError) { + if (identityRef.current === requestedIdentity) setError((requestError as { response?: { status?: number } })?.response?.status === 403 + ? t('agentSettings.forbidden') : t('agentSettings.removeError')); + } finally { + if (identityRef.current === requestedIdentity) setBusyPod(null); + } + }; + + if (loading || loadedIdentity !== identity) { + return
{t('agentProfile.loading')}
; + } + + return ( +
+ {error &&

{error}

} +
+

{t('agentSettings.wakeTitle')}

+

{t('agentSettings.mentionsAlwaysWake')}

+ {installations.map((entry) => ( +
+ {entry.podName} + + {entry.wakeOnMessage && } + + + +

+ {turnCap === null + ? t('agentSettings.capUnavailable', { + count: Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)), + }) + : t('agentSettings.turnsPerDay', { + count: Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)), cap: turnCap, + })} + {turnCap !== null && Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)) > turnCap + && ` ${t('agentSettings.overCap')}`} +

+ {entry.heartbeatEnabled && draftMinutes[entry.podId] !== entry.heartbeatMinutes + && } +
+ ))} +
+
+

{t('agentSettings.podsTitle')}

+ {installations.length ? installations.map((entry) => ( +
+ {entry.podName} + +
+ )) :

{t('agentSettings.noPods')}

} +
+
+ ); +}; + +export default V2AgentWakePods; diff --git a/frontend/src/v2/agents/useAgentSettings.ts b/frontend/src/v2/agents/useAgentSettings.ts index 8905b65aa2..e32e7e1b10 100644 --- a/frontend/src/v2/agents/useAgentSettings.ts +++ b/frontend/src/v2/agents/useAgentSettings.ts @@ -24,7 +24,9 @@ export interface AgentSettingsPatch { export interface AgentPodWakePatch { wakeOnMessage: boolean; + humanOnly: boolean; boardWake: boolean; + heartbeatEnabled: boolean; heartbeatMinutes: number; } @@ -67,7 +69,9 @@ export const useAgentSettings = (agentName: string, instanceId: string) => { } return response.data.agent; } catch (candidateError) { - if ((candidateError as { response?: { status?: number } })?.response?.status !== 403) throw candidateError; + if (![403, 404].includes((candidateError as { response?: { status?: number } })?.response?.status || 0)) { + throw candidateError; + } } } throw new Error('No accessible installation'); diff --git a/frontend/src/v2/agents/v2-agent-wake-pods.css b/frontend/src/v2/agents/v2-agent-wake-pods.css new file mode 100644 index 0000000000..864f37845a --- /dev/null +++ b/frontend/src/v2/agents/v2-agent-wake-pods.css @@ -0,0 +1,17 @@ +.v2-agent-wake-pods { display: grid; gap: 24px; } +.v2-agent-wake-pods__section { border: 1px solid var(--v2-border); border-radius: 6px; padding: 20px; background: var(--v2-surface); } +.v2-agent-wake-pods__section h2 { margin: 0 0 16px; color: var(--v2-text-primary); font: 700 20px/28px var(--v2-font-display); } +.v2-agent-wake-pods__pod + .v2-agent-wake-pods__pod { border-top: 1px solid var(--v2-border-soft); margin-top: 16px; padding-top: 16px; } +.v2-agent-wake-pods__pod > a, .v2-agent-wake-pods__pod-row > a { color: var(--v2-accent-text); font: 600 14px/20px var(--v2-font); } +.v2-agent-wake-pods__row, .v2-agent-wake-pods__pod-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 44px; color: var(--v2-text-primary); font: 500 14px/20px var(--v2-font); } +.v2-agent-wake-pods__row input { width: 18px; height: 18px; accent-color: var(--v2-ink); } +.v2-agent-wake-pods__subrow { padding-left: 18px; } +.v2-agent-wake-pods__hint { margin: 4px 0 12px; color: var(--v2-text-muted); font: 400 13px/19px var(--v2-font); } +.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod > button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } +.v2-agent-wake-pods__row select { max-width: 55%; border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px; background: var(--v2-surface); color: var(--v2-text-primary); } +.v2-agent-wake-pods__pod-row + .v2-agent-wake-pods__pod-row { border-top: 1px solid var(--v2-border-soft); } +.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod-row button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-secondary); cursor: pointer; } +.v2-agent-wake-pods__pod-row button:disabled { opacity: .5; cursor: default; } +.v2-agent-wake-pods__error { margin: 0; color: var(--v2-danger); } +.v2-agent-wake-pods__empty, .v2-agent-wake-pods__loading { margin: 0; color: var(--v2-text-muted); } +@media (max-width: 600px) { .v2-agent-wake-pods__section { padding: 16px; } }