mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 08:52:03 +00:00
Polish agent inspect UX and approval resume behavior
This commit is contained in:
@@ -41,31 +41,39 @@ The Gateway (OpenClaw) is the enforcement point:
|
||||
|
||||
## Studio: Where “Permissions” Are Chosen
|
||||
|
||||
Agent creation is now create-only:
|
||||
Agent creation is intentionally lightweight:
|
||||
- `src/features/agents/components/AgentCreateModal.tsx` captures `name` and optional avatar shuffle seed.
|
||||
- `src/features/agents/operations/createAgentMutationLifecycleOperation.ts` applies queue/guard behavior and calls create.
|
||||
- `src/features/agents/operations/mutationLifecycleWorkflow.ts` applies queue/guard behavior and calls create.
|
||||
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) performs `config.get` + `agents.create`.
|
||||
|
||||
Authority/permission changes happen after creation from settings:
|
||||
After creation, Studio applies a safe default capability envelope:
|
||||
- Commands: `Ask`
|
||||
- Web access: `Off`
|
||||
- File tools: `Off`
|
||||
|
||||
Implementation:
|
||||
- `src/app/page.tsx` (`handleCreateAgentSubmit`) applies `CREATE_AGENT_DEFAULT_PERMISSIONS`.
|
||||
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`) persists those defaults.
|
||||
|
||||
Further capability changes happen from the `Capabilities` tab:
|
||||
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`)
|
||||
- updates per-agent exec approvals (`exec.approvals.get` + `exec.approvals.set`)
|
||||
- updates tool-group overrides for runtime, web, and file access (`config.get` + `config.patch` via `updateGatewayAgentOverrides`)
|
||||
- updates session exec behavior (`sessions.patch` via `syncGatewaySessionSettings`)
|
||||
- `src/features/agents/operations/executionRoleUpdateOperation.ts` remains for execution-role-only updates and bootstrap paths.
|
||||
|
||||
### Runtime Tool Groups Used By Post-Create Role Updates
|
||||
### Runtime Tool Groups Used By Capability Updates
|
||||
|
||||
Studio role updates still rely on OpenClaw tool-group expansion (`openclaw/src/agents/tool-policy.ts`), especially:
|
||||
Studio capability updates rely on OpenClaw tool-group expansion (`openclaw/src/agents/tool-policy.ts`), especially:
|
||||
- `group:runtime` -> runtime execution tools (`exec`, `process`)
|
||||
|
||||
What this means in practice:
|
||||
- Conservative role removes runtime group access and sets exec approvals to deny.
|
||||
- Collaborative/autonomous roles include runtime group access and set exec approvals to allowlist/full respectively.
|
||||
Internal mapping detail:
|
||||
- Command mode `off|ask|auto` maps to role logic (`conservative|collaborative|autonomous`) for policy generation.
|
||||
- UI exposes direct capability controls, not role labels.
|
||||
|
||||
## Studio -> Gateway: “Create Agent” End-to-End
|
||||
|
||||
Primary entry points:
|
||||
- `src/features/agents/operations/createAgentMutationLifecycleOperation.ts`
|
||||
- `src/features/agents/operations/mutationLifecycleWorkflow.ts`
|
||||
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`)
|
||||
|
||||
Sequence:
|
||||
@@ -228,8 +236,8 @@ This is why “`workspaceAccess=ro`” means more than “mount it read-only”:
|
||||
|
||||
Studio create flow no longer compiles authority/sandbox settings during initial create.
|
||||
|
||||
When authority is changed post-create, Studio uses:
|
||||
- `src/features/agents/operations/executionRoleUpdateOperation.ts`
|
||||
When capabilities are changed post-create, Studio uses:
|
||||
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`)
|
||||
|
||||
That operation updates:
|
||||
- exec approvals policy (`exec.approvals.set`)
|
||||
@@ -312,7 +320,7 @@ Studio wiring for UX:
|
||||
|
||||
Studio can also change permissions after an agent exists.
|
||||
|
||||
### Capabilities Permissions Updates (Preset + Advanced)
|
||||
### Capabilities Permissions Updates
|
||||
|
||||
Studio’s permissions flow applies coordinated changes from one save action:
|
||||
- Exec approvals policy (per-agent, persisted in exec approvals file)
|
||||
@@ -323,13 +331,8 @@ Code:
|
||||
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`)
|
||||
|
||||
UI model:
|
||||
- Presets: `Conservative`, `Collaborative`, `Autonomous`
|
||||
- Advanced controls: `Command mode` (`Off`/`Ask`/`Auto`), `Web access` (`Off`/`On`), `File tools` (`Off`/`On`)
|
||||
- Create modal remains permission-free; permissions are configured only after creation.
|
||||
|
||||
Terminology note:
|
||||
- Current builds may still label this surface as `Settings`.
|
||||
- Planned IA renames this to `Capabilities` and splits `Schedule` and `Advanced` into separate surfaces.
|
||||
- Direct controls: `Command mode` (`Off`/`Ask`/`Auto`), `Web access` (`Off`/`On`), `File tools` (`Off`/`On`)
|
||||
- Create modal remains permission-light (name/avatar only) and create flow immediately applies safe defaults (`Ask`, web off, file tools off).
|
||||
|
||||
Why it matters:
|
||||
- You can have exec approvals configured but still be unable to run commands if `group:runtime` is denied.
|
||||
|
||||
+47
-48
@@ -1,58 +1,57 @@
|
||||
# Studio UI Guide
|
||||
|
||||
This doc covers Studio UI behavior and near-term UX direction.
|
||||
This doc describes the current Studio IA and behavior.
|
||||
|
||||
## Current Behavior (Today)
|
||||
## Agent Surfaces
|
||||
|
||||
### Agent creation workflow
|
||||
- Click **New Agent** in the fleet sidebar.
|
||||
- Enter an agent name and avatar, then create.
|
||||
- The create modal does not include permission controls.
|
||||
- After create succeeds, Studio currently opens **Settings** for the new agent.
|
||||
### Chat (default)
|
||||
- Selecting an agent opens chat as the primary workspace.
|
||||
- Chat header controls include:
|
||||
- New session
|
||||
- Personality shortcut
|
||||
- Settings shortcut
|
||||
- New session resets the current agent session and clears visible transcript state in Studio.
|
||||
|
||||
### Cron jobs
|
||||
- Open an agent and go to **Settings -> Cron jobs**.
|
||||
- If no jobs exist, use the empty-state **Create** button.
|
||||
- If jobs already exist, use the header **Create** button.
|
||||
- The modal is agent-scoped and walks through template selection, task text, schedule, and review.
|
||||
- Submitting creates the job via gateway `cron.add` and refreshes that same agent's cron list.
|
||||
### Settings Sidebar
|
||||
- The settings cog opens one sidebar with four tabs:
|
||||
1. Personality
|
||||
2. Capabilities
|
||||
3. Automations
|
||||
4. Advanced
|
||||
|
||||
### Exec approvals in chat
|
||||
- When a run requires exec approval, chat shows an **Exec approval required** card with command preview, host/cwd, and expiration.
|
||||
- Resolve directly in chat with:
|
||||
- **Allow once**
|
||||
- **Always allow**
|
||||
- **Deny**
|
||||
- The fleet row displays **Needs approval** while approvals are pending for that agent.
|
||||
## Personality
|
||||
- Personality is the first tab when opening settings.
|
||||
- Rename agent lives in Personality.
|
||||
- Personality file tabs are intentionally limited to:
|
||||
- Personality (`SOUL.md`)
|
||||
- Instructions (`AGENTS.md`)
|
||||
- About You (`USER.md`)
|
||||
- Identity (`IDENTITY.md`)
|
||||
- Underlying persistence still saves the full gateway-backed agent file set.
|
||||
|
||||
## Direction (Planned)
|
||||
## Capabilities
|
||||
- Capabilities exposes direct controls (no role preset labels):
|
||||
- Run commands: Off / Ask / Auto
|
||||
- Web access: Off / On
|
||||
- File tools: Off / On
|
||||
- Skills and Browser automation are visible as coming-soon toggles.
|
||||
|
||||
Studio is moving to a capability-first IA for non-technical users.
|
||||
## Automations
|
||||
- User-facing language is schedules/automations (not cron-first terminology).
|
||||
- Schedule creation uses template -> task -> schedule -> review flow.
|
||||
- Heartbeats are represented in this tab as coming soon.
|
||||
|
||||
### Per-agent surfaces
|
||||
1. `Chat` (default)
|
||||
2. `Personality`
|
||||
3. `Capabilities`
|
||||
4. `Schedule`
|
||||
5. `Advanced`
|
||||
## Advanced
|
||||
- Advanced contains:
|
||||
- Display toggles (Show tool calls, Show thinking)
|
||||
- Open Full Control UI
|
||||
- Delete agent (danger zone)
|
||||
- Session controls are not in Advanced.
|
||||
|
||||
### Naming and scope changes
|
||||
- `Brain` is being renamed to `Personality`.
|
||||
- `Settings` is being split into `Capabilities`, `Schedule`, and `Advanced`.
|
||||
- `Delete agent` moves to `Advanced` danger zone only.
|
||||
- `New session` moves to a chat-adjacent header action.
|
||||
- Personality editor should use friendly labels over raw filenames:
|
||||
- `Instructions` for `AGENTS.md`
|
||||
- `About You` for `USER.md`
|
||||
- `Personality` context for `SOUL.md` + `IDENTITY.md`
|
||||
|
||||
### Capability-first model (planned)
|
||||
- Commands: `Off` / `Ask before running` / `Run automatically`
|
||||
- Web research: `Off` / `On`
|
||||
- Browser automation: `Off` / `On`
|
||||
- Files: `Off` / `Read-only` / `Read & write`
|
||||
- Automations: managed in `Schedule`
|
||||
- Integrations: skill enable/disable and API key setup
|
||||
|
||||
### Defaults direction
|
||||
- New agents should move toward a collaborative/safe default envelope rather than autonomous-by-default behavior.
|
||||
## Agent Creation Defaults
|
||||
- Create modal captures only name/avatar.
|
||||
- After creation, Studio applies safe defaults:
|
||||
- Commands: Ask
|
||||
- Web access: Off
|
||||
- File tools: Off
|
||||
- Post-create UX keeps chat as primary and auto-opens Capabilities sidebar for onboarding.
|
||||
|
||||
+261
-291
@@ -55,13 +55,9 @@ import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder";
|
||||
import {
|
||||
createGatewayAgent,
|
||||
renameGatewayAgent,
|
||||
removeGatewayHeartbeatOverride,
|
||||
listHeartbeatsForAgent,
|
||||
readConfigAgentList,
|
||||
slugifyAgentName,
|
||||
triggerHeartbeatNow,
|
||||
updateGatewayAgentOverrides,
|
||||
type AgentHeartbeatSummary,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
|
||||
import { createStudioSettingsCoordinator } from "@/lib/studio/coordinator";
|
||||
@@ -107,7 +103,6 @@ import {
|
||||
} from "@/features/agents/operations/latestUpdateWorkflow";
|
||||
import { createSpecialLatestUpdateOperation } from "@/features/agents/operations/specialLatestUpdateOperation";
|
||||
import {
|
||||
updateExecutionRoleViaStudio,
|
||||
updateAgentPermissionsViaStudio,
|
||||
resolveAgentPermissionsDraft,
|
||||
type AgentPermissionsDraft,
|
||||
@@ -138,12 +133,78 @@ import {
|
||||
const DEFAULT_CHAT_HISTORY_LIMIT = 200;
|
||||
const MAX_CHAT_HISTORY_LIMIT = 5000;
|
||||
const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500;
|
||||
const EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS = 3_000;
|
||||
const CREATE_AGENT_DEFAULT_PERMISSIONS: AgentPermissionsDraft = {
|
||||
commandMode: "ask",
|
||||
webAccess: false,
|
||||
fileTools: false,
|
||||
};
|
||||
|
||||
type MobilePane = "fleet" | "chat" | "settings" | "brain";
|
||||
type MobilePane = "fleet" | "chat" | "settings";
|
||||
type InspectSidebarTab = "personality" | "capabilities" | "automations" | "advanced";
|
||||
type InspectSidebarState = { agentId: string; tab: InspectSidebarTab } | null;
|
||||
type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind };
|
||||
|
||||
const RESERVED_MAIN_AGENT_ID = "main";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const normalizeControlUiBasePath = (basePath: string): string => {
|
||||
let normalized = basePath.trim();
|
||||
if (!normalized || normalized === "/") return "";
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const resolveControlUiUrl = (params: {
|
||||
gatewayUrl: string;
|
||||
configSnapshot: GatewayModelPolicySnapshot | null;
|
||||
}): string | null => {
|
||||
const rawGatewayUrl = params.gatewayUrl.trim();
|
||||
if (!rawGatewayUrl) return null;
|
||||
|
||||
let controlUiEnabled = true;
|
||||
let controlUiBasePath = "";
|
||||
|
||||
const config = params.configSnapshot?.config;
|
||||
if (isRecord(config)) {
|
||||
const configRecord = config as Record<string, unknown>;
|
||||
const gateway = isRecord(configRecord["gateway"])
|
||||
? (configRecord["gateway"] as Record<string, unknown>)
|
||||
: null;
|
||||
const controlUi = gateway && isRecord(gateway.controlUi) ? gateway.controlUi : null;
|
||||
if (controlUi && typeof controlUi.enabled === "boolean") {
|
||||
controlUiEnabled = controlUi.enabled;
|
||||
}
|
||||
if (typeof controlUi?.basePath === "string") {
|
||||
controlUiBasePath = normalizeControlUiBasePath(controlUi.basePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!controlUiEnabled) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(rawGatewayUrl);
|
||||
if (url.protocol === "ws:") {
|
||||
url.protocol = "http:";
|
||||
} else if (url.protocol === "wss:") {
|
||||
url.protocol = "https:";
|
||||
}
|
||||
url.pathname = controlUiBasePath ? `${controlUiBasePath}/` : "/";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveNextNewAgentName = (agents: AgentState[]) => {
|
||||
const baseName = "New Agent";
|
||||
const existingNames = new Set(
|
||||
@@ -200,19 +261,13 @@ const AgentStudioPage = () => {
|
||||
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(null);
|
||||
const [stopBusyAgentId, setStopBusyAgentId] = useState<string | null>(null);
|
||||
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
|
||||
const [settingsAgentId, setSettingsAgentId] = useState<string | null>(null);
|
||||
const [inspectSidebar, setInspectSidebar] = useState<InspectSidebarState>(null);
|
||||
const [settingsCronJobs, setSettingsCronJobs] = useState<CronJobSummary[]>([]);
|
||||
const [settingsCronLoading, setSettingsCronLoading] = useState(false);
|
||||
const [settingsCronError, setSettingsCronError] = useState<string | null>(null);
|
||||
const [cronCreateBusy, setCronCreateBusy] = useState(false);
|
||||
const [cronRunBusyJobId, setCronRunBusyJobId] = useState<string | null>(null);
|
||||
const [cronDeleteBusyJobId, setCronDeleteBusyJobId] = useState<string | null>(null);
|
||||
const [settingsHeartbeats, setSettingsHeartbeats] = useState<AgentHeartbeatSummary[]>([]);
|
||||
const [settingsHeartbeatLoading, setSettingsHeartbeatLoading] = useState(false);
|
||||
const [settingsHeartbeatError, setSettingsHeartbeatError] = useState<string | null>(null);
|
||||
const [heartbeatRunBusyId, setHeartbeatRunBusyId] = useState<string | null>(null);
|
||||
const [heartbeatDeleteBusyId, setHeartbeatDeleteBusyId] = useState<string | null>(null);
|
||||
const [brainPanelOpen, setBrainPanelOpen] = useState(false);
|
||||
const [createAgentBlock, setCreateAgentBlock] = useState<CreateAgentBlockState | null>(null);
|
||||
const [restartingMutationBlock, setRestartingMutationBlock] =
|
||||
useState<RestartingMutationBlockState | null>(null);
|
||||
@@ -258,12 +313,14 @@ const AgentStudioPage = () => {
|
||||
if (!lastMessage || !isHeartbeatPrompt(lastMessage)) return null;
|
||||
return "This task is running as an automatic heartbeat check. Stopping heartbeat runs from Studio isn't available yet (coming soon).";
|
||||
}, [focusedAgent]);
|
||||
const settingsAgent = useMemo(() => {
|
||||
if (!settingsAgentId) return null;
|
||||
return agents.find((entry) => entry.agentId === settingsAgentId) ?? null;
|
||||
}, [agents, settingsAgentId]);
|
||||
const inspectSidebarAgentId = inspectSidebar?.agentId ?? null;
|
||||
const inspectSidebarTab = inspectSidebar?.tab ?? null;
|
||||
const inspectSidebarAgent = useMemo(() => {
|
||||
if (!inspectSidebarAgentId) return null;
|
||||
return agents.find((entry) => entry.agentId === inspectSidebarAgentId) ?? null;
|
||||
}, [agents, inspectSidebarAgentId]);
|
||||
const settingsAgentPermissionsDraft = useMemo(() => {
|
||||
if (!settingsAgent) return null;
|
||||
if (!inspectSidebarAgent) return null;
|
||||
const baseConfig =
|
||||
gatewayConfigSnapshot?.config &&
|
||||
typeof gatewayConfigSnapshot.config === "object" &&
|
||||
@@ -271,7 +328,7 @@ const AgentStudioPage = () => {
|
||||
? (gatewayConfigSnapshot.config as Record<string, unknown>)
|
||||
: undefined;
|
||||
const list = readConfigAgentList(baseConfig);
|
||||
const configEntry = list.find((entry) => entry.id === settingsAgent.agentId) ?? null;
|
||||
const configEntry = list.find((entry) => entry.id === inspectSidebarAgent.agentId) ?? null;
|
||||
const toolsRaw =
|
||||
configEntry && typeof (configEntry as Record<string, unknown>).tools === "object"
|
||||
? ((configEntry as Record<string, unknown>).tools as unknown)
|
||||
@@ -281,13 +338,10 @@ const AgentStudioPage = () => {
|
||||
? (toolsRaw as Record<string, unknown>)
|
||||
: null;
|
||||
return resolveAgentPermissionsDraft({
|
||||
agent: settingsAgent,
|
||||
agent: inspectSidebarAgent,
|
||||
existingTools: tools,
|
||||
});
|
||||
}, [gatewayConfigSnapshot, settingsAgent]);
|
||||
const selectedBrainAgentId = useMemo(() => {
|
||||
return focusedAgent?.agentId ?? agents[0]?.agentId ?? null;
|
||||
}, [agents, focusedAgent]);
|
||||
}, [gatewayConfigSnapshot, inspectSidebarAgent]);
|
||||
const focusedPendingExecApprovals = useMemo(() => {
|
||||
if (!focusedAgentId) return unscopedPendingExecApprovals;
|
||||
const scoped = pendingExecApprovalsByAgentId[focusedAgentId] ?? [];
|
||||
@@ -319,6 +373,10 @@ const AgentStudioPage = () => {
|
||||
);
|
||||
const hasRunningAgents = runningAgentCount > 0;
|
||||
const isLocalGateway = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]);
|
||||
const controlUiUrl = useMemo(
|
||||
() => resolveControlUiUrl({ gatewayUrl, configSnapshot: gatewayConfigSnapshot }),
|
||||
[gatewayConfigSnapshot, gatewayUrl]
|
||||
);
|
||||
|
||||
const hasRenameMutationBlock = restartingMutationBlock?.kind === "rename-agent";
|
||||
const hasDeleteMutationBlock = restartingMutationBlock?.kind === "delete-agent";
|
||||
@@ -462,7 +520,7 @@ const AgentStudioPage = () => {
|
||||
const resolvedAgentId = agentId.trim();
|
||||
if (!resolvedAgentId) {
|
||||
setSettingsCronJobs([]);
|
||||
setSettingsCronError("Failed to load cron jobs: missing agent id.");
|
||||
setSettingsCronError("Failed to load schedules: missing agent id.");
|
||||
return;
|
||||
}
|
||||
setSettingsCronLoading(true);
|
||||
@@ -472,7 +530,7 @@ const AgentStudioPage = () => {
|
||||
const filtered = filterCronJobsForAgent(result.jobs, resolvedAgentId);
|
||||
setSettingsCronJobs(sortCronJobsByUpdatedAt(filtered));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load cron jobs.";
|
||||
const message = err instanceof Error ? err.message : "Failed to load schedules.";
|
||||
setSettingsCronJobs([]);
|
||||
setSettingsCronError(message);
|
||||
if (!isGatewayDisconnectLikeError(err)) {
|
||||
@@ -485,33 +543,6 @@ const AgentStudioPage = () => {
|
||||
[client]
|
||||
);
|
||||
|
||||
const loadHeartbeatsForSettingsAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
const resolvedAgentId = agentId.trim();
|
||||
if (!resolvedAgentId) {
|
||||
setSettingsHeartbeats([]);
|
||||
setSettingsHeartbeatError("Failed to load heartbeats: missing agent id.");
|
||||
return;
|
||||
}
|
||||
setSettingsHeartbeatLoading(true);
|
||||
setSettingsHeartbeatError(null);
|
||||
try {
|
||||
const result = await listHeartbeatsForAgent(client, resolvedAgentId);
|
||||
setSettingsHeartbeats(result.heartbeats);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load heartbeats.";
|
||||
setSettingsHeartbeats([]);
|
||||
setSettingsHeartbeatError(message);
|
||||
if (!isGatewayDisconnectLikeError(err)) {
|
||||
console.error(message);
|
||||
}
|
||||
} finally {
|
||||
setSettingsHeartbeatLoading(false);
|
||||
}
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
const refreshHeartbeatLatestUpdate = useCallback(() => {
|
||||
const agents = stateRef.current.agents;
|
||||
specialLatestUpdate.refreshHeartbeat(agents);
|
||||
@@ -790,41 +821,46 @@ const AgentStudioPage = () => {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsAgentId) return;
|
||||
if (state.selectedAgentId && state.selectedAgentId !== settingsAgentId) {
|
||||
setSettingsAgentId(null);
|
||||
if (!inspectSidebar) return;
|
||||
const selectedAgentId = state.selectedAgentId?.trim() ?? "";
|
||||
if (!selectedAgentId) {
|
||||
setInspectSidebar(null);
|
||||
return;
|
||||
}
|
||||
}, [settingsAgentId, state.selectedAgentId]);
|
||||
if (inspectSidebar.agentId === selectedAgentId) return;
|
||||
setInspectSidebar((current) =>
|
||||
current ? { ...current, agentId: selectedAgentId } : current
|
||||
);
|
||||
}, [inspectSidebar, state.selectedAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsAgentId && !settingsAgent) {
|
||||
setSettingsAgentId(null);
|
||||
if (inspectSidebarAgentId && !inspectSidebarAgent) {
|
||||
setInspectSidebar(null);
|
||||
}
|
||||
}, [settingsAgentId, settingsAgent]);
|
||||
}, [inspectSidebarAgent, inspectSidebarAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsAgentId) return;
|
||||
if (!inspectSidebarAgentId) return;
|
||||
if (status !== "connected") return;
|
||||
void refreshGatewayConfigSnapshot();
|
||||
}, [refreshGatewayConfigSnapshot, settingsAgentId, status]);
|
||||
}, [refreshGatewayConfigSnapshot, inspectSidebarAgentId, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsAgentId || status !== "connected") {
|
||||
if (!inspectSidebarAgentId || status !== "connected" || inspectSidebarTab !== "automations") {
|
||||
setSettingsCronJobs([]);
|
||||
setSettingsCronLoading(false);
|
||||
setSettingsCronError(null);
|
||||
setCronRunBusyJobId(null);
|
||||
setCronDeleteBusyJobId(null);
|
||||
setSettingsHeartbeats([]);
|
||||
setSettingsHeartbeatLoading(false);
|
||||
setSettingsHeartbeatError(null);
|
||||
setHeartbeatRunBusyId(null);
|
||||
setHeartbeatDeleteBusyId(null);
|
||||
return;
|
||||
}
|
||||
void loadCronJobsForSettingsAgent(settingsAgentId);
|
||||
void loadHeartbeatsForSettingsAgent(settingsAgentId);
|
||||
}, [loadCronJobsForSettingsAgent, loadHeartbeatsForSettingsAgent, settingsAgentId, status]);
|
||||
void loadCronJobsForSettingsAgent(inspectSidebarAgentId);
|
||||
}, [
|
||||
inspectSidebarAgentId,
|
||||
inspectSidebarTab,
|
||||
loadCronJobsForSettingsAgent,
|
||||
status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const nowMs = Date.now();
|
||||
@@ -872,23 +908,11 @@ const AgentStudioPage = () => {
|
||||
}
|
||||
}, [agents, dispatch, pendingExecApprovalsByAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!brainPanelOpen) return;
|
||||
if (selectedBrainAgentId) return;
|
||||
setBrainPanelOpen(false);
|
||||
}, [brainPanelOpen, selectedBrainAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mobilePane !== "settings") return;
|
||||
if (settingsAgent) return;
|
||||
if (inspectSidebarAgent) return;
|
||||
setMobilePane("chat");
|
||||
}, [mobilePane, settingsAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mobilePane !== "brain") return;
|
||||
if (brainPanelOpen && selectedBrainAgentId) return;
|
||||
setMobilePane("chat");
|
||||
}, [brainPanelOpen, mobilePane, selectedBrainAgentId]);
|
||||
}, [inspectSidebarAgent, mobilePane]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "connected") {
|
||||
@@ -1098,26 +1122,21 @@ const AgentStudioPage = () => {
|
||||
}
|
||||
}, [agents, loadAgentHistory, status]);
|
||||
|
||||
const handleOpenAgentSettings = useCallback(
|
||||
(agentId: string) => {
|
||||
const handleOpenAgentInspectSidebar = useCallback(
|
||||
(agentId: string, tab: InspectSidebarTab) => {
|
||||
flushPendingDraft(focusedAgent?.agentId ?? null);
|
||||
setBrainPanelOpen(false);
|
||||
setSettingsAgentId(agentId);
|
||||
setInspectSidebar({ agentId, tab });
|
||||
setMobilePane("settings");
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
},
|
||||
[dispatch, flushPendingDraft, focusedAgent]
|
||||
);
|
||||
|
||||
const handleOpenAgentBrain = useCallback(
|
||||
const handleOpenAgentPersonality = useCallback(
|
||||
(agentId: string) => {
|
||||
flushPendingDraft(focusedAgent?.agentId ?? null);
|
||||
setSettingsAgentId(null);
|
||||
setBrainPanelOpen(true);
|
||||
setMobilePane("brain");
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
handleOpenAgentInspectSidebar(agentId, "personality");
|
||||
},
|
||||
[dispatch, flushPendingDraft, focusedAgent]
|
||||
[handleOpenAgentInspectSidebar]
|
||||
);
|
||||
|
||||
const runRestartingMutationLifecycle = useCallback(
|
||||
@@ -1219,7 +1238,7 @@ const AgentStudioPage = () => {
|
||||
const agent = agents.find((entry) => entry.agentId === agentId);
|
||||
if (!agent) return;
|
||||
const confirmed = window.confirm(
|
||||
`Delete ${agent.name}? This removes the agent from gateway config + cron and moves its workspace/state into ~/.openclaw/trash on the gateway host.`
|
||||
`Delete ${agent.name}? This removes the agent from gateway config + scheduled automations and moves its workspace/state into ~/.openclaw/trash on the gateway host.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
await runRestartingMutationLifecycle({
|
||||
@@ -1234,7 +1253,7 @@ const AgentStudioPage = () => {
|
||||
fetchJson,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
});
|
||||
setSettingsAgentId(null);
|
||||
setInspectSidebar(null);
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -1266,7 +1285,7 @@ const AgentStudioPage = () => {
|
||||
onJobs: setSettingsCronJobs,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create cron job.";
|
||||
const message = err instanceof Error ? err.message : "Failed to create automation.";
|
||||
if (!isGatewayDisconnectLikeError(err)) {
|
||||
console.error(message);
|
||||
}
|
||||
@@ -1288,7 +1307,7 @@ const AgentStudioPage = () => {
|
||||
await runCronJobNow(client, resolvedJobId);
|
||||
await loadCronJobsForSettingsAgent(resolvedAgentId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to run cron job.";
|
||||
const message = err instanceof Error ? err.message : "Failed to run schedule.";
|
||||
setSettingsCronError(message);
|
||||
console.error(message);
|
||||
} finally {
|
||||
@@ -1313,7 +1332,7 @@ const AgentStudioPage = () => {
|
||||
}
|
||||
await loadCronJobsForSettingsAgent(resolvedAgentId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to delete cron job.";
|
||||
const message = err instanceof Error ? err.message : "Failed to delete schedule.";
|
||||
setSettingsCronError(message);
|
||||
console.error(message);
|
||||
} finally {
|
||||
@@ -1323,60 +1342,6 @@ const AgentStudioPage = () => {
|
||||
[client, cronCreateBusy, cronDeleteBusyJobId, cronRunBusyJobId, loadCronJobsForSettingsAgent]
|
||||
);
|
||||
|
||||
const handleRunHeartbeat = useCallback(
|
||||
async (agentId: string, heartbeatId: string) => {
|
||||
const resolvedAgentId = agentId.trim();
|
||||
const resolvedHeartbeatId = heartbeatId.trim();
|
||||
if (!resolvedAgentId || !resolvedHeartbeatId) return;
|
||||
if (heartbeatRunBusyId || heartbeatDeleteBusyId) return;
|
||||
setHeartbeatRunBusyId(resolvedHeartbeatId);
|
||||
setSettingsHeartbeatError(null);
|
||||
try {
|
||||
await triggerHeartbeatNow(client, resolvedAgentId);
|
||||
await loadHeartbeatsForSettingsAgent(resolvedAgentId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to trigger heartbeat.";
|
||||
setSettingsHeartbeatError(message);
|
||||
console.error(message);
|
||||
} finally {
|
||||
setHeartbeatRunBusyId((current) =>
|
||||
current === resolvedHeartbeatId ? null : current
|
||||
);
|
||||
}
|
||||
},
|
||||
[client, heartbeatDeleteBusyId, heartbeatRunBusyId, loadHeartbeatsForSettingsAgent]
|
||||
);
|
||||
|
||||
const handleDeleteHeartbeat = useCallback(
|
||||
async (agentId: string, heartbeatId: string) => {
|
||||
const resolvedAgentId = agentId.trim();
|
||||
const resolvedHeartbeatId = heartbeatId.trim();
|
||||
if (!resolvedAgentId || !resolvedHeartbeatId) return;
|
||||
if (heartbeatRunBusyId || heartbeatDeleteBusyId) return;
|
||||
setHeartbeatDeleteBusyId(resolvedHeartbeatId);
|
||||
setSettingsHeartbeatError(null);
|
||||
try {
|
||||
await removeGatewayHeartbeatOverride({
|
||||
client,
|
||||
agentId: resolvedAgentId,
|
||||
});
|
||||
setSettingsHeartbeats((heartbeats) =>
|
||||
heartbeats.filter((heartbeat) => heartbeat.id !== resolvedHeartbeatId)
|
||||
);
|
||||
await loadHeartbeatsForSettingsAgent(resolvedAgentId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to delete heartbeat.";
|
||||
setSettingsHeartbeatError(message);
|
||||
console.error(message);
|
||||
} finally {
|
||||
setHeartbeatDeleteBusyId((current) =>
|
||||
current === resolvedHeartbeatId ? null : current
|
||||
);
|
||||
}
|
||||
},
|
||||
[client, heartbeatDeleteBusyId, heartbeatRunBusyId, loadHeartbeatsForSettingsAgent]
|
||||
);
|
||||
|
||||
const handleOpenCreateAgentModal = useCallback(() => {
|
||||
if (createAgentBusy) return;
|
||||
if (createAgentBlock) return;
|
||||
@@ -1468,32 +1433,29 @@ const AgentStudioPage = () => {
|
||||
}
|
||||
|
||||
let bootstrapError: string | null = null;
|
||||
if (
|
||||
createdAgent.sessionExecSecurity !== "full" ||
|
||||
createdAgent.sessionExecAsk !== "off"
|
||||
) {
|
||||
try {
|
||||
await updateExecutionRoleViaStudio({
|
||||
client,
|
||||
agentId: createdAgent.agentId,
|
||||
sessionKey: createdAgent.sessionKey,
|
||||
role: "autonomous",
|
||||
loadAgents: async () => {},
|
||||
});
|
||||
await loadAgents();
|
||||
await refreshGatewayConfigSnapshot();
|
||||
} catch (err) {
|
||||
bootstrapError =
|
||||
err instanceof Error ? err.message : "Failed to apply autonomous defaults.";
|
||||
setError(
|
||||
`Agent created, but autonomous defaults could not be applied: ${bootstrapError}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
await updateAgentPermissionsViaStudio({
|
||||
client,
|
||||
agentId: createdAgent.agentId,
|
||||
sessionKey: createdAgent.sessionKey,
|
||||
draft: CREATE_AGENT_DEFAULT_PERMISSIONS,
|
||||
loadAgents,
|
||||
});
|
||||
await refreshGatewayConfigSnapshot();
|
||||
} catch (err) {
|
||||
bootstrapError =
|
||||
err instanceof Error ? err.message : "Failed to apply default permissions.";
|
||||
setError(
|
||||
`Agent created, but default permissions could not be applied: ${bootstrapError}`
|
||||
);
|
||||
}
|
||||
|
||||
handleOpenAgentSettings(completion.agentId);
|
||||
flushPendingDraft(focusedAgent?.agentId ?? null);
|
||||
dispatch({ type: "selectAgent", agentId: completion.agentId });
|
||||
setInspectSidebar({ agentId: completion.agentId, tab: "capabilities" });
|
||||
setMobilePane("chat");
|
||||
if (bootstrapError) {
|
||||
setCreateAgentModalError(`Autonomous defaults failed: ${bootstrapError}`);
|
||||
setCreateAgentModalError(`Default permissions failed: ${bootstrapError}`);
|
||||
} else {
|
||||
setCreateAgentModalError(null);
|
||||
}
|
||||
@@ -1519,7 +1481,6 @@ const AgentStudioPage = () => {
|
||||
focusedAgent,
|
||||
hasDeleteMutationBlock,
|
||||
hasRenameMutationBlock,
|
||||
handleOpenAgentSettings,
|
||||
loadAgents,
|
||||
persistAvatarSeed,
|
||||
refreshGatewayConfigSnapshot,
|
||||
@@ -1609,7 +1570,7 @@ const AgentStudioPage = () => {
|
||||
agentId,
|
||||
patch,
|
||||
});
|
||||
setSettingsAgentId(null);
|
||||
setInspectSidebar(null);
|
||||
setMobilePane("chat");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to start new session.";
|
||||
@@ -1772,37 +1733,6 @@ const AgentStudioPage = () => {
|
||||
setPendingExecApprovalsByAgentId,
|
||||
setUnscopedPendingExecApprovals,
|
||||
requestHistoryRefresh: (agentId) => loadAgentHistory(agentId),
|
||||
onAllowResolved: ({ approval, targetAgentId }) => {
|
||||
const scopedPending = (pendingExecApprovalsByAgentId[targetAgentId] ?? []).some(
|
||||
(pendingApproval) => pendingApproval.id !== approval.id
|
||||
);
|
||||
const targetSessionKey = approval.sessionKey?.trim() ?? "";
|
||||
const unscopedPending = unscopedPendingExecApprovals.some((pendingApproval) => {
|
||||
if (pendingApproval.id === approval.id) return false;
|
||||
const pendingAgentId = pendingApproval.agentId?.trim() ?? "";
|
||||
if (pendingAgentId && pendingAgentId === targetAgentId) return true;
|
||||
if (!targetSessionKey) return false;
|
||||
return (pendingApproval.sessionKey?.trim() ?? "") === targetSessionKey;
|
||||
});
|
||||
if (scopedPending || unscopedPending) return;
|
||||
const latest =
|
||||
stateRef.current.agents.find((entry) => entry.agentId === targetAgentId) ?? null;
|
||||
if (!latest) return;
|
||||
const pausedRunId =
|
||||
approvalPausedRunIdByAgentRef.current.get(targetAgentId)?.trim() ?? "";
|
||||
const nowMs = Date.now();
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId: targetAgentId,
|
||||
patch: {
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
lastActivityAt: nowMs,
|
||||
...(pausedRunId ? { runId: pausedRunId } : {}),
|
||||
...(latest.runStartedAt === null ? { runStartedAt: nowMs } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
onAllowed: async ({ approval, targetAgentId }) => {
|
||||
const pausedByAgent = approvalPausedRunIdByAgentRef.current;
|
||||
const pausedRunId = pausedByAgent.get(targetAgentId) ?? null;
|
||||
@@ -1824,8 +1754,18 @@ const AgentStudioPage = () => {
|
||||
}
|
||||
|
||||
pausedByAgent.delete(targetAgentId);
|
||||
const nowMs = Date.now();
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId: targetAgentId,
|
||||
patch: {
|
||||
status: "running",
|
||||
runId: pausedRunId,
|
||||
lastActivityAt: nowMs,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await client.call("agent.wait", { runId: pausedRunId, timeoutMs: 15_000 });
|
||||
await client.call("agent.wait", { runId: pausedRunId, timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS });
|
||||
} catch (waitError) {
|
||||
if (!isGatewayDisconnectLikeError(waitError)) {
|
||||
console.warn("Failed waiting for paused run before auto-resume.", waitError);
|
||||
@@ -1833,7 +1773,11 @@ const AgentStudioPage = () => {
|
||||
}
|
||||
|
||||
const latest = stateRef.current.agents.find((entry) => entry.agentId === targetAgentId) ?? null;
|
||||
if (!latest || latest.status === "running") return;
|
||||
if (!latest) return;
|
||||
const latestRunId = latest.runId?.trim() ?? "";
|
||||
if (latest.status === "running" && latestRunId && latestRunId !== pausedRunId) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = latest.sessionKey.trim();
|
||||
if (!sessionKey) return;
|
||||
|
||||
@@ -2124,7 +2068,10 @@ const AgentStudioPage = () => {
|
||||
});
|
||||
await loadAgents();
|
||||
await refreshGatewayConfigSnapshot();
|
||||
setSettingsAgentId(resolvedAgentId);
|
||||
setInspectSidebar((current) => {
|
||||
if (current?.agentId === resolvedAgentId) return current;
|
||||
return { agentId: resolvedAgentId, tab: "capabilities" };
|
||||
});
|
||||
setMobilePane("settings");
|
||||
},
|
||||
});
|
||||
@@ -2342,7 +2289,7 @@ const AgentStudioPage = () => {
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 xl:flex-row">
|
||||
<div className="glass-panel ui-panel p-2 xl:hidden" data-testid="mobile-pane-toggle">
|
||||
<div className="ui-segment grid-cols-4">
|
||||
<div className="ui-segment grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-segment-item px-2 py-2 font-mono text-[12px] font-medium tracking-[0.02em]"
|
||||
@@ -2363,24 +2310,14 @@ const AgentStudioPage = () => {
|
||||
type="button"
|
||||
className="ui-segment-item px-2 py-2 font-mono text-[12px] font-medium tracking-[0.02em]"
|
||||
data-active={mobilePane === "settings" ? "true" : "false"}
|
||||
onClick={() => setMobilePane("settings")}
|
||||
disabled={!settingsAgent}
|
||||
onClick={() => {
|
||||
if (!focusedAgent) return;
|
||||
handleOpenAgentPersonality(focusedAgent.agentId);
|
||||
}}
|
||||
disabled={!focusedAgent}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-segment-item px-2 py-2 font-mono text-[12px] font-medium tracking-[0.02em]"
|
||||
data-active={mobilePane === "brain" ? "true" : "false"}
|
||||
onClick={() => {
|
||||
setBrainPanelOpen(true);
|
||||
setSettingsAgentId(null);
|
||||
setMobilePane("brain");
|
||||
}}
|
||||
disabled={!hasAnyAgents}
|
||||
>
|
||||
Brain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -2399,6 +2336,9 @@ const AgentStudioPage = () => {
|
||||
onSelectAgent={(agentId) => {
|
||||
flushPendingDraft(focusedAgent?.agentId ?? null);
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
setInspectSidebar((current) =>
|
||||
current ? { ...current, agentId } : current
|
||||
);
|
||||
setMobilePane("chat");
|
||||
}}
|
||||
/>
|
||||
@@ -2418,8 +2358,8 @@ const AgentStudioPage = () => {
|
||||
stopBusy={stopBusyAgentId === focusedAgent.agentId}
|
||||
stopDisabledReason={focusedAgentStopDisabledReason}
|
||||
onLoadMoreHistory={() => loadMoreAgentHistory(focusedAgent.agentId)}
|
||||
onOpenSettings={() => handleOpenAgentSettings(focusedAgent.agentId)}
|
||||
onOpenBrain={() => handleOpenAgentBrain(focusedAgent.agentId)}
|
||||
onOpenSettings={() => handleOpenAgentPersonality(focusedAgent.agentId)}
|
||||
onNewSession={() => handleNewSession(focusedAgent.agentId)}
|
||||
onModelChange={(value) =>
|
||||
handleModelChange(focusedAgent.agentId, focusedAgent.sessionKey, value)
|
||||
}
|
||||
@@ -2454,67 +2394,97 @@ const AgentStudioPage = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{brainPanelOpen ? (
|
||||
{inspectSidebarAgent ? (
|
||||
<div
|
||||
className={`${mobilePane === "brain" ? "block" : "hidden"} sidebar-shell glass-panel ui-panel ui-depth-sidepanel min-h-0 w-full shrink-0 overflow-hidden p-0 xl:block xl:min-w-[360px] xl:max-w-[430px]`}
|
||||
className={`${mobilePane === "settings" ? "block" : "hidden"} sidebar-shell glass-panel ui-panel ui-depth-sidepanel flex min-h-0 w-full shrink-0 flex-col overflow-hidden p-0 xl:flex xl:min-w-[468px] xl:max-w-[559px]`}
|
||||
>
|
||||
<AgentBrainPanel
|
||||
client={client}
|
||||
agents={agents}
|
||||
selectedAgentId={selectedBrainAgentId}
|
||||
onClose={() => {
|
||||
setBrainPanelOpen(false);
|
||||
setMobilePane("chat");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{settingsAgent ? (
|
||||
<div
|
||||
className={`${mobilePane === "settings" ? "block" : "hidden"} sidebar-shell glass-panel ui-panel ui-depth-sidepanel min-h-0 w-full shrink-0 overflow-hidden p-0 xl:block xl:min-w-[360px] xl:max-w-[430px]`}
|
||||
>
|
||||
<AgentSettingsPanel
|
||||
key={settingsAgent.agentId}
|
||||
agent={settingsAgent}
|
||||
onClose={() => {
|
||||
setSettingsAgentId(null);
|
||||
setMobilePane("chat");
|
||||
}}
|
||||
onRename={(name) => handleRenameAgent(settingsAgent.agentId, name)}
|
||||
permissionsDraft={settingsAgentPermissionsDraft ?? undefined}
|
||||
onUpdateAgentPermissions={(draft) =>
|
||||
handleUpdateAgentPermissions(settingsAgent.agentId, draft)
|
||||
}
|
||||
onNewSession={() => handleNewSession(settingsAgent.agentId)}
|
||||
onDelete={() => handleDeleteAgent(settingsAgent.agentId)}
|
||||
canDelete={settingsAgent.agentId !== RESERVED_MAIN_AGENT_ID}
|
||||
onToolCallingToggle={(enabled) =>
|
||||
handleToolCallingToggle(settingsAgent.agentId, enabled)
|
||||
}
|
||||
onThinkingTracesToggle={(enabled) =>
|
||||
handleThinkingTracesToggle(settingsAgent.agentId, enabled)
|
||||
}
|
||||
cronJobs={settingsCronJobs}
|
||||
cronLoading={settingsCronLoading}
|
||||
cronError={settingsCronError}
|
||||
cronCreateBusy={cronCreateBusy}
|
||||
cronRunBusyJobId={cronRunBusyJobId}
|
||||
cronDeleteBusyJobId={cronDeleteBusyJobId}
|
||||
onCreateCronJob={(draft) => handleCreateCronJob(settingsAgent.agentId, draft)}
|
||||
onRunCronJob={(jobId) => handleRunCronJob(settingsAgent.agentId, jobId)}
|
||||
onDeleteCronJob={(jobId) => handleDeleteCronJob(settingsAgent.agentId, jobId)}
|
||||
heartbeats={settingsHeartbeats}
|
||||
heartbeatLoading={settingsHeartbeatLoading}
|
||||
heartbeatError={settingsHeartbeatError}
|
||||
heartbeatRunBusyId={heartbeatRunBusyId}
|
||||
heartbeatDeleteBusyId={heartbeatDeleteBusyId}
|
||||
onRunHeartbeat={(heartbeatId) =>
|
||||
handleRunHeartbeat(settingsAgent.agentId, heartbeatId)
|
||||
}
|
||||
onDeleteHeartbeat={(heartbeatId) =>
|
||||
handleDeleteHeartbeat(settingsAgent.agentId, heartbeatId)
|
||||
}
|
||||
/>
|
||||
<div className="border-b border-border/60 px-3 py-3">
|
||||
<div className="ui-segment grid-cols-4">
|
||||
{(
|
||||
[
|
||||
{ id: "personality", label: "Personality" },
|
||||
{ id: "capabilities", label: "Capabilities" },
|
||||
{ id: "automations", label: "Automations" },
|
||||
{ id: "advanced", label: "Advanced" },
|
||||
] as const
|
||||
).map((entry) => {
|
||||
const active = inspectSidebarTab === entry.id;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="ui-segment-item px-2 py-2 font-mono text-[11px] font-medium tracking-[0.02em]"
|
||||
data-active={active ? "true" : "false"}
|
||||
onClick={() =>
|
||||
setInspectSidebar((current) => {
|
||||
if (!current) {
|
||||
return { agentId: inspectSidebarAgent.agentId, tab: entry.id };
|
||||
}
|
||||
if (current.tab === entry.id) return current;
|
||||
return { ...current, tab: entry.id };
|
||||
})
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{inspectSidebarTab === "personality" ? (
|
||||
<AgentBrainPanel
|
||||
client={client}
|
||||
agents={agents}
|
||||
selectedAgentId={inspectSidebarAgent.agentId}
|
||||
onRename={(name) => handleRenameAgent(inspectSidebarAgent.agentId, name)}
|
||||
onClose={() => {
|
||||
setInspectSidebar(null);
|
||||
setMobilePane("chat");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AgentSettingsPanel
|
||||
key={`${inspectSidebarAgent.agentId}:${inspectSidebarTab ?? "capabilities"}`}
|
||||
mode={
|
||||
inspectSidebarTab === "automations"
|
||||
? "automations"
|
||||
: inspectSidebarTab === "advanced"
|
||||
? "advanced"
|
||||
: "capabilities"
|
||||
}
|
||||
agent={inspectSidebarAgent}
|
||||
onClose={() => {
|
||||
setInspectSidebar(null);
|
||||
setMobilePane("chat");
|
||||
}}
|
||||
permissionsDraft={settingsAgentPermissionsDraft ?? undefined}
|
||||
onUpdateAgentPermissions={(draft) =>
|
||||
handleUpdateAgentPermissions(inspectSidebarAgent.agentId, draft)
|
||||
}
|
||||
onDelete={() => handleDeleteAgent(inspectSidebarAgent.agentId)}
|
||||
canDelete={inspectSidebarAgent.agentId !== RESERVED_MAIN_AGENT_ID}
|
||||
onToolCallingToggle={(enabled) =>
|
||||
handleToolCallingToggle(inspectSidebarAgent.agentId, enabled)
|
||||
}
|
||||
onThinkingTracesToggle={(enabled) =>
|
||||
handleThinkingTracesToggle(inspectSidebarAgent.agentId, enabled)
|
||||
}
|
||||
cronJobs={settingsCronJobs}
|
||||
cronLoading={settingsCronLoading}
|
||||
cronError={settingsCronError}
|
||||
cronCreateBusy={cronCreateBusy}
|
||||
cronRunBusyJobId={cronRunBusyJobId}
|
||||
cronDeleteBusyJobId={cronDeleteBusyJobId}
|
||||
onCreateCronJob={(draft) => handleCreateCronJob(inspectSidebarAgent.agentId, draft)}
|
||||
onRunCronJob={(jobId) => handleRunCronJob(inspectSidebarAgent.agentId, jobId)}
|
||||
onDeleteCronJob={(jobId) =>
|
||||
handleDeleteCronJob(inspectSidebarAgent.agentId, jobId)
|
||||
}
|
||||
controlUiUrl={controlUiUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Brain, ChevronRight, Clock, Cog, Shuffle } from "lucide-react";
|
||||
import { ChevronRight, Clock, Cog, Shuffle } from "lucide-react";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
@@ -98,7 +98,7 @@ type AgentChatPanelProps = {
|
||||
stopDisabledReason?: string | null;
|
||||
onLoadMoreHistory: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenBrain?: () => void;
|
||||
onNewSession?: () => Promise<void> | void;
|
||||
onModelChange: (value: string | null) => void;
|
||||
onThinkingChange: (value: string | null) => void;
|
||||
onDraftChange: (value: string) => void;
|
||||
@@ -401,7 +401,7 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 space-y-3 dark:space-y-5">
|
||||
{streaming ? (
|
||||
{streaming && !hasThinking ? (
|
||||
<div
|
||||
className="flex items-center gap-2 text-[10px] text-muted-foreground/80"
|
||||
role="status"
|
||||
@@ -832,7 +832,7 @@ export const AgentChatPanel = ({
|
||||
stopDisabledReason = null,
|
||||
onLoadMoreHistory,
|
||||
onOpenSettings,
|
||||
onOpenBrain,
|
||||
onNewSession,
|
||||
onModelChange,
|
||||
onThinkingChange,
|
||||
onDraftChange,
|
||||
@@ -843,6 +843,7 @@ export const AgentChatPanel = ({
|
||||
onResolveExecApproval,
|
||||
}: AgentChatPanelProps) => {
|
||||
const [draftValue, setDraftValue] = useState(agent.draft);
|
||||
const [newSessionBusy, setNewSessionBusy] = useState(false);
|
||||
const draftRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const scrollToBottomNextOutputRef = useRef(false);
|
||||
const plainDraftRef = useRef(agent.draft);
|
||||
@@ -875,7 +876,6 @@ export const AgentChatPanel = ({
|
||||
sessionKey: agent.sessionKey,
|
||||
};
|
||||
plainDraftRef.current = agent.draft;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDraftValue(agent.draft);
|
||||
return;
|
||||
}
|
||||
@@ -928,12 +928,19 @@ export const AgentChatPanel = ({
|
||||
[agent.outputLines, agent.showThinkingTraces, agent.toolCallingEnabled]
|
||||
);
|
||||
const running = agent.status === "running";
|
||||
const renderBlocks = useMemo(() => buildAgentChatRenderBlocks(chatItems), [chatItems]);
|
||||
const hasActiveStreamingTailInTranscript =
|
||||
running && renderBlocks.length > 0 && !renderBlocks[renderBlocks.length - 1].text;
|
||||
const liveAssistantText =
|
||||
running && agent.streamText ? normalizeAssistantDisplayText(agent.streamText) : "";
|
||||
const liveThinkingText =
|
||||
running && agent.showThinkingTraces && agent.thinkingTrace ? agent.thinkingTrace.trim() : "";
|
||||
const hasVisibleLiveThinking = Boolean(liveThinkingText.trim());
|
||||
const showTypingIndicator = running && !hasVisibleLiveThinking;
|
||||
const showTypingIndicator =
|
||||
running &&
|
||||
!hasVisibleLiveThinking &&
|
||||
!liveAssistantText &&
|
||||
!hasActiveStreamingTailInTranscript;
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
@@ -984,6 +991,18 @@ export const AgentChatPanel = ({
|
||||
handleSend(draftValue);
|
||||
}, [draftValue, handleSend]);
|
||||
|
||||
const handleNewSession = useCallback(async () => {
|
||||
if (!onNewSession || newSessionBusy || !canSend) return;
|
||||
setNewSessionBusy(true);
|
||||
try {
|
||||
await onNewSession();
|
||||
} finally {
|
||||
setNewSessionBusy(false);
|
||||
}
|
||||
}, [canSend, newSessionBusy, onNewSession]);
|
||||
|
||||
const newSessionDisabled = newSessionBusy || !canSend || !onNewSession;
|
||||
|
||||
return (
|
||||
<div data-agent-panel className="group fade-up relative flex h-full w-full flex-col">
|
||||
<div className="px-3 pt-3 sm:px-4 sm:pt-4">
|
||||
@@ -1080,21 +1099,24 @@ export const AgentChatPanel = ({
|
||||
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<button
|
||||
className="nodrag ui-btn-icon"
|
||||
className="nodrag ui-btn-primary px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
|
||||
type="button"
|
||||
data-testid="agent-brain-toggle"
|
||||
aria-label="Open agent brain files"
|
||||
title="Brain files"
|
||||
onClick={() => onOpenBrain?.()}
|
||||
data-testid="agent-new-session-toggle"
|
||||
aria-label="Start new session"
|
||||
title="Start new session"
|
||||
onClick={() => {
|
||||
void handleNewSession();
|
||||
}}
|
||||
disabled={newSessionDisabled}
|
||||
>
|
||||
<Brain className="h-4 w-4" />
|
||||
{newSessionBusy ? "Starting..." : "New session"}
|
||||
</button>
|
||||
<button
|
||||
className="nodrag ui-btn-icon"
|
||||
type="button"
|
||||
data-testid="agent-settings-toggle"
|
||||
aria-label="Open agent settings"
|
||||
title="Agent settings"
|
||||
aria-label="Open personality"
|
||||
title="Personality"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<Cog className="h-4 w-4" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,22 @@ export const AGENT_FILE_NAMES = [
|
||||
|
||||
export type AgentFileName = (typeof AGENT_FILE_NAMES)[number];
|
||||
|
||||
export const PERSONALITY_FILE_NAMES = [
|
||||
"SOUL.md",
|
||||
"AGENTS.md",
|
||||
"USER.md",
|
||||
"IDENTITY.md",
|
||||
] as const satisfies readonly AgentFileName[];
|
||||
|
||||
export type PersonalityFileName = (typeof PERSONALITY_FILE_NAMES)[number];
|
||||
|
||||
export const PERSONALITY_FILE_LABELS: Record<PersonalityFileName, string> = {
|
||||
"SOUL.md": "Personality",
|
||||
"AGENTS.md": "Instructions",
|
||||
"USER.md": "About You",
|
||||
"IDENTITY.md": "Identity",
|
||||
};
|
||||
|
||||
export const isAgentFileName = (value: string): value is AgentFileName =>
|
||||
AGENT_FILE_NAMES.includes(value as AgentFileName);
|
||||
|
||||
|
||||
@@ -107,15 +107,23 @@ describe("AgentBrainPanel", () => {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
onRename: vi.fn(async () => true),
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Agents" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Instructions" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("alpha agents")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Personality" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "About You" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Tools" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Be useful.")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Instructions" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("alpha agents")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Identity" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Name: Alpha")).toBeInTheDocument();
|
||||
@@ -131,6 +139,7 @@ describe("AgentBrainPanel", () => {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "",
|
||||
onRename: vi.fn(async () => true),
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
@@ -150,6 +159,7 @@ describe("AgentBrainPanel", () => {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
onRename: vi.fn(async () => true),
|
||||
onClose,
|
||||
})
|
||||
);
|
||||
@@ -159,14 +169,21 @@ describe("AgentBrainPanel", () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Identity" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await screen.findByRole("textbox");
|
||||
const textarea = await waitFor(() => {
|
||||
const panel = screen.getByTestId("agent-personality-files");
|
||||
const element = panel.querySelector("textarea");
|
||||
if (!element) {
|
||||
throw new Error("Personality editor textarea not found.");
|
||||
}
|
||||
return element;
|
||||
});
|
||||
fireEvent.change(textarea, {
|
||||
target: {
|
||||
value:
|
||||
"# IDENTITY.md - Who Am I?\n\n- Name: Alpha Prime\n- Creature: droid\n- Vibe: calm\n- Emoji: 🤖\n",
|
||||
},
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("agent-brain-close"));
|
||||
fireEvent.click(screen.getByTestId("agent-personality-close"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
@@ -187,4 +204,29 @@ describe("AgentBrainPanel", () => {
|
||||
String((identityWrite?.params as Record<string, unknown>).content ?? "")
|
||||
).toContain("- Name: Alpha Prime");
|
||||
});
|
||||
|
||||
it("renames_agent_from_personality_panel", async () => {
|
||||
const { client } = createMockClient();
|
||||
const agents = [createAgent("agent-1", "Alpha", "session-1")];
|
||||
const onRename = vi.fn(async () => true);
|
||||
|
||||
render(
|
||||
createElement(AgentBrainPanel, {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
onRename,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Agent name"), {
|
||||
target: { value: " Alpha Prime " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update Name" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRename).toHaveBeenCalledWith("Alpha Prime");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,15 +73,15 @@ describe("AgentChatPanel controls", () => {
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Thinking")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("Agent One")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-brain-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Open agent brain files")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-new-session-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Start new session")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-settings-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Open agent settings")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Open personality")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Inspect")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes_on_open_brain_when_control_clicked", () => {
|
||||
const onOpenBrain = vi.fn();
|
||||
it("invokes_on_new_session_when_control_clicked", () => {
|
||||
const onNewSession = vi.fn(async () => {});
|
||||
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
@@ -92,7 +92,7 @@ describe("AgentChatPanel controls", () => {
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onOpenBrain,
|
||||
onNewSession,
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
@@ -102,8 +102,8 @@ describe("AgentChatPanel controls", () => {
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-brain-toggle"));
|
||||
expect(onOpenBrain).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByTestId("agent-new-session-toggle"));
|
||||
expect(onNewSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders semantic status badge markers for idle and running states", () => {
|
||||
@@ -334,7 +334,7 @@ describe("AgentChatPanel controls", () => {
|
||||
expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Thinking")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps_thinking_animation_visible_when_saved_thinking_exists", () => {
|
||||
it("does_not_render_duplicate_typing_indicator_when_internal_thinking_is_visible", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
@@ -357,7 +357,8 @@ describe("AgentChatPanel controls", () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("agent-typing-indicator").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByTestId("agent-typing-indicator")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Thinking (internal)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders thinking row collapsed by default", () => {
|
||||
|
||||
@@ -44,13 +44,11 @@ describe("AgentSettingsPanel header", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("uses inspect header style with section label and agent title", () => {
|
||||
it("uses inspect header style with agent title", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -64,7 +62,7 @@ describe("AgentSettingsPanel header", () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText("Agent settings")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Capabilities")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Web Researcher")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Close panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentSettingsPanel } from "@/features/agents/components/AgentInspectPanels";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
import type { AgentHeartbeatSummary } from "@/lib/gateway/agentConfig";
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
@@ -54,35 +53,16 @@ const createCronJob = (id: string): CronJobSummary => ({
|
||||
state: {},
|
||||
});
|
||||
|
||||
const createHeartbeat = (
|
||||
source: AgentHeartbeatSummary["source"] = "override"
|
||||
): AgentHeartbeatSummary => ({
|
||||
id: "agent-1",
|
||||
agentId: "agent-1",
|
||||
source,
|
||||
enabled: true,
|
||||
heartbeat: {
|
||||
every: "30m",
|
||||
target: "last",
|
||||
includeReasoning: false,
|
||||
ackMaxChars: 300,
|
||||
activeHours: null,
|
||||
},
|
||||
});
|
||||
|
||||
describe("AgentSettingsPanel", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders_identity_rename_section_and_saves_trimmed_name", async () => {
|
||||
const onRename = vi.fn(async () => true);
|
||||
it("does_not_render_name_editor_in_capabilities_mode", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onRename,
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -96,14 +76,8 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Agent name"), {
|
||||
target: { value: " Agent Two " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update Name" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRename).toHaveBeenCalledWith("Agent Two");
|
||||
});
|
||||
expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders_icon_close_button_with_accessible_label", () => {
|
||||
@@ -111,8 +85,6 @@ describe("AgentSettingsPanel", () => {
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -134,9 +106,8 @@ describe("AgentSettingsPanel", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "advanced",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -163,8 +134,6 @@ describe("AgentSettingsPanel", () => {
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -178,7 +147,7 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText("Permissions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Capabilities")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Run commands off" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Run commands ask" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Run commands auto" })).toBeInTheDocument();
|
||||
@@ -197,8 +166,6 @@ describe("AgentSettingsPanel", () => {
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -218,49 +185,44 @@ describe("AgentSettingsPanel", () => {
|
||||
});
|
||||
|
||||
it("autosaves_updated_permissions_draft", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onUpdateAgentPermissions = vi.fn(async () => {});
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
permissionsDraft: {
|
||||
commandMode: "off",
|
||||
webAccess: false,
|
||||
fileTools: false,
|
||||
},
|
||||
onUpdateAgentPermissions,
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
cronRunBusyJobId: null,
|
||||
cronDeleteBusyJobId: null,
|
||||
onRunCronJob: vi.fn(),
|
||||
onDeleteCronJob: vi.fn(),
|
||||
})
|
||||
);
|
||||
const onUpdateAgentPermissions = vi.fn(async () => {});
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
permissionsDraft: {
|
||||
commandMode: "off",
|
||||
webAccess: false,
|
||||
fileTools: false,
|
||||
},
|
||||
onUpdateAgentPermissions,
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
cronRunBusyJobId: null,
|
||||
cronDeleteBusyJobId: null,
|
||||
onRunCronJob: vi.fn(),
|
||||
onDeleteCronJob: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Run commands auto" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Web access" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "File tools" }));
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Run commands auto" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Web access" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "File tools" }));
|
||||
|
||||
await waitFor(() => {
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(onUpdateAgentPermissions).toHaveBeenCalledWith({
|
||||
commandMode: "auto",
|
||||
webAccess: true,
|
||||
fileTools: true,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
it("does_not_render_runtime_settings_section", () => {
|
||||
@@ -268,8 +230,6 @@ describe("AgentSettingsPanel", () => {
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -284,17 +244,15 @@ describe("AgentSettingsPanel", () => {
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Runtime settings")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Brain files")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Personality")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes_on_new_session_when_clicked", () => {
|
||||
const onNewSession = vi.fn();
|
||||
it("does_not_render_new_session_control_in_advanced_mode", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "advanced",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession,
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -308,17 +266,15 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
expect(onNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("button", { name: "New session" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders_cron_jobs_section_below_session", () => {
|
||||
it("renders_automations_section_when_mode_is_automations", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -332,11 +288,9 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const sessionSection = screen.getByTestId("agent-settings-session");
|
||||
const cronSection = screen.getByTestId("agent-settings-cron");
|
||||
expect(cronSection).toBeInTheDocument();
|
||||
const position = sessionSection.compareDocumentPosition(cronSection);
|
||||
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.queryByTestId("agent-settings-session")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes_run_now_and_disables_play_while_pending", () => {
|
||||
@@ -345,9 +299,8 @@ describe("AgentSettingsPanel", () => {
|
||||
const { rerender } = render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -361,15 +314,14 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Run cron job Job job-1 now" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Run timed automation Job job-1 now" }));
|
||||
expect(onRunCronJob).toHaveBeenCalledWith("job-1");
|
||||
|
||||
rerender(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -383,7 +335,7 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Run cron job Job job-1 now" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Run timed automation Job job-1 now" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("invokes_delete_and_disables_trash_while_pending", () => {
|
||||
@@ -392,9 +344,8 @@ describe("AgentSettingsPanel", () => {
|
||||
const { rerender } = render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -408,15 +359,14 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete cron job Job job-1" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete timed automation Job job-1" }));
|
||||
expect(onDeleteCronJob).toHaveBeenCalledWith("job-1");
|
||||
|
||||
rerender(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -430,16 +380,15 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Delete cron job Job job-1" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Delete timed automation Job job-1" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows_empty_cron_state_when_agent_has_no_jobs", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -453,7 +402,7 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText("No cron jobs for this agent.")).toBeInTheDocument();
|
||||
expect(screen.getByText("No timed automations for this agent.")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cron-empty-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -461,9 +410,8 @@ describe("AgentSettingsPanel", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -484,9 +432,8 @@ describe("AgentSettingsPanel", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -501,16 +448,15 @@ describe("AgentSettingsPanel", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
expect(screen.getByRole("dialog", { name: "Create cron job" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: "Create automation" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates_template_defaults_when_switching_templates", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -527,12 +473,12 @@ describe("AgentSettingsPanel", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Weekly Review" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByLabelText("Job name")).toHaveValue("Weekly review");
|
||||
expect(screen.getByLabelText("Automation name")).toHaveValue("Weekly review");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Morning Brief" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByLabelText("Job name")).toHaveValue("Morning brief");
|
||||
expect(screen.getByLabelText("Automation name")).toHaveValue("Morning brief");
|
||||
});
|
||||
|
||||
it("submits_modal_with_agent_scoped_draft", async () => {
|
||||
@@ -540,9 +486,8 @@ describe("AgentSettingsPanel", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -560,15 +505,21 @@ describe("AgentSettingsPanel", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Custom" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
fireEvent.change(screen.getByLabelText("Job name"), {
|
||||
fireEvent.change(screen.getByLabelText("Automation name"), {
|
||||
target: { value: "Nightly sync" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Task"), {
|
||||
target: { value: "Sync project status and report blockers." },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Next" })).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create cron job" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Create automation" })).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create automation" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreateCronJob).toHaveBeenCalledWith({
|
||||
@@ -584,13 +535,12 @@ describe("AgentSettingsPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disables_create_submit_while_create_in_flight", () => {
|
||||
it("hides_create_submit_before_review_step_and_disables_next_when_busy", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -606,7 +556,8 @@ describe("AgentSettingsPanel", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
expect(screen.getByRole("button", { name: "Create cron job" })).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: "Create automation" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps_modal_open_and_shows_error_when_create_fails", async () => {
|
||||
@@ -616,9 +567,8 @@ describe("AgentSettingsPanel", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -636,29 +586,34 @@ describe("AgentSettingsPanel", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Custom" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
fireEvent.change(screen.getByLabelText("Job name"), {
|
||||
fireEvent.change(screen.getByLabelText("Automation name"), {
|
||||
target: { value: "Nightly sync" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Task"), {
|
||||
target: { value: "Sync project status and report blockers." },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Next" })).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create cron job" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Create automation" })).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create automation" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Gateway exploded")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("dialog", { name: "Create cron job" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: "Create automation" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders_heartbeat_section_below_cron", () => {
|
||||
it("shows_heartbeat_coming_soon_in_automations_mode", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "automations",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -669,25 +624,19 @@ describe("AgentSettingsPanel", () => {
|
||||
cronDeleteBusyJobId: null,
|
||||
onRunCronJob: vi.fn(),
|
||||
onDeleteCronJob: vi.fn(),
|
||||
heartbeats: [createHeartbeat()],
|
||||
})
|
||||
);
|
||||
|
||||
const cronSection = screen.getByTestId("agent-settings-cron");
|
||||
const heartbeatSection = screen.getByTestId("agent-settings-heartbeat");
|
||||
expect(heartbeatSection).toBeInTheDocument();
|
||||
const position = cronSection.compareDocumentPosition(heartbeatSection);
|
||||
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByTestId("agent-settings-heartbeat-coming-soon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Heartbeat automation controls are coming soon.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes_run_heartbeat_and_disables_delete_for_inherited", () => {
|
||||
const onRunHeartbeat = vi.fn();
|
||||
it("shows_control_ui_section_in_advanced_mode", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "advanced",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -698,24 +647,19 @@ describe("AgentSettingsPanel", () => {
|
||||
cronDeleteBusyJobId: null,
|
||||
onRunCronJob: vi.fn(),
|
||||
onDeleteCronJob: vi.fn(),
|
||||
heartbeats: [createHeartbeat("default")],
|
||||
onRunHeartbeat,
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Run heartbeat for agent-1 now" }));
|
||||
expect(onRunHeartbeat).toHaveBeenCalledWith("agent-1");
|
||||
expect(screen.getByRole("button", { name: "Delete heartbeat for agent-1" })).toBeDisabled();
|
||||
expect(screen.getByTestId("agent-settings-control-ui")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Open Full Control UI" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("invokes_delete_heartbeat_for_override", () => {
|
||||
const onDeleteHeartbeat = vi.fn();
|
||||
it("renders_enabled_control_ui_link_when_available", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "advanced",
|
||||
onClose: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onNewSession: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
@@ -726,12 +670,11 @@ describe("AgentSettingsPanel", () => {
|
||||
cronDeleteBusyJobId: null,
|
||||
onRunCronJob: vi.fn(),
|
||||
onDeleteCronJob: vi.fn(),
|
||||
heartbeats: [createHeartbeat("override")],
|
||||
onDeleteHeartbeat,
|
||||
controlUiUrl: "http://localhost:3000/control",
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete heartbeat for agent-1" }));
|
||||
expect(onDeleteHeartbeat).toHaveBeenCalledWith("agent-1");
|
||||
const link = screen.getByRole("link", { name: "Open Full Control UI" });
|
||||
expect(link).toHaveAttribute("href", "http://localhost:3000/control");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user