mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 08:52:03 +00:00
reliability and skills junk
This commit is contained in:
+1
-1
@@ -27,7 +27,7 @@ Non-goals:
|
||||
This keeps feature cohesion high while preserving a clear client/server boundary.
|
||||
|
||||
## Main modules / bounded contexts
|
||||
- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same **New Agent** entry point, now implemented as a one-step `AgentCreateModal` flow (name + avatar + launch). Creation is create-only: `src/app/page.tsx` calls `runCreateAgentMutationLifecycle` and `createGatewayAgent`, then reloads fleet state and focuses chat for the new agent; no guided setup compilation, deferred setup persistence, or pending retry UI remains in runtime flows. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (`model`, `thinking`) live in the chat header (`AgentChatPanel`), active runs can be stopped from the chat composer via `chat.abort`, and pending exec approvals render in-chat action cards (`Allow once`, `Always allow`, `Deny`) while fleet rows show `Needs approval`. Settings sidebar actions remain focused on rename, display toggles, execution role updates (`updateExecutionRoleViaStudio`), new session, cron list/run/delete/create, and delete (`AgentSettingsPanel`). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (`presence`/`heartbeat` summary refresh and `chat`/`agent` runtime streams) is centralized in bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`), while runtime flow decisions are emitted from pure policy helpers (`src/features/agents/state/runtimeEventPolicy.ts`) and executed by `src/features/agents/state/gatewayRuntimeEventHandler.ts`; both are consumed from one gateway subscription path in `src/app/page.tsx`, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under `src/features/agents/operations/` (fleet hydration snapshots in `agentFleetHydration.ts`, pure fleet hydration derivation in `agentFleetHydrationDerivation.ts`, chat send in `chatSendOperation.ts`, cron create in `cronCreateOperation.ts`, mutation lifecycle policy (create/rename/delete) in `mutationLifecycleWorkflow.ts`, latest-update policy in `latestUpdateWorkflow.ts`, fleet summary/reconcile policy in `fleetLifecycleWorkflow.ts`, reconcile operation adapter in `agentReconcileOperation.ts`, history request/disposition policy in `historyLifecycleWorkflow.ts`, history sync operation adapter in `historySyncOperation.ts`, approval lifecycle policy in `src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, manual exec approval resolve operation in `src/features/agents/approvals/execApprovalResolveOperation.ts`, and execution primitives in `useConfigMutationQueue.ts` and `useGatewayRestartBlock.ts`). Rename/delete post-run UI side effects are emitted as typed mutation commands from `mutationLifecycleWorkflow.ts` and executed in `src/app/page.tsx`. Session setting mutations (model/thinking) are centralized in `src/features/agents/state/sessionSettingsMutations.ts` so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from `chat.history` sync via the history workflow boundary (`historyLifecycleWorkflow.ts`) and operation adapter (`historySyncOperation.ts`); replayed terminal chat events and late deltas from recently closed runs are ignored in `gatewayRuntimeEventHandler`, which requests recovery history only through the `requestHistoryRefresh` boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first `GatewayConnectScreen` with a local command copy affordance and a collapsible remote form.
|
||||
- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same **New Agent** entry point, now implemented as a one-step `AgentCreateModal` flow (name + avatar + launch). Creation is create-only: `src/app/page.tsx` calls `runCreateAgentMutationLifecycle` and `createGatewayAgent`, then reloads fleet state and focuses chat for the new agent; no guided setup compilation, deferred setup persistence, or pending retry UI remains in runtime flows. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (`model`, `thinking`) live in the chat header (`AgentChatPanel`), active runs can be stopped from the chat composer via `chat.abort`, and pending exec approvals render in-chat action cards (`Allow once`, `Always allow`, `Deny`) while fleet rows show `Needs approval`. Settings sidebar actions remain focused on rename, display toggles, execution role updates (`updateExecutionRoleViaStudio`), new session, cron list/run/delete/create, and delete (`AgentSettingsPanel`). The Skills section in `AgentSettingsPanel` is split into `Access` (per-agent allowlist mode + toggles) and `Library` (gateway-wide setup actions in modal flow). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (`presence`/`heartbeat` summary refresh and `chat`/`agent` runtime streams) is centralized in bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`), while runtime flow decisions are emitted from pure policy helpers (`src/features/agents/state/runtimeEventPolicy.ts`) and executed by `src/features/agents/state/gatewayRuntimeEventHandler.ts`; both are consumed from one gateway subscription path in `src/app/page.tsx`, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under `src/features/agents/operations/` (fleet hydration snapshots in `agentFleetHydration.ts`, pure fleet hydration derivation in `agentFleetHydrationDerivation.ts`, chat send in `chatSendOperation.ts`, cron create in `cronCreateOperation.ts`, mutation lifecycle policy (create/rename/delete) in `mutationLifecycleWorkflow.ts`, latest-update policy in `latestUpdateWorkflow.ts`, fleet summary/reconcile policy in `fleetLifecycleWorkflow.ts`, reconcile operation adapter in `agentReconcileOperation.ts`, history request/disposition policy in `historyLifecycleWorkflow.ts`, history sync operation adapter in `historySyncOperation.ts`, approval lifecycle policy in `src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, manual exec approval resolve operation in `src/features/agents/approvals/execApprovalResolveOperation.ts`, and execution primitives in `useConfigMutationQueue.ts` and `useGatewayRestartBlock.ts`). Rename/delete post-run UI side effects are emitted as typed mutation commands from `mutationLifecycleWorkflow.ts` and executed in `src/app/page.tsx`. Session setting mutations (model/thinking) are centralized in `src/features/agents/state/sessionSettingsMutations.ts` so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from `chat.history` sync via the history workflow boundary (`historyLifecycleWorkflow.ts`) and operation adapter (`historySyncOperation.ts`); replayed terminal chat events and late deltas from recently closed runs are ignored in `gatewayRuntimeEventHandler`, which requests recovery history only through the `requestHistoryRefresh` boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first `GatewayConnectScreen` with a local command copy affordance and a collapsible remote form.
|
||||
- **Studio settings** (`src/lib/studio`, `src/app/api/studio`): local settings store for gateway URL/token and focused preferences (`src/lib/studio/settings.ts`, `src/lib/studio/settings-store.ts`, `src/app/api/studio/route.ts`). `src/lib/studio/coordinator.ts` now owns both the `/api/studio` transport helpers and shared client-side load/patch scheduling for gateway and focused settings.
|
||||
- **Gateway** (`src/lib/gateway`): WebSocket client for agent runtime (frames, connect, request/response). Session settings sync transport (`sessions.patch`) is centralized in `src/lib/gateway/GatewayClient.ts`. Connect failures surfaced through the Studio WS proxy are preserved as `GatewayResponseError` codes (parsed from `connect failed: <CODE> ...`) so `useGatewayConnection` can gate auto-retry via `resolveGatewayAutoRetryDelayMs`. The OpenClaw control UI client is vendored in `src/lib/gateway/openclaw/GatewayBrowserClient.ts` with a sync script at `scripts/sync-openclaw-gateway-client.ts`.
|
||||
- **Studio gateway proxy server** (`server/index.js`, `server/gateway-proxy.js`, `server/studio-settings.js`): custom Next server that terminates browser WS at `/api/gateway/ws`, loads upstream gateway URL/token server-side, injects auth token when needed, and forwards frames to the upstream gateway.
|
||||
|
||||
@@ -46,10 +46,10 @@ Agent creation is intentionally lightweight:
|
||||
- `src/features/agents/operations/mutationLifecycleWorkflow.ts` applies queue/guard behavior and calls create.
|
||||
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) performs `config.get` + `agents.create`.
|
||||
|
||||
After creation, Studio applies a safe default capability envelope:
|
||||
- Commands: `Ask`
|
||||
- Web access: `Off`
|
||||
- File tools: `Off`
|
||||
After creation, Studio applies a permissive default capability envelope:
|
||||
- Commands: `Auto`
|
||||
- Web access: `On`
|
||||
- File tools: `On`
|
||||
|
||||
Implementation:
|
||||
- `src/app/page.tsx` (`handleCreateAgentSubmit`) applies `CREATE_AGENT_DEFAULT_PERMISSIONS`.
|
||||
@@ -332,7 +332,7 @@ Code:
|
||||
|
||||
UI model:
|
||||
- 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).
|
||||
- Create modal remains permission-light (name/avatar only) and create flow immediately applies permissive defaults (`Auto`, web on, file tools on).
|
||||
|
||||
Why it matters:
|
||||
- You can have exec approvals configured but still be unable to run commands if `group:runtime` is denied.
|
||||
|
||||
+4
-4
@@ -50,8 +50,8 @@ This doc describes the current Studio IA and behavior.
|
||||
|
||||
## Agent Creation Defaults
|
||||
- Create modal captures only name/avatar.
|
||||
- After creation, Studio applies safe defaults:
|
||||
- Commands: Ask
|
||||
- Web access: Off
|
||||
- File tools: Off
|
||||
- After creation, Studio applies permissive defaults:
|
||||
- Commands: Auto
|
||||
- Web access: On
|
||||
- File tools: On
|
||||
- Post-create UX keeps chat as primary and auto-opens Capabilities sidebar for onboarding.
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
# Skills in OpenClaw + OpenClaw Studio
|
||||
|
||||
This document explains skills from first principles, how they work in the OpenClaw runtime (`~/openclaw`), and how OpenClaw Studio currently exposes them in UX.
|
||||
|
||||
It is intended as design context for rethinking the Skills UX.
|
||||
|
||||
## 1) Why skills exist (first principles)
|
||||
|
||||
Skills are the mechanism OpenClaw uses to give agents reusable operational know-how without hardcoding that know-how into core runtime logic.
|
||||
|
||||
At a product level, a skill is:
|
||||
- A unit of capability guidance (`SKILL.md`) that teaches an agent how to perform a job.
|
||||
- A gated unit of readiness (only available when required binaries/env/config/OS are satisfied).
|
||||
- A portable package format compatible with AgentSkills (`agentskills.io`) so skill content can be authored and shared outside a single product.
|
||||
|
||||
Without skills, every workflow instruction would need to live in prompts, app code, or ad hoc user messages. Skills create a middle layer: structured capability packs that are discoverable, filterable, and enforceable.
|
||||
|
||||
## 2) AgentSkills.io context
|
||||
|
||||
OpenClaw intentionally uses AgentSkills-compatible `SKILL.md` structure and semantics.
|
||||
|
||||
Why this matters:
|
||||
- Interoperability: skills can move between ecosystems that understand AgentSkills.
|
||||
- Community/network effects: external skill ecosystems (for OpenClaw specifically, ClawHub) can be leveraged instead of reinventing proprietary formats.
|
||||
- UX consistency: users can reason about “a skill folder with `SKILL.md` + metadata gates” instead of app-specific abstractions.
|
||||
|
||||
OpenClaw adds product-specific metadata under `metadata.openclaw` (install specs, gating fields, primary env key, etc.) while keeping the base skill shape compatible.
|
||||
|
||||
## 3) Skill object model
|
||||
|
||||
A skill is loaded from a directory containing `SKILL.md` with frontmatter.
|
||||
|
||||
Minimum frontmatter:
|
||||
- `name`
|
||||
- `description`
|
||||
|
||||
Important optional fields used by OpenClaw:
|
||||
- `metadata.openclaw.always`
|
||||
- `metadata.openclaw.skillKey`
|
||||
- `metadata.openclaw.primaryEnv`
|
||||
- `metadata.openclaw.os`
|
||||
- `metadata.openclaw.requires.{bins, anyBins, env, config}`
|
||||
- `metadata.openclaw.install[]`
|
||||
- `user-invocable`
|
||||
- `disable-model-invocation`
|
||||
- `command-dispatch`, `command-tool`, `command-arg-mode`
|
||||
|
||||
In runtime, this becomes a normalized `SkillEntry`:
|
||||
- Raw skill (`name`, `description`, `source`, file paths)
|
||||
- Parsed frontmatter
|
||||
- Resolved OpenClaw metadata
|
||||
- Invocation policy flags
|
||||
|
||||
## 4) Where skills come from (discovery + precedence)
|
||||
|
||||
OpenClaw merges multiple sources into one effective skill set.
|
||||
|
||||
Current merge precedence in code (lowest -> highest):
|
||||
1. `skills.load.extraDirs` and plugin-contributed skill dirs (`source: openclaw-extra`)
|
||||
2. Bundled skills (`openclaw-bundled`)
|
||||
3. Managed/global local skills (`~/.openclaw/skills`, `openclaw-managed`)
|
||||
4. Personal agents skills (`~/.agents/skills`, `agents-skills-personal`)
|
||||
5. Project agents skills (`<workspace>/.agents/skills`, `agents-skills-project`)
|
||||
6. Workspace skills (`<workspace>/skills`, `openclaw-workspace`)
|
||||
|
||||
Name conflicts are resolved by “last writer wins” according to this order.
|
||||
|
||||
## 5) Eligibility and gating model
|
||||
|
||||
Eligibility is not just “is this skill installed.” It is computed every load/snapshot using:
|
||||
- Per-skill disable (`skills.entries.<skillKey>.enabled === false`)
|
||||
- Bundled allowlist (`skills.allowBundled`) for bundled skills only
|
||||
- Runtime requirements:
|
||||
- `requires.bins` (all required)
|
||||
- `requires.anyBins` (at least one)
|
||||
- `requires.env`
|
||||
- `requires.config`
|
||||
- `os`
|
||||
- Remote node eligibility (macOS node bin probing can satisfy certain requirements)
|
||||
- `always: true` short-circuiting requirement failures
|
||||
|
||||
Status output carries:
|
||||
- `eligible` / `blocked`
|
||||
- structured `missing` reasons
|
||||
- `configChecks` with `{ path, satisfied }` (not secret values)
|
||||
- install options derived from metadata
|
||||
|
||||
## 6) Agent-level filtering semantics
|
||||
|
||||
OpenClaw has a separate per-agent skill filter via `agents.list[].skills`:
|
||||
- Missing `skills` key: all discovered skills are allowed
|
||||
- `skills: []`: no skills allowed
|
||||
- `skills: ["a", "b"]`: allowlist mode
|
||||
|
||||
This filter is normalized and passed into snapshot generation as `skillFilter`.
|
||||
|
||||
In practice this is the key UX distinction:
|
||||
- Discovery/readiness is global + workspace-derived.
|
||||
- “Can this specific agent use it?” is per-agent allowlist.
|
||||
|
||||
## 7) Snapshot + prompt lifecycle
|
||||
|
||||
Skills are snapshotted into session state (`skillsSnapshot`) to avoid re-scanning every turn.
|
||||
|
||||
Snapshot contains:
|
||||
- prebuilt prompt block
|
||||
- lightweight skill metadata (`name`, `primaryEnv`, required env names)
|
||||
- normalized `skillFilter`
|
||||
- resolved skills list
|
||||
- version
|
||||
|
||||
Lifecycle:
|
||||
1. First turn/new session builds snapshot.
|
||||
2. File watcher / remote-node events bump snapshot version.
|
||||
3. Later turns refresh snapshot only if version is newer.
|
||||
4. Prompt injection uses snapshot prompt when present.
|
||||
|
||||
Watcher scope includes:
|
||||
- workspace `skills/`
|
||||
- workspace `.agents/skills`
|
||||
- `~/.openclaw/skills`
|
||||
- `~/.agents/skills`
|
||||
- configured extra dirs
|
||||
- plugin skill dirs
|
||||
|
||||
Watcher monitors `SKILL.md` patterns (not entire trees) and debounces changes.
|
||||
|
||||
## 8) Runtime execution behavior
|
||||
|
||||
During an agent run:
|
||||
1. Skill env overrides are applied (`skills.entries.*.env` + `apiKey` mapping to `primaryEnv`).
|
||||
2. Overrides are sanitized/guarded (dangerous host env keys blocked).
|
||||
3. Skills prompt is injected.
|
||||
4. Environment is restored after run.
|
||||
|
||||
Invocation behavior:
|
||||
- `disable-model-invocation: true` keeps skill out of model prompt.
|
||||
- `user-invocable: true` exposes slash commands.
|
||||
- Optional direct tool dispatch can bypass model routing.
|
||||
|
||||
Sandbox nuance:
|
||||
- For non-`rw` sandbox workspaces, OpenClaw syncs skills into sandbox workspace (best-effort) so skill files remain accessible.
|
||||
|
||||
## 9) Gateway API surface for skills
|
||||
|
||||
Core RPC methods:
|
||||
- `skills.status` -> returns `SkillStatusReport` for an agent workspace.
|
||||
- `skills.install` -> installs dependencies for a skill install option.
|
||||
- `skills.update` -> updates `skills.entries.<skillKey>` config (`enabled`, `apiKey`, `env`).
|
||||
- `skills.bins` -> aggregates required bins across agent workspaces.
|
||||
|
||||
Important scope behavior:
|
||||
- `skills.install` is executed against the default agent workspace (not arbitrary selected agent workspace).
|
||||
- `skills.update` writes gateway config (`openclaw.json`) and is gateway-wide state mutation.
|
||||
|
||||
Security detail:
|
||||
- `skills.status` exposes config check satisfaction, not raw secret config values.
|
||||
|
||||
## 10) OpenClaw Studio UX (current behavior)
|
||||
|
||||
### 10.1 Route and navigation model
|
||||
|
||||
Studio settings currently live on root route with a query-driven settings mode:
|
||||
- Canonical settings state is `/?settingsAgentId=<agentId>`.
|
||||
- `/agents/[agentId]/settings` currently redirects to that query route.
|
||||
|
||||
Left nav tabs in settings mode:
|
||||
- Behavior
|
||||
- Capabilities
|
||||
- Skills
|
||||
- Automations
|
||||
- Advanced
|
||||
|
||||
### 10.2 Skills tab data and interactions
|
||||
|
||||
When either `Skills` or `System setup` tab is active and connected, Studio:
|
||||
1. Calls `skills.status`.
|
||||
2. Reads current per-agent allowlist from gateway config (`agents.list[].skills`).
|
||||
3. Renders two distinct settings surfaces:
|
||||
|
||||
`Skills` tab (agent-scoped):
|
||||
- Shows one list focused on “what this agent can use”.
|
||||
- Per-skill allow toggle (`Skill <name>` switch) for agent access only.
|
||||
- Simplified status chips (`Ready`, `Setup required`, `Not supported`).
|
||||
- Search + status filters for scanning.
|
||||
- Non-ready rows provide `Open System Setup` instead of inline setup actions.
|
||||
|
||||
`System setup` tab (gateway-scoped):
|
||||
- Explicitly states that setup actions affect all agents.
|
||||
- Shows setup queue and full readiness details.
|
||||
- Per-skill `Configure` modal with setup/lifecycle actions:
|
||||
- install dependencies (`skills.install`)
|
||||
- save API key (`skills.update` with `apiKey`)
|
||||
- global enable/disable (`skills.update` with `enabled`)
|
||||
- remove removable skill directories via Studio remove route
|
||||
- Supports transition handoff from agent row to preselected skill setup context.
|
||||
|
||||
### 10.3 Mutation wiring from Studio
|
||||
|
||||
Per-agent access mutations:
|
||||
- `updateGatewayAgentSkillsAllowlist` in Studio writes `config.set` with retry-on-stale-hash behavior.
|
||||
- Agent toggles continue to rely on allowlist semantics (`undefined` means all, explicit array means selected-only).
|
||||
|
||||
System setup mutations:
|
||||
- Install -> `skills.install`
|
||||
- API key save -> `skills.update`
|
||||
- Remove files -> Studio route `/api/gateway/skills/remove` (local fs or SSH helper)
|
||||
|
||||
Removal has strict guards:
|
||||
- Only specific sources removable (`openclaw-managed`, `openclaw-workspace`).
|
||||
- Must stay inside allowed root.
|
||||
- Cannot remove skills root directory.
|
||||
- Must look like a real skill dir (`SKILL.md` exists).
|
||||
|
||||
### 10.4 Scope warning shown in Studio
|
||||
|
||||
Studio computes the default agent id and passes install-scope context into the system setup surface.
|
||||
|
||||
Current scope copy behavior:
|
||||
- `Skills` tab copy states controls apply to the current agent.
|
||||
- `System setup` tab copy states actions apply to all agents.
|
||||
- Install target caveat (default-agent workspace behavior) is shown in system setup context and setup modal context, where install actions actually occur.
|
||||
|
||||
This keeps scope and install-target warnings accurate while minimizing noise in the agent access flow.
|
||||
|
||||
## 11) What recent `.agent/done` plans show
|
||||
|
||||
Sorted by most recent creation time in `openclaw-studio/.agent/done`, the latest items are mostly bugfix exec plans (streaming, proxy auth, stale config, cron rollback, etc.).
|
||||
|
||||
The most recent plan with explicit skills direction is:
|
||||
- `ui-execplan-stuff.md` (2026-02-20 create time), which intentionally scoped skills as coming-soon during that IA pass.
|
||||
|
||||
Additional files with incidental skill mentions:
|
||||
- `simplify-agent-creation-starter-kits.md`
|
||||
- `ux-zero-agent-layout-consolidation.md`
|
||||
|
||||
Interpretation:
|
||||
- The current Studio code now has a real Skills tab and mutation flow, but the older IA/doc language still contains “coming soon” assumptions in places.
|
||||
- For redesign, trust current code behavior over older plan phrasing.
|
||||
|
||||
## 12) UX redesign constraints that are not optional
|
||||
|
||||
Any redesign should preserve these distinctions:
|
||||
|
||||
1. Three separate scopes:
|
||||
- Agent allowlist scope (`agents.list[].skills`)
|
||||
- Gateway setup scope (`skills.entries.*`, installs)
|
||||
- Source/discovery scope (workspace/managed/bundled/extra/plugin)
|
||||
|
||||
2. Eligibility vs enablement:
|
||||
- A skill can be enabled by allowlist but still blocked by missing requirements.
|
||||
- A skill can be eligible but disabled by agent allowlist.
|
||||
|
||||
3. Session-snapshot behavior:
|
||||
- Skills changes may not appear mid-turn; they apply on next turn/snapshot refresh.
|
||||
|
||||
4. Install target caveat:
|
||||
- Install currently targets default agent workspace context in gateway path.
|
||||
|
||||
5. Security posture:
|
||||
- Secret values should never be exposed in status surfaces.
|
||||
- Removal must stay bounded to allowed roots and verified skill dirs.
|
||||
|
||||
## 13) Practical mental model for reviewing a Skills screenshot
|
||||
|
||||
If you hand a screenshot to another LLM for UX feedback, ask it to evaluate on three axes:
|
||||
|
||||
1. **Scope clarity**
|
||||
- Can a user tell what is per-agent vs gateway-wide?
|
||||
|
||||
2. **Readiness clarity**
|
||||
- Can a user tell blocked vs eligible and why?
|
||||
|
||||
3. **Action safety**
|
||||
- Are destructive/setup actions clearly separated from allowlist toggles?
|
||||
|
||||
If a design fails any of those axes, users will misconfigure skills even if controls are technically correct.
|
||||
+38
-16
@@ -79,7 +79,6 @@ import {
|
||||
import { createSpecialLatestUpdateOperation } from "@/features/agents/operations/specialLatestUpdateOperation";
|
||||
import {
|
||||
resolveAgentPermissionsDraft,
|
||||
type AgentPermissionsDraft,
|
||||
} from "@/features/agents/operations/agentPermissionsOperation";
|
||||
import {
|
||||
executeStudioBootstrapLoadCommands,
|
||||
@@ -91,6 +90,7 @@ import {
|
||||
runStudioFocusedSelectionPersistenceOperation,
|
||||
} from "@/features/agents/operations/studioBootstrapOperation";
|
||||
import {
|
||||
CREATE_AGENT_DEFAULT_PERMISSIONS,
|
||||
applyCreateAgentBootstrapPermissions,
|
||||
executeCreateAgentBootstrapCommands,
|
||||
runCreateAgentBootstrapOperation,
|
||||
@@ -114,11 +114,6 @@ import {
|
||||
} from "@/features/agents/operations/settingsRouteWorkflow";
|
||||
import { useSettingsRouteController } from "@/features/agents/operations/useSettingsRouteController";
|
||||
const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500;
|
||||
const CREATE_AGENT_DEFAULT_PERMISSIONS: AgentPermissionsDraft = {
|
||||
commandMode: "ask",
|
||||
webAccess: false,
|
||||
fileTools: false,
|
||||
};
|
||||
|
||||
type MobilePane = "fleet" | "chat";
|
||||
type SettingsSidebarItem = SettingsRouteTab;
|
||||
@@ -249,6 +244,7 @@ const AgentStudioPage = () => {
|
||||
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(null);
|
||||
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
|
||||
const [inspectSidebar, setInspectSidebar] = useState<InspectSidebarState>(null);
|
||||
const [systemInitialSkillKey, setSystemInitialSkillKey] = useState<string | null>(null);
|
||||
const [personalityHasUnsavedChanges, setPersonalityHasUnsavedChanges] = useState(false);
|
||||
const [settingsSidebarItem, setSettingsSidebarItem] = useState<SettingsSidebarItem>("personality");
|
||||
const [createAgentBlock, setCreateAgentBlock] = useState<CreateAgentBlockState | null>(null);
|
||||
@@ -308,6 +304,14 @@ const AgentStudioPage = () => {
|
||||
if (!inspectSidebarAgentId) return null;
|
||||
return agents.find((entry) => entry.agentId === inspectSidebarAgentId) ?? null;
|
||||
}, [agents, inspectSidebarAgentId]);
|
||||
useEffect(() => {
|
||||
setSystemInitialSkillKey(null);
|
||||
}, [inspectSidebarAgentId]);
|
||||
useEffect(() => {
|
||||
if (effectiveSettingsTab !== "system") {
|
||||
setSystemInitialSkillKey(null);
|
||||
}
|
||||
}, [effectiveSettingsTab]);
|
||||
const settingsAgentPermissionsDraft = useMemo(() => {
|
||||
if (!inspectSidebarAgent) return null;
|
||||
const baseConfig =
|
||||
@@ -360,9 +364,9 @@ const AgentStudioPage = () => {
|
||||
const settingsSkillScopeWarning = useMemo(() => {
|
||||
if (!inspectSidebarAgent) return null;
|
||||
if (inspectSidebarAgent.agentId === settingsDefaultAgentId) {
|
||||
return "Skill setup actions are gateway-wide. Install actions run in this default agent workspace.";
|
||||
return "Setup actions are shared across agents. Installs run in this shared workspace.";
|
||||
}
|
||||
return `Skill setup actions are gateway-wide. Install actions currently run in the default agent workspace (${settingsDefaultAgentId}), not this agent (${inspectSidebarAgent.agentId}).`;
|
||||
return `Setup actions are shared across agents. Installs currently run in ${settingsDefaultAgentId} (shared workspace), not ${inspectSidebarAgent.agentId}.`;
|
||||
}, [inspectSidebarAgent, settingsDefaultAgentId]);
|
||||
const focusedPendingExecApprovals = useMemo(() => {
|
||||
if (!focusedAgentId) return unscopedPendingExecApprovals;
|
||||
@@ -821,6 +825,15 @@ const AgentStudioPage = () => {
|
||||
replace: router.replace,
|
||||
confirmDiscard: () => window.confirm("Discard changes?"),
|
||||
});
|
||||
const handleOpenSystemSkillSetup = useCallback(
|
||||
(skillKey?: string) => {
|
||||
const normalized = skillKey?.trim() ?? "";
|
||||
setSystemInitialSkillKey(normalized.length > 0 ? normalized : null);
|
||||
setSettingsSidebarItem("system");
|
||||
handleSettingsRouteTabChange("system");
|
||||
},
|
||||
[handleSettingsRouteTabChange]
|
||||
);
|
||||
|
||||
const handleOpenCreateAgentModal = useCallback(() => {
|
||||
if (createAgentBusy) return;
|
||||
@@ -905,7 +918,7 @@ const AgentStudioPage = () => {
|
||||
client,
|
||||
agentId,
|
||||
sessionKey,
|
||||
draft: CREATE_AGENT_DEFAULT_PERMISSIONS,
|
||||
draft: { ...CREATE_AGENT_DEFAULT_PERMISSIONS },
|
||||
loadAgents,
|
||||
});
|
||||
},
|
||||
@@ -1398,6 +1411,7 @@ const AgentStudioPage = () => {
|
||||
{ id: "personality", label: "Behavior" },
|
||||
{ id: "capabilities", label: "Capabilities" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "system", label: "System setup" },
|
||||
{ id: "automations", label: "Automations" },
|
||||
{ id: "advanced", label: "Advanced" },
|
||||
] as const
|
||||
@@ -1464,7 +1478,9 @@ const AgentStudioPage = () => {
|
||||
? "automations"
|
||||
: effectiveSettingsTab === "skills"
|
||||
? "skills"
|
||||
: effectiveSettingsTab === "advanced"
|
||||
: effectiveSettingsTab === "system"
|
||||
? "system"
|
||||
: effectiveSettingsTab === "advanced"
|
||||
? "advanced"
|
||||
: "capabilities"
|
||||
}
|
||||
@@ -1496,13 +1512,11 @@ const AgentStudioPage = () => {
|
||||
skillMessages={settingsMutationController.settingsSkillMessages}
|
||||
skillApiKeyDrafts={settingsMutationController.settingsSkillApiKeyDrafts}
|
||||
defaultAgentScopeWarning={settingsSkillScopeWarning}
|
||||
systemInitialSkillKey={systemInitialSkillKey}
|
||||
onSystemInitialSkillHandled={() => {
|
||||
setSystemInitialSkillKey(null);
|
||||
}}
|
||||
skillsAllowlist={settingsAgentSkillsAllowlist}
|
||||
onUseAllSkills={() =>
|
||||
settingsMutationController.handleUseAllSkills(inspectSidebarAgent.agentId)
|
||||
}
|
||||
onDisableAllSkills={() =>
|
||||
settingsMutationController.handleDisableAllSkills(inspectSidebarAgent.agentId)
|
||||
}
|
||||
onSetSkillEnabled={(skillName, enabled) =>
|
||||
settingsMutationController.handleSetSkillEnabled(
|
||||
inspectSidebarAgent.agentId,
|
||||
@@ -1510,6 +1524,7 @@ const AgentStudioPage = () => {
|
||||
enabled
|
||||
)
|
||||
}
|
||||
onOpenSystemSetup={handleOpenSystemSkillSetup}
|
||||
onInstallSkill={(skillKey, name, installId) =>
|
||||
settingsMutationController.handleInstallSkill(
|
||||
inspectSidebarAgent.agentId,
|
||||
@@ -1533,6 +1548,13 @@ const AgentStudioPage = () => {
|
||||
skillKey
|
||||
)
|
||||
}
|
||||
onSetSkillGlobalEnabled={(skillKey, enabled) =>
|
||||
settingsMutationController.handleSetSkillGlobalEnabled(
|
||||
inspectSidebarAgent.agentId,
|
||||
skillKey,
|
||||
enabled
|
||||
)
|
||||
}
|
||||
cronJobs={settingsMutationController.settingsCronJobs}
|
||||
cronLoading={settingsMutationController.settingsCronLoading}
|
||||
cronError={settingsMutationController.settingsCronError}
|
||||
|
||||
@@ -19,20 +19,14 @@ import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPay
|
||||
import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import type { SkillStatusReport } from "@/lib/skills/types";
|
||||
import {
|
||||
buildSkillMissingDetails,
|
||||
buildSkillReasons,
|
||||
canRemoveSkill,
|
||||
groupSkillsBySource,
|
||||
isBundledBlockedSkill,
|
||||
resolvePreferredInstallOption,
|
||||
} from "@/lib/skills/presentation";
|
||||
import { readGatewayAgentFile, writeGatewayAgentFile } from "@/lib/gateway/agentFiles";
|
||||
import {
|
||||
resolveExecutionRoleFromAgent,
|
||||
resolvePresetDefaultsForRole,
|
||||
type AgentPermissionsDraft,
|
||||
} from "@/features/agents/operations/agentPermissionsOperation";
|
||||
import { AgentSkillsPanel } from "@/features/agents/components/AgentSkillsPanel";
|
||||
import { SystemSkillsPanel } from "@/features/agents/components/SystemSkillsPanel";
|
||||
import {
|
||||
AGENT_FILE_NAMES,
|
||||
type AgentFileName,
|
||||
@@ -97,7 +91,7 @@ const AgentInspectHeader = ({
|
||||
|
||||
type AgentSettingsPanelProps = {
|
||||
agent: AgentState;
|
||||
mode?: "capabilities" | "skills" | "automations" | "advanced";
|
||||
mode?: "capabilities" | "skills" | "system" | "automations" | "advanced";
|
||||
showHeader?: boolean;
|
||||
onClose: () => void;
|
||||
permissionsDraft?: AgentPermissionsDraft;
|
||||
@@ -124,10 +118,12 @@ type AgentSettingsPanelProps = {
|
||||
skillMessages?: Record<string, { kind: "success" | "error"; message: string }>;
|
||||
skillApiKeyDrafts?: Record<string, string>;
|
||||
defaultAgentScopeWarning?: string | null;
|
||||
systemInitialSkillKey?: string | null;
|
||||
onSystemInitialSkillHandled?: () => void;
|
||||
skillsAllowlist?: string[] | undefined;
|
||||
onUseAllSkills?: () => Promise<void> | void;
|
||||
onDisableAllSkills?: () => Promise<void> | void;
|
||||
onSetSkillEnabled?: (skillName: string, enabled: boolean) => Promise<void> | void;
|
||||
onOpenSystemSetup?: (skillKey?: string) => void;
|
||||
onSetSkillGlobalEnabled?: (skillKey: string, enabled: boolean) => Promise<void> | void;
|
||||
onInstallSkill?: (skillKey: string, name: string, installId: string) => Promise<void> | void;
|
||||
onRemoveSkill?: (
|
||||
skill: { skillKey: string; source: string; baseDir: string }
|
||||
@@ -325,10 +321,12 @@ export const AgentSettingsPanel = ({
|
||||
skillMessages = {},
|
||||
skillApiKeyDrafts = {},
|
||||
defaultAgentScopeWarning = null,
|
||||
systemInitialSkillKey = null,
|
||||
onSystemInitialSkillHandled = () => {},
|
||||
skillsAllowlist,
|
||||
onUseAllSkills = () => {},
|
||||
onDisableAllSkills = () => {},
|
||||
onSetSkillEnabled = () => {},
|
||||
onOpenSystemSetup = () => {},
|
||||
onSetSkillGlobalEnabled = () => {},
|
||||
onInstallSkill = () => {},
|
||||
onRemoveSkill = () => {},
|
||||
onSkillApiKeyChange = () => {},
|
||||
@@ -352,14 +350,6 @@ export const AgentSettingsPanel = ({
|
||||
const [cronCreateStep, setCronCreateStep] = useState(0);
|
||||
const [cronCreateError, setCronCreateError] = useState<string | null>(null);
|
||||
const [cronDraft, setCronDraft] = useState<CronCreateDraft>(createInitialCronDraft);
|
||||
const [skillsFilter, setSkillsFilter] = useState("");
|
||||
const [hideBundledBlockedSkills, setHideBundledBlockedSkills] = useState(true);
|
||||
const [pendingSkillRemoval, setPendingSkillRemoval] = useState<{
|
||||
skillKey: string;
|
||||
name: string;
|
||||
source: string;
|
||||
baseDir: string;
|
||||
} | null>(null);
|
||||
|
||||
const resolvedExecutionRole = useMemo(() => resolveExecutionRoleFromAgent(agent), [agent]);
|
||||
const resolvedPermissionsDraft = useMemo(
|
||||
@@ -428,12 +418,6 @@ export const AgentSettingsPanel = ({
|
||||
};
|
||||
}, [permissionsDirty, permissionsDraftValue, permissionsSaving, runPermissionsSave]);
|
||||
|
||||
useEffect(() => {
|
||||
setSkillsFilter("");
|
||||
setHideBundledBlockedSkills(true);
|
||||
setPendingSkillRemoval(null);
|
||||
}, [agent.agentId]);
|
||||
|
||||
const openCronCreate = () => {
|
||||
setCronCreateOpen(true);
|
||||
setCronCreateStep(0);
|
||||
@@ -520,45 +504,14 @@ export const AgentSettingsPanel = ({
|
||||
}
|
||||
};
|
||||
|
||||
const skillEntries = skillsReport?.skills ?? [];
|
||||
const normalizedAllowlist = useMemo(
|
||||
() =>
|
||||
(skillsAllowlist ?? [])
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
[skillsAllowlist]
|
||||
);
|
||||
const allowlistSet = useMemo(() => new Set(normalizedAllowlist), [normalizedAllowlist]);
|
||||
const usingAllowlist = skillsAllowlist !== undefined;
|
||||
const enabledSkillCount = useMemo(() => {
|
||||
if (!usingAllowlist) return skillEntries.length;
|
||||
return skillEntries.reduce((count, skill) => count + (allowlistSet.has(skill.name) ? 1 : 0), 0);
|
||||
}, [allowlistSet, skillEntries, usingAllowlist]);
|
||||
const searchedSkillEntries = useMemo(() => {
|
||||
const query = skillsFilter.trim().toLowerCase();
|
||||
if (!query) return skillEntries;
|
||||
return skillEntries.filter((skill) =>
|
||||
[skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(query)
|
||||
);
|
||||
}, [skillEntries, skillsFilter]);
|
||||
const filteredSkillEntries = useMemo(() => {
|
||||
if (!hideBundledBlockedSkills) {
|
||||
return searchedSkillEntries;
|
||||
}
|
||||
return searchedSkillEntries.filter((skill) => !isBundledBlockedSkill(skill));
|
||||
}, [hideBundledBlockedSkills, searchedSkillEntries]);
|
||||
const filteredSkillGroups = useMemo(
|
||||
() => groupSkillsBySource(filteredSkillEntries),
|
||||
[filteredSkillEntries]
|
||||
);
|
||||
const hiddenBundledBlockedCount = useMemo(() => {
|
||||
if (!hideBundledBlockedSkills) return 0;
|
||||
return searchedSkillEntries.reduce(
|
||||
(count, skill) => count + (isBundledBlockedSkill(skill) ? 1 : 0),
|
||||
0
|
||||
);
|
||||
}, [hideBundledBlockedSkills, searchedSkillEntries]);
|
||||
const panelLabel = mode === "advanced" ? "Advanced" : mode === "skills" ? "Skills" : "";
|
||||
const panelLabel =
|
||||
mode === "advanced"
|
||||
? "Advanced"
|
||||
: mode === "skills"
|
||||
? "Skills"
|
||||
: mode === "system"
|
||||
? "System setup"
|
||||
: "";
|
||||
const canOpenControlUi = typeof controlUiUrl === "string" && controlUiUrl.trim().length > 0;
|
||||
const timedAutomationStepMeta =
|
||||
TIMED_AUTOMATION_STEP_META[cronCreateStep] ??
|
||||
@@ -725,252 +678,36 @@ export const AgentSettingsPanel = ({
|
||||
) : null}
|
||||
|
||||
{mode === "skills" ? (
|
||||
<section className="sidebar-section" data-testid="agent-settings-skills">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="sidebar-section-title">Skills</h3>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{usingAllowlist ? `${enabledSkillCount}/${skillEntries.length}` : `All (${skillEntries.length})`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
Control which discovered skills this agent can use.
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/80">
|
||||
Allowlist toggles affect this agent only. Install and API key setup affect gateway-wide
|
||||
skill readiness.
|
||||
</div>
|
||||
{defaultAgentScopeWarning ? (
|
||||
<div className="ui-alert-danger mt-2 rounded-md px-3 py-2 text-[10px]">
|
||||
{defaultAgentScopeWarning}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
value={skillsFilter}
|
||||
onChange={(event) => setSkillsFilter(event.target.value)}
|
||||
placeholder="Search skills"
|
||||
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
|
||||
aria-label="Search skills"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-2 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={skillsBusy || skillsLoading || Boolean(skillsBusyKey)}
|
||||
onClick={() => {
|
||||
void onUseAllSkills();
|
||||
}}
|
||||
>
|
||||
Use all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-2 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={skillsBusy || skillsLoading || Boolean(skillsBusyKey)}
|
||||
onClick={() => {
|
||||
void onDisableAllSkills();
|
||||
}}
|
||||
>
|
||||
Disable all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="inline-flex items-center gap-2 text-[10px] text-muted-foreground/85">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Hide bundled + blocked"
|
||||
checked={hideBundledBlockedSkills}
|
||||
onChange={(event) => setHideBundledBlockedSkills(event.target.checked)}
|
||||
/>
|
||||
<span>Hide bundled + blocked</span>
|
||||
</label>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{filteredSkillEntries.length}/{skillEntries.length} shown
|
||||
</div>
|
||||
</div>
|
||||
{hideBundledBlockedSkills && hiddenBundledBlockedCount > 0 ? (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/80">
|
||||
Hidden bundled + blocked: {hiddenBundledBlockedCount}
|
||||
</div>
|
||||
) : null}
|
||||
{skillsLoading ? (
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div>
|
||||
) : null}
|
||||
{!skillsLoading && skillsError ? (
|
||||
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredSkillEntries.length === 0 ? (
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredSkillEntries.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{filteredSkillGroups.map((group) => {
|
||||
const collapsedByDefault = group.id === "workspace" || group.id === "built-in";
|
||||
return (
|
||||
<details
|
||||
key={group.id}
|
||||
className="rounded-md border border-border/60 bg-surface-1/65"
|
||||
open={!collapsedByDefault}
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center justify-between px-3 py-2 text-[11px]">
|
||||
<span className="font-medium text-foreground/88">{group.label}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{group.skills.length}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="flex flex-col gap-2 px-3 pb-3 pt-1">
|
||||
{group.skills.map((skill) => {
|
||||
const enabled = usingAllowlist ? allowlistSet.has(skill.name) : true;
|
||||
const missingDetails = buildSkillMissingDetails(skill);
|
||||
const reasons = buildSkillReasons(skill);
|
||||
const message = skillMessages[skill.skillKey] ?? null;
|
||||
const busyForSkill = skillsBusyKey === skill.skillKey;
|
||||
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
|
||||
const installOption = resolvePreferredInstallOption(skill);
|
||||
const canDeleteSkill = canRemoveSkill(skill);
|
||||
const apiKeyDraft = skillApiKeyDrafts[skill.skillKey] ?? "";
|
||||
const hasApiKeyDraft = apiKeyDraft.trim().length > 0;
|
||||
<AgentSkillsPanel
|
||||
skillsReport={skillsReport}
|
||||
skillsLoading={skillsLoading}
|
||||
skillsError={skillsError}
|
||||
skillsBusy={skillsBusy}
|
||||
skillsBusyKey={skillsBusyKey}
|
||||
skillsAllowlist={skillsAllowlist}
|
||||
onSetSkillEnabled={onSetSkillEnabled}
|
||||
onOpenSystemSetup={onOpenSystemSetup}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${skill.source}:${skill.skillKey}`}
|
||||
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-[11px] font-medium text-foreground/88">
|
||||
{skill.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
|
||||
{skill.source}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${
|
||||
skill.eligible ? "ui-badge-status-running" : "ui-badge-status-error"
|
||||
}`}
|
||||
>
|
||||
{skill.eligible ? "eligible" : "blocked"}
|
||||
</span>
|
||||
{skill.disabled ? (
|
||||
<span className="ui-badge-status-error rounded px-1.5 py-0.5 font-mono text-[9px]">
|
||||
disabled
|
||||
</span>
|
||||
) : null}
|
||||
{skill.blockedByAllowlist ? (
|
||||
<span className="ui-badge-status-error rounded px-1.5 py-0.5 font-mono text-[9px]">
|
||||
allowlist block
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/70">
|
||||
{skill.description}
|
||||
</div>
|
||||
{missingDetails.map((line) => (
|
||||
<div key={`${skill.skillKey}:${line}`} className="mt-1 text-[10px] text-muted-foreground/70">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
{reasons.length > 0 ? (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/70">
|
||||
Reason: {reasons.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
{message ? (
|
||||
<div
|
||||
className={`mt-1 text-[10px] ${
|
||||
message.kind === "error" ? "ui-text-danger" : "ui-text-success"
|
||||
}`}
|
||||
>
|
||||
{message.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-stretch gap-2 sm:w-[240px]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label={`Skill ${skill.name}`}
|
||||
aria-checked={enabled}
|
||||
className={`ui-switch self-start ${enabled ? "ui-switch--on" : ""}`}
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
void onSetSkillEnabled(skill.name, !enabled);
|
||||
}}
|
||||
>
|
||||
<span className="ui-switch-thumb" />
|
||||
</button>
|
||||
{canDeleteSkill ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove skill ${skill.name}`}
|
||||
className="ui-btn-icon ui-btn-icon-danger h-7 w-7 bg-transparent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
setPendingSkillRemoval({
|
||||
skillKey: skill.skillKey,
|
||||
name: skill.name,
|
||||
source: skill.source,
|
||||
baseDir: skill.baseDir,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{installOption ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
void onInstallSkill(skill.skillKey, skill.name, installOption.id);
|
||||
}}
|
||||
>
|
||||
{busyForSkill ? "Working..." : installOption.label}
|
||||
</button>
|
||||
) : null}
|
||||
{skill.primaryEnv ? (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeyDraft}
|
||||
onChange={(event) => {
|
||||
void onSkillApiKeyChange(skill.skillKey, event.target.value);
|
||||
}}
|
||||
disabled={anySkillBusy}
|
||||
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[10px] text-foreground outline-none transition focus:border-border"
|
||||
placeholder={`Set ${skill.primaryEnv}`}
|
||||
aria-label={`API key for ${skill.name}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy || !hasApiKeyDraft}
|
||||
onClick={() => {
|
||||
if (!hasApiKeyDraft) {
|
||||
return;
|
||||
}
|
||||
void onSaveSkillApiKey(skill.skillKey);
|
||||
}}
|
||||
>
|
||||
{busyForSkill ? "Working..." : `Save ${skill.primaryEnv}`}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
{mode === "system" ? (
|
||||
<SystemSkillsPanel
|
||||
skillsReport={skillsReport}
|
||||
skillsLoading={skillsLoading}
|
||||
skillsError={skillsError}
|
||||
skillsBusy={skillsBusy}
|
||||
skillsBusyKey={skillsBusyKey}
|
||||
skillMessages={skillMessages}
|
||||
skillApiKeyDrafts={skillApiKeyDrafts}
|
||||
defaultAgentScopeWarning={defaultAgentScopeWarning}
|
||||
initialSkillKey={systemInitialSkillKey}
|
||||
onInitialSkillKeyHandled={onSystemInitialSkillHandled}
|
||||
onSetSkillGlobalEnabled={onSetSkillGlobalEnabled}
|
||||
onInstallSkill={onInstallSkill}
|
||||
onRemoveSkill={onRemoveSkill}
|
||||
onSkillApiKeyChange={onSkillApiKeyChange}
|
||||
onSaveSkillApiKey={onSaveSkillApiKey}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{mode === "automations" ? (
|
||||
@@ -1191,82 +928,6 @@ export const AgentSettingsPanel = ({
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{pendingSkillRemoval ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Remove skill ${pendingSkillRemoval.name}`}
|
||||
onClick={() => {
|
||||
setPendingSkillRemoval(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="ui-panel w-full max-w-xl bg-card shadow-xs"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-6 py-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80">
|
||||
Remove skill files
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-foreground">
|
||||
Remove {pendingSkillRemoval.name} from the gateway?
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]"
|
||||
onClick={() => {
|
||||
setPendingSkillRemoval(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-6 pb-3 text-[11px] text-muted-foreground">
|
||||
<div>
|
||||
This permanently removes this skill directory on the gateway host. This action cannot
|
||||
be undone.
|
||||
</div>
|
||||
<div className="rounded-md border border-border/60 bg-surface-1/65 px-3 py-3 font-mono text-[10px] text-foreground/85">
|
||||
<div>Source: {pendingSkillRemoval.source}</div>
|
||||
<div className="mt-1 break-all">Path: {pendingSkillRemoval.baseDir}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-6 pb-5 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-2 text-[10px] font-medium"
|
||||
onClick={() => {
|
||||
setPendingSkillRemoval(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary ui-btn-danger px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={skillsBusy || Boolean(skillsBusyKey)}
|
||||
onClick={() => {
|
||||
const target = pendingSkillRemoval;
|
||||
setPendingSkillRemoval(null);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
void onRemoveSkill({
|
||||
skillKey: target.skillKey,
|
||||
source: target.source,
|
||||
baseDir: target.baseDir,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Remove skill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{cronCreateOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { SkillStatusReport } from "@/lib/skills/types";
|
||||
import {
|
||||
buildAgentSkillsAllowlistSet,
|
||||
buildSkillMissingDetails,
|
||||
deriveAgentSkillDisplayState,
|
||||
deriveAgentSkillsAccessMode,
|
||||
deriveSkillReadinessState,
|
||||
type AgentSkillDisplayState,
|
||||
} from "@/lib/skills/presentation";
|
||||
|
||||
type SkillRowFilter = "all" | AgentSkillDisplayState;
|
||||
|
||||
type AgentSkillsPanelProps = {
|
||||
skillsReport?: SkillStatusReport | null;
|
||||
skillsLoading?: boolean;
|
||||
skillsError?: string | null;
|
||||
skillsBusy?: boolean;
|
||||
skillsBusyKey?: string | null;
|
||||
skillsAllowlist?: string[] | undefined;
|
||||
onSetSkillEnabled: (skillName: string, enabled: boolean) => Promise<void> | void;
|
||||
onOpenSystemSetup: (skillKey?: string) => void;
|
||||
};
|
||||
|
||||
const FILTERS: Array<{ id: SkillRowFilter; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "ready", label: "Ready" },
|
||||
{ id: "setup-required", label: "Setup required" },
|
||||
{ id: "not-supported", label: "Not supported" },
|
||||
];
|
||||
|
||||
const DISPLAY_LABELS: Record<AgentSkillDisplayState, string> = {
|
||||
ready: "Ready",
|
||||
"setup-required": "Setup required",
|
||||
"not-supported": "Not supported",
|
||||
};
|
||||
|
||||
const DISPLAY_CLASSES: Record<AgentSkillDisplayState, string> = {
|
||||
ready: "ui-badge-status-running",
|
||||
"setup-required": "ui-badge-status-error",
|
||||
"not-supported": "ui-badge-status-error",
|
||||
};
|
||||
|
||||
const resolveHint = (
|
||||
skill: SkillStatusReport["skills"][number],
|
||||
displayState: AgentSkillDisplayState
|
||||
): string | null => {
|
||||
if (displayState === "ready") {
|
||||
return null;
|
||||
}
|
||||
if (displayState === "not-supported") {
|
||||
if (skill.blockedByAllowlist) {
|
||||
return "Blocked by bundled skills policy.";
|
||||
}
|
||||
return buildSkillMissingDetails(skill).find((line) => line.startsWith("Requires OS:")) ?? "Not supported.";
|
||||
}
|
||||
const readiness = deriveSkillReadinessState(skill);
|
||||
if (readiness === "disabled-globally") {
|
||||
return "Disabled globally. Enable it in System setup.";
|
||||
}
|
||||
return buildSkillMissingDetails(skill)[0] ?? "Requires setup in System setup.";
|
||||
};
|
||||
|
||||
export const AgentSkillsPanel = ({
|
||||
skillsReport = null,
|
||||
skillsLoading = false,
|
||||
skillsError = null,
|
||||
skillsBusy = false,
|
||||
skillsBusyKey = null,
|
||||
skillsAllowlist,
|
||||
onSetSkillEnabled,
|
||||
onOpenSystemSetup,
|
||||
}: AgentSkillsPanelProps) => {
|
||||
const [skillsFilter, setSkillsFilter] = useState("");
|
||||
const [rowFilter, setRowFilter] = useState<SkillRowFilter>("all");
|
||||
|
||||
const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]);
|
||||
const accessMode = deriveAgentSkillsAccessMode(skillsAllowlist);
|
||||
const allowlistSet = useMemo(() => buildAgentSkillsAllowlistSet(skillsAllowlist), [skillsAllowlist]);
|
||||
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return skillEntries.map((skill) => {
|
||||
const normalizedName = skill.name.trim();
|
||||
const allowed =
|
||||
accessMode === "all" ? true : accessMode === "none" ? false : allowlistSet.has(normalizedName);
|
||||
const readiness = deriveSkillReadinessState(skill);
|
||||
return {
|
||||
skill,
|
||||
allowed,
|
||||
displayState: deriveAgentSkillDisplayState(readiness),
|
||||
};
|
||||
});
|
||||
}, [accessMode, allowlistSet, skillEntries]);
|
||||
|
||||
const searchedRows = useMemo(() => {
|
||||
const query = skillsFilter.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((entry) =>
|
||||
[entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}, [rows, skillsFilter]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (rowFilter === "all") {
|
||||
return searchedRows;
|
||||
}
|
||||
return searchedRows.filter((entry) => entry.displayState === rowFilter);
|
||||
}, [rowFilter, searchedRows]);
|
||||
|
||||
const filterCounts = useMemo(
|
||||
() =>
|
||||
searchedRows.reduce(
|
||||
(counts, entry) => {
|
||||
counts.all += 1;
|
||||
counts[entry.displayState] += 1;
|
||||
return counts;
|
||||
},
|
||||
{
|
||||
all: 0,
|
||||
ready: 0,
|
||||
"setup-required": 0,
|
||||
"not-supported": 0,
|
||||
} satisfies Record<SkillRowFilter, number>
|
||||
),
|
||||
[searchedRows]
|
||||
);
|
||||
|
||||
const enabledCount = useMemo(
|
||||
() => rows.reduce((count, entry) => count + (entry.allowed ? 1 : 0), 0),
|
||||
[rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="sidebar-section" data-testid="agent-settings-skills">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="sidebar-section-title">Skills</h3>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{enabledCount}/{skillEntries.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">Skill access controls apply to this agent.</div>
|
||||
{accessMode === "selected" ? (
|
||||
<div className="mt-2 text-[10px] text-muted-foreground/80">
|
||||
This agent is using selected skills only.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
value={skillsFilter}
|
||||
onChange={(event) => setSkillsFilter(event.target.value)}
|
||||
placeholder="Search skills"
|
||||
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
|
||||
aria-label="Search skills"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{FILTERS.map((filter) => {
|
||||
const selected = rowFilter === filter.id;
|
||||
return (
|
||||
<button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
|
||||
data-active={selected ? "true" : "false"}
|
||||
disabled={skillsLoading}
|
||||
onClick={() => {
|
||||
setRowFilter(filter.id);
|
||||
}}
|
||||
>
|
||||
{filter.label} ({filterCounts[filter.id]})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
|
||||
{!skillsLoading && skillsError ? (
|
||||
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{filteredRows.map((entry) => {
|
||||
const statusLabel = DISPLAY_LABELS[entry.displayState];
|
||||
const statusClassName = DISPLAY_CLASSES[entry.displayState];
|
||||
const canConfigureInSystem = entry.displayState === "setup-required";
|
||||
const switchDisabled = anySkillBusy || entry.displayState === "not-supported";
|
||||
return (
|
||||
<div
|
||||
key={`${entry.skill.source}:${entry.skill.skillKey}`}
|
||||
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
|
||||
{entry.skill.source}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${statusClassName}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
|
||||
{entry.displayState !== "ready" ? (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/80">
|
||||
{resolveHint(entry.skill, entry.displayState)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2 sm:w-[240px] sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label={`Skill ${entry.skill.name}`}
|
||||
aria-checked={entry.allowed}
|
||||
className={`ui-switch self-start ${entry.allowed ? "ui-switch--on" : ""}`}
|
||||
disabled={switchDisabled}
|
||||
onClick={() => {
|
||||
void onSetSkillEnabled(entry.skill.name, !entry.allowed);
|
||||
}}
|
||||
>
|
||||
<span className="ui-switch-thumb" />
|
||||
</button>
|
||||
{canConfigureInSystem ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold"
|
||||
onClick={() => {
|
||||
onOpenSystemSetup(entry.skill.skillKey);
|
||||
}}
|
||||
>
|
||||
Open System Setup
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { SkillStatusEntry } from "@/lib/skills/types";
|
||||
import {
|
||||
buildSkillMissingDetails,
|
||||
canRemoveSkill,
|
||||
deriveSkillReadinessState,
|
||||
resolvePreferredInstallOption,
|
||||
} from "@/lib/skills/presentation";
|
||||
|
||||
type SkillSetupMessage = { kind: "success" | "error"; message: string };
|
||||
|
||||
type AgentSkillsSetupModalProps = {
|
||||
skill: SkillStatusEntry | null;
|
||||
skillsBusy: boolean;
|
||||
skillsBusyKey: string | null;
|
||||
skillMessage: SkillSetupMessage | null;
|
||||
apiKeyDraft: string;
|
||||
defaultAgentScopeWarning?: string | null;
|
||||
onClose: () => void;
|
||||
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
|
||||
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
|
||||
onRemoveSkill: (
|
||||
skill: { skillKey: string; source: string; baseDir: string }
|
||||
) => Promise<void> | void;
|
||||
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
|
||||
onSaveSkillApiKey: (skillKey: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const READINESS_LABELS = {
|
||||
ready: "Ready",
|
||||
"needs-setup": "Needs setup",
|
||||
unavailable: "Unavailable",
|
||||
"disabled-globally": "Disabled globally",
|
||||
} as const;
|
||||
|
||||
const READINESS_CLASSES = {
|
||||
ready: "ui-badge-status-running",
|
||||
"needs-setup": "ui-badge-status-error",
|
||||
unavailable: "ui-badge-status-error",
|
||||
"disabled-globally": "ui-badge-status-error",
|
||||
} as const;
|
||||
|
||||
export const AgentSkillsSetupModal = ({
|
||||
skill,
|
||||
skillsBusy,
|
||||
skillsBusyKey,
|
||||
skillMessage,
|
||||
apiKeyDraft,
|
||||
defaultAgentScopeWarning = null,
|
||||
onClose,
|
||||
onInstallSkill,
|
||||
onSetSkillGlobalEnabled,
|
||||
onRemoveSkill,
|
||||
onSkillApiKeyChange,
|
||||
onSaveSkillApiKey,
|
||||
}: AgentSkillsSetupModalProps) => {
|
||||
useEffect(() => {
|
||||
if (!skill) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, skill]);
|
||||
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const readiness = deriveSkillReadinessState(skill);
|
||||
const readinessLabel = READINESS_LABELS[readiness];
|
||||
const readinessClassName = READINESS_CLASSES[readiness];
|
||||
const missingDetails = buildSkillMissingDetails(skill);
|
||||
const installOption = resolvePreferredInstallOption(skill);
|
||||
const canDeleteSkill = canRemoveSkill(skill);
|
||||
const busyForSkill = skillsBusyKey === skill.skillKey;
|
||||
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
|
||||
const trimmedApiKey = apiKeyDraft.trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Setup ${skill.name}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="ui-panel w-full max-w-2xl bg-card shadow-xs"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-6 py-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80">
|
||||
System setup
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-semibold text-foreground">{skill.name}</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
|
||||
>
|
||||
{readinessLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-muted-foreground/80">
|
||||
Changes affect all agents on this gateway.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-6 pb-3 text-[11px] text-muted-foreground">
|
||||
{defaultAgentScopeWarning ? (
|
||||
<div className="rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/80">
|
||||
{defaultAgentScopeWarning}
|
||||
</div>
|
||||
) : null}
|
||||
<div>{skill.description}</div>
|
||||
{skill.blockedByAllowlist ? (
|
||||
<div className="text-[10px] text-muted-foreground/80">
|
||||
Blocked by bundled skills policy (`skills.allowBundled`).
|
||||
</div>
|
||||
) : null}
|
||||
{missingDetails.map((line) => (
|
||||
<div key={`${skill.skillKey}:${line}`} className="text-[10px] text-muted-foreground/80">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
{skillMessage ? (
|
||||
<div
|
||||
className={`text-[10px] ${skillMessage.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
|
||||
>
|
||||
{skillMessage.message}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
|
||||
{installOption ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
void onInstallSkill(skill.skillKey, skill.name, installOption.id);
|
||||
}}
|
||||
>
|
||||
{busyForSkill ? "Working..." : installOption.label}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
void onSetSkillGlobalEnabled(skill.skillKey, skill.disabled);
|
||||
}}
|
||||
>
|
||||
{busyForSkill
|
||||
? "Working..."
|
||||
: skill.disabled
|
||||
? "Enable globally"
|
||||
: "Disable globally"}
|
||||
</button>
|
||||
{skill.primaryEnv ? (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeyDraft}
|
||||
onChange={(event) => {
|
||||
void onSkillApiKeyChange(skill.skillKey, event.target.value);
|
||||
}}
|
||||
disabled={anySkillBusy}
|
||||
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[10px] text-foreground outline-none transition focus:border-border"
|
||||
placeholder={`Set ${skill.primaryEnv}`}
|
||||
aria-label={`API key for ${skill.name}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy || trimmedApiKey.length === 0}
|
||||
onClick={() => {
|
||||
if (trimmedApiKey.length === 0) {
|
||||
return;
|
||||
}
|
||||
void onSaveSkillApiKey(skill.skillKey);
|
||||
}}
|
||||
>
|
||||
{busyForSkill ? "Working..." : `Save ${skill.primaryEnv}`}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{canDeleteSkill ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary ui-btn-danger w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
const approved = window.confirm(
|
||||
`Remove ${skill.name} from the gateway? This affects all agents.`
|
||||
);
|
||||
if (!approved) {
|
||||
return;
|
||||
}
|
||||
void onRemoveSkill({
|
||||
skillKey: skill.skillKey,
|
||||
source: skill.source,
|
||||
baseDir: skill.baseDir,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Remove skill from gateway
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentSkillsSetupModal } from "@/features/agents/components/AgentSkillsSetupModal";
|
||||
import {
|
||||
buildSkillMissingDetails,
|
||||
deriveSkillReadinessState,
|
||||
type SkillReadinessState,
|
||||
} from "@/lib/skills/presentation";
|
||||
import type { SkillStatusReport } from "@/lib/skills/types";
|
||||
|
||||
type SkillSetupMessage = { kind: "success" | "error"; message: string };
|
||||
|
||||
type ReadinessFilter = "all" | SkillReadinessState;
|
||||
|
||||
type SystemSkillsPanelProps = {
|
||||
skillsReport?: SkillStatusReport | null;
|
||||
skillsLoading?: boolean;
|
||||
skillsError?: string | null;
|
||||
skillsBusy?: boolean;
|
||||
skillsBusyKey?: string | null;
|
||||
skillMessages?: Record<string, SkillSetupMessage>;
|
||||
skillApiKeyDrafts?: Record<string, string>;
|
||||
defaultAgentScopeWarning?: string | null;
|
||||
initialSkillKey?: string | null;
|
||||
onInitialSkillKeyHandled?: () => void;
|
||||
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
|
||||
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
|
||||
onRemoveSkill: (
|
||||
skill: { skillKey: string; source: string; baseDir: string }
|
||||
) => Promise<void> | void;
|
||||
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
|
||||
onSaveSkillApiKey: (skillKey: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const READINESS_FILTERS: Array<{ id: ReadinessFilter; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "ready", label: "Ready" },
|
||||
{ id: "needs-setup", label: "Needs setup" },
|
||||
{ id: "unavailable", label: "Unavailable" },
|
||||
{ id: "disabled-globally", label: "Disabled globally" },
|
||||
];
|
||||
|
||||
const READINESS_LABELS = {
|
||||
ready: "Ready",
|
||||
"needs-setup": "Needs setup",
|
||||
unavailable: "Unavailable",
|
||||
"disabled-globally": "Disabled globally",
|
||||
} as const;
|
||||
|
||||
const READINESS_CLASSES = {
|
||||
ready: "ui-badge-status-running",
|
||||
"needs-setup": "ui-badge-status-error",
|
||||
unavailable: "ui-badge-status-error",
|
||||
"disabled-globally": "ui-badge-status-error",
|
||||
} as const;
|
||||
|
||||
const resolveReadinessHint = (
|
||||
skill: SkillStatusReport["skills"][number],
|
||||
readiness: SkillReadinessState
|
||||
): string | null => {
|
||||
if (readiness === "ready") {
|
||||
return null;
|
||||
}
|
||||
if (readiness === "disabled-globally") {
|
||||
return "Disabled globally for all agents.";
|
||||
}
|
||||
if (readiness === "unavailable") {
|
||||
if (skill.blockedByAllowlist) {
|
||||
return "Blocked by bundled skills policy.";
|
||||
}
|
||||
return buildSkillMissingDetails(skill)[0] ?? "Unavailable on this system.";
|
||||
}
|
||||
return buildSkillMissingDetails(skill)[0] ?? "Requires setup.";
|
||||
};
|
||||
|
||||
export const SystemSkillsPanel = ({
|
||||
skillsReport = null,
|
||||
skillsLoading = false,
|
||||
skillsError = null,
|
||||
skillsBusy = false,
|
||||
skillsBusyKey = null,
|
||||
skillMessages = {},
|
||||
skillApiKeyDrafts = {},
|
||||
defaultAgentScopeWarning = null,
|
||||
initialSkillKey = null,
|
||||
onInitialSkillKeyHandled,
|
||||
onSetSkillGlobalEnabled,
|
||||
onInstallSkill,
|
||||
onRemoveSkill,
|
||||
onSkillApiKeyChange,
|
||||
onSaveSkillApiKey,
|
||||
}: SystemSkillsPanelProps) => {
|
||||
const [skillsFilter, setSkillsFilter] = useState("");
|
||||
const [readinessFilter, setReadinessFilter] = useState<ReadinessFilter>("all");
|
||||
const [setupSkillKey, setSetupSkillKey] = useState<string | null>(null);
|
||||
|
||||
const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]);
|
||||
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
|
||||
const requestedInitialSkillKey = useMemo(() => {
|
||||
const candidate = initialSkillKey?.trim() ?? "";
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
return skillEntries.some((entry) => entry.skillKey === candidate) ? candidate : null;
|
||||
}, [initialSkillKey, skillEntries]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
skillEntries.map((skill) => ({
|
||||
skill,
|
||||
readiness: deriveSkillReadinessState(skill),
|
||||
})),
|
||||
[skillEntries]
|
||||
);
|
||||
|
||||
const searchedRows = useMemo(() => {
|
||||
const query = skillsFilter.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((entry) =>
|
||||
[entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}, [rows, skillsFilter]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (readinessFilter === "all") {
|
||||
return searchedRows;
|
||||
}
|
||||
return searchedRows.filter((entry) => entry.readiness === readinessFilter);
|
||||
}, [readinessFilter, searchedRows]);
|
||||
|
||||
const readinessCounts = useMemo(
|
||||
() =>
|
||||
searchedRows.reduce(
|
||||
(counts, entry) => {
|
||||
counts.all += 1;
|
||||
counts[entry.readiness] += 1;
|
||||
return counts;
|
||||
},
|
||||
{
|
||||
all: 0,
|
||||
ready: 0,
|
||||
"needs-setup": 0,
|
||||
unavailable: 0,
|
||||
"disabled-globally": 0,
|
||||
} satisfies Record<ReadinessFilter, number>
|
||||
),
|
||||
[searchedRows]
|
||||
);
|
||||
|
||||
const setupQueue = useMemo(
|
||||
() =>
|
||||
rows.filter(
|
||||
(entry) => entry.readiness === "needs-setup" || entry.readiness === "disabled-globally"
|
||||
),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const selectedSkillKey = setupSkillKey ?? requestedInitialSkillKey;
|
||||
const selectedSetupSkill = selectedSkillKey
|
||||
? skillEntries.find((entry) => entry.skillKey === selectedSkillKey) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="sidebar-section" data-testid="agent-settings-system-skills">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="sidebar-section-title">System skill setup</h3>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{skillEntries.length}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
Changes here affect all agents on this gateway.
|
||||
</div>
|
||||
{defaultAgentScopeWarning ? (
|
||||
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/82">
|
||||
{defaultAgentScopeWarning}
|
||||
</div>
|
||||
) : null}
|
||||
{setupQueue.length > 0 ? (
|
||||
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
|
||||
<div className="text-[10px] font-semibold text-foreground/85">Needs setup ({setupQueue.length})</div>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{setupQueue.slice(0, 5).map((entry) => (
|
||||
<div
|
||||
key={`setup-queue:${entry.skill.skillKey}`}
|
||||
className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground/85"
|
||||
>
|
||||
<span className="truncate">{entry.skill.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
onInitialSkillKeyHandled?.();
|
||||
setSetupSkillKey(entry.skill.skillKey);
|
||||
}}
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
value={skillsFilter}
|
||||
onChange={(event) => setSkillsFilter(event.target.value)}
|
||||
placeholder="Search skills"
|
||||
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
|
||||
aria-label="Search skills"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{READINESS_FILTERS.map((filter) => {
|
||||
const selected = readinessFilter === filter.id;
|
||||
return (
|
||||
<button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
|
||||
data-active={selected ? "true" : "false"}
|
||||
disabled={skillsLoading}
|
||||
onClick={() => {
|
||||
setReadinessFilter(filter.id);
|
||||
}}
|
||||
>
|
||||
{filter.label} ({readinessCounts[filter.id]})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
|
||||
{!skillsLoading && skillsError ? (
|
||||
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{filteredRows.map((entry) => {
|
||||
const readinessLabel = READINESS_LABELS[entry.readiness];
|
||||
const readinessClassName = READINESS_CLASSES[entry.readiness];
|
||||
const message = skillMessages[entry.skill.skillKey] ?? null;
|
||||
return (
|
||||
<div
|
||||
key={`${entry.skill.source}:${entry.skill.skillKey}`}
|
||||
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
|
||||
{entry.skill.source}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
|
||||
>
|
||||
{readinessLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
|
||||
{entry.readiness !== "ready" ? (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/80">
|
||||
{resolveReadinessHint(entry.skill, entry.readiness)}
|
||||
</div>
|
||||
) : null}
|
||||
{message ? (
|
||||
<div
|
||||
className={`mt-1 text-[10px] ${message.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
|
||||
>
|
||||
{message.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-2 sm:w-[210px]">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
onInitialSkillKeyHandled?.();
|
||||
setSetupSkillKey(entry.skill.skillKey);
|
||||
}}
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<AgentSkillsSetupModal
|
||||
skill={selectedSetupSkill}
|
||||
skillsBusy={skillsBusy}
|
||||
skillsBusyKey={skillsBusyKey}
|
||||
skillMessage={selectedSetupSkill ? skillMessages[selectedSetupSkill.skillKey] ?? null : null}
|
||||
apiKeyDraft={selectedSetupSkill ? skillApiKeyDrafts[selectedSetupSkill.skillKey] ?? "" : ""}
|
||||
defaultAgentScopeWarning={defaultAgentScopeWarning}
|
||||
onClose={() => {
|
||||
onInitialSkillKeyHandled?.();
|
||||
setSetupSkillKey(null);
|
||||
}}
|
||||
onInstallSkill={onInstallSkill}
|
||||
onSetSkillGlobalEnabled={onSetSkillGlobalEnabled}
|
||||
onRemoveSkill={onRemoveSkill}
|
||||
onSkillApiKeyChange={onSkillApiKeyChange}
|
||||
onSaveSkillApiKey={onSaveSkillApiKey}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,9 @@ type GuardedActionKind =
|
||||
| "update-agent-permissions"
|
||||
| "use-all-skills"
|
||||
| "disable-all-skills"
|
||||
| "set-skills-allowlist"
|
||||
| "set-skill-enabled"
|
||||
| "set-skill-global-enabled"
|
||||
| "install-skill"
|
||||
| "remove-skill"
|
||||
| "save-skill-api-key";
|
||||
@@ -64,7 +66,9 @@ const isGuardedAction = (
|
||||
kind === "update-agent-permissions" ||
|
||||
kind === "use-all-skills" ||
|
||||
kind === "disable-all-skills" ||
|
||||
kind === "set-skills-allowlist" ||
|
||||
kind === "set-skill-enabled" ||
|
||||
kind === "set-skill-global-enabled" ||
|
||||
kind === "install-skill" ||
|
||||
kind === "remove-skill" ||
|
||||
kind === "save-skill-api-key";
|
||||
@@ -149,6 +153,7 @@ export const planAgentSettingsMutation = (
|
||||
}
|
||||
|
||||
if (
|
||||
request.kind === "set-skill-global-enabled" ||
|
||||
request.kind === "install-skill" ||
|
||||
request.kind === "remove-skill" ||
|
||||
request.kind === "save-skill-api-key"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type AgentPermissionsDraft,
|
||||
resolvePresetDefaultsForRole,
|
||||
updateAgentPermissionsViaStudio,
|
||||
} from "@/features/agents/operations/agentPermissionsOperation";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
@@ -18,6 +19,9 @@ type CreatedAgent = {
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
export const CREATE_AGENT_DEFAULT_PERMISSIONS: Readonly<AgentPermissionsDraft> =
|
||||
Object.freeze(resolvePresetDefaultsForRole("autonomous"));
|
||||
|
||||
const resolveBootstrapErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return error.message || "Failed to apply default permissions.";
|
||||
|
||||
@@ -2,6 +2,7 @@ export type SettingsRouteTab =
|
||||
| "personality"
|
||||
| "capabilities"
|
||||
| "skills"
|
||||
| "system"
|
||||
| "automations"
|
||||
| "advanced";
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type MutationBlockState,
|
||||
type MutationWorkflowKind,
|
||||
} from "@/features/agents/operations/mutationLifecycleWorkflow";
|
||||
import type { SettingsRouteTab } from "@/features/agents/operations/settingsRouteWorkflow";
|
||||
import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue";
|
||||
import { useGatewayRestartBlock } from "@/features/agents/operations/useGatewayRestartBlock";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
updateGatewayAgentSkillsAllowlist,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import { fetchJson } from "@/lib/http";
|
||||
import { canRemoveSkillSource } from "@/lib/skills/presentation";
|
||||
import { canRemoveSkillSource, filterOsCompatibleSkills } from "@/lib/skills/presentation";
|
||||
import { removeSkillFromGateway } from "@/lib/skills/remove";
|
||||
import {
|
||||
installSkill,
|
||||
@@ -66,7 +67,7 @@ export type UseAgentSettingsMutationControllerParams = {
|
||||
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
|
||||
settingsRouteActive: boolean;
|
||||
inspectSidebarAgentId: string | null;
|
||||
inspectSidebarTab: string | null;
|
||||
inspectSidebarTab: SettingsRouteTab | null;
|
||||
loadAgents: () => Promise<void>;
|
||||
refreshGatewayConfigSnapshot: () => Promise<GatewayModelPolicySnapshot | null>;
|
||||
clearInspectSidebar: () => void;
|
||||
@@ -181,11 +182,13 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const skillsTabActive =
|
||||
params.inspectSidebarTab === "skills" || params.inspectSidebarTab === "system";
|
||||
if (
|
||||
!params.settingsRouteActive ||
|
||||
!params.inspectSidebarAgentId ||
|
||||
params.status !== "connected" ||
|
||||
params.inspectSidebarTab !== "skills"
|
||||
!skillsTabActive
|
||||
) {
|
||||
skillsLoadRequestIdRef.current += 1;
|
||||
setSettingsSkillsReport(null);
|
||||
@@ -635,9 +638,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
|
||||
const reloadSkillsIfVisible = useCallback(
|
||||
async (agentId: string) => {
|
||||
const skillsTabActive =
|
||||
params.inspectSidebarTab === "skills" || params.inspectSidebarTab === "system";
|
||||
if (
|
||||
params.settingsRouteActive &&
|
||||
params.inspectSidebarTab === "skills" &&
|
||||
skillsTabActive &&
|
||||
params.inspectSidebarAgentId === agentId &&
|
||||
params.status === "connected"
|
||||
) {
|
||||
@@ -656,7 +661,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
const runSkillsMutation = useCallback(
|
||||
async (input: {
|
||||
agentId: string;
|
||||
decisionKind: "use-all-skills" | "disable-all-skills" | "set-skill-enabled";
|
||||
decisionKind:
|
||||
| "use-all-skills"
|
||||
| "disable-all-skills"
|
||||
| "set-skills-allowlist"
|
||||
| "set-skill-enabled";
|
||||
skillName?: string;
|
||||
run: (normalizedAgentId: string) => Promise<void>;
|
||||
}) => {
|
||||
@@ -747,7 +756,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
const resolvedSkillName = skillName.trim();
|
||||
const visibleSkillNames = Array.from(
|
||||
new Set(
|
||||
(settingsSkillsReport?.skills ?? [])
|
||||
filterOsCompatibleSkills(settingsSkillsReport?.skills ?? [])
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter((name) => name.length > 0)
|
||||
)
|
||||
@@ -780,6 +789,34 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
[params.client, runSkillsMutation, settingsSkillsReport]
|
||||
);
|
||||
|
||||
const handleSetSkillsAllowlist = useCallback(
|
||||
async (agentId: string, skillNames: string[]) => {
|
||||
await runSkillsMutation({
|
||||
agentId,
|
||||
decisionKind: "set-skills-allowlist",
|
||||
run: async (normalizedAgentId) => {
|
||||
const normalizedSkillNames = Array.from(
|
||||
new Set(
|
||||
skillNames
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0)
|
||||
)
|
||||
);
|
||||
if (normalizedSkillNames.length === 0) {
|
||||
throw new Error("Cannot set selected skills mode: choose at least one skill.");
|
||||
}
|
||||
await updateGatewayAgentSkillsAllowlist({
|
||||
client: params.client,
|
||||
agentId: normalizedAgentId,
|
||||
mode: "allowlist",
|
||||
skillNames: normalizedSkillNames,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[params.client, runSkillsMutation]
|
||||
);
|
||||
|
||||
const handleSkillApiKeyDraftChange = useCallback((skillKey: string, value: string) => {
|
||||
const normalizedSkillKey = skillKey.trim();
|
||||
if (!normalizedSkillKey) {
|
||||
@@ -794,7 +831,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
const runSkillSetupMutation = useCallback(
|
||||
async (input: {
|
||||
agentId: string;
|
||||
decisionKind: "install-skill" | "remove-skill" | "save-skill-api-key";
|
||||
decisionKind:
|
||||
| "install-skill"
|
||||
| "remove-skill"
|
||||
| "save-skill-api-key"
|
||||
| "set-skill-global-enabled";
|
||||
skillKey: string;
|
||||
label: string;
|
||||
run: () => Promise<{ successMessage: string }>;
|
||||
@@ -963,6 +1004,29 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
[params.client, runSkillSetupMutation, setSkillMessage, settingsSkillApiKeyDrafts]
|
||||
);
|
||||
|
||||
const handleSetSkillGlobalEnabled = useCallback(
|
||||
async (agentId: string, skillKey: string, enabled: boolean) => {
|
||||
const normalizedSkillKey = skillKey.trim();
|
||||
await runSkillSetupMutation({
|
||||
agentId,
|
||||
decisionKind: "set-skill-global-enabled",
|
||||
skillKey: normalizedSkillKey,
|
||||
label: `${enabled ? "Enable" : "Disable"} ${normalizedSkillKey}`,
|
||||
refreshConfigSnapshot: true,
|
||||
run: async () => {
|
||||
await updateSkill(params.client, {
|
||||
skillKey: normalizedSkillKey,
|
||||
enabled,
|
||||
});
|
||||
return {
|
||||
successMessage: enabled ? "Skill enabled globally" : "Skill disabled globally",
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
[params.client, runSkillSetupMutation]
|
||||
);
|
||||
|
||||
return {
|
||||
settingsSkillsReport,
|
||||
settingsSkillsLoading,
|
||||
@@ -989,10 +1053,12 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
handleUpdateAgentPermissions,
|
||||
handleUseAllSkills,
|
||||
handleDisableAllSkills,
|
||||
handleSetSkillsAllowlist,
|
||||
handleSetSkillEnabled,
|
||||
handleInstallSkill,
|
||||
handleRemoveSkill,
|
||||
handleSkillApiKeyDraftChange,
|
||||
handleSaveSkillApiKey,
|
||||
handleSetSkillGlobalEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@ export type SkillSourceGroup = {
|
||||
skills: SkillStatusEntry[];
|
||||
};
|
||||
|
||||
export type SkillReadinessState =
|
||||
| "ready"
|
||||
| "needs-setup"
|
||||
| "unavailable"
|
||||
| "disabled-globally";
|
||||
|
||||
export type AgentSkillDisplayState = "ready" | "setup-required" | "not-supported";
|
||||
|
||||
export type AgentSkillsAccessMode = "all" | "none" | "selected";
|
||||
|
||||
const GROUP_DEFINITIONS: Array<{ id: Exclude<SkillSourceGroupId, "other">; label: string }> = [
|
||||
{ id: "workspace", label: "Workspace Skills" },
|
||||
{ id: "built-in", label: "Built-in Skills" },
|
||||
@@ -30,6 +40,18 @@ const trimNonEmpty = (value: string): string | null => {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const OS_LABELS: Record<string, string> = {
|
||||
darwin: "macOS",
|
||||
linux: "Linux",
|
||||
win32: "Windows",
|
||||
windows: "Windows",
|
||||
};
|
||||
|
||||
const toOsLabel = (value: string): string => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return OS_LABELS[normalized] ?? value.trim();
|
||||
};
|
||||
|
||||
const normalizeStringList = (values: string[] | undefined): string[] => {
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
@@ -44,6 +66,23 @@ const normalizeStringList = (values: string[] | undefined): string[] => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const normalizeAgentSkillsAllowlist = (values: string[] | undefined): string[] => {
|
||||
const normalized = normalizeStringList(values);
|
||||
return Array.from(new Set(normalized));
|
||||
};
|
||||
|
||||
export const deriveAgentSkillsAccessMode = (
|
||||
values: string[] | undefined
|
||||
): AgentSkillsAccessMode => {
|
||||
if (!Array.isArray(values)) {
|
||||
return "all";
|
||||
}
|
||||
return normalizeAgentSkillsAllowlist(values).length === 0 ? "none" : "selected";
|
||||
};
|
||||
|
||||
export const buildAgentSkillsAllowlistSet = (values: string[] | undefined): Set<string> =>
|
||||
new Set(normalizeAgentSkillsAllowlist(values));
|
||||
|
||||
const resolveGroupId = (skill: SkillStatusEntry): SkillSourceGroupId => {
|
||||
const source = trimNonEmpty(skill.source) ?? "";
|
||||
const bundled = skill.bundled || source === "openclaw-bundled";
|
||||
@@ -116,7 +155,7 @@ export const buildSkillMissingDetails = (skill: SkillStatusEntry): string[] => {
|
||||
|
||||
const os = normalizeStringList(skill.missing.os);
|
||||
if (os.length > 0) {
|
||||
details.push(`Unsupported OS: ${os.join(", ")}`);
|
||||
details.push(`Requires OS: ${os.map((value) => toOsLabel(value)).join(", ")}`);
|
||||
}
|
||||
|
||||
return details;
|
||||
@@ -148,6 +187,39 @@ export const buildSkillReasons = (skill: SkillStatusEntry): string[] => {
|
||||
return reasons;
|
||||
};
|
||||
|
||||
export const isSkillOsIncompatible = (skill: SkillStatusEntry): boolean => {
|
||||
return normalizeStringList(skill.missing.os).length > 0;
|
||||
};
|
||||
|
||||
export const filterOsCompatibleSkills = (skills: SkillStatusEntry[]): SkillStatusEntry[] => {
|
||||
return skills.filter((skill) => !isSkillOsIncompatible(skill));
|
||||
};
|
||||
|
||||
export const deriveSkillReadinessState = (skill: SkillStatusEntry): SkillReadinessState => {
|
||||
if (skill.disabled) {
|
||||
return "disabled-globally";
|
||||
}
|
||||
if (isSkillOsIncompatible(skill) || skill.blockedByAllowlist) {
|
||||
return "unavailable";
|
||||
}
|
||||
if (skill.eligible) {
|
||||
return "ready";
|
||||
}
|
||||
return "needs-setup";
|
||||
};
|
||||
|
||||
export const deriveAgentSkillDisplayState = (
|
||||
readiness: SkillReadinessState
|
||||
): AgentSkillDisplayState => {
|
||||
if (readiness === "ready") {
|
||||
return "ready";
|
||||
}
|
||||
if (readiness === "unavailable") {
|
||||
return "not-supported";
|
||||
}
|
||||
return "setup-required";
|
||||
};
|
||||
|
||||
export const isBundledBlockedSkill = (skill: SkillStatusEntry): boolean => {
|
||||
const source = trimNonEmpty(skill.source) ?? "";
|
||||
return (skill.bundled || source === "openclaw-bundled") && !skill.eligible;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isPermissionsCustom,
|
||||
resolveAgentPermissionsDraft,
|
||||
resolveCommandModeFromRole,
|
||||
resolvePresetDefaultsForRole,
|
||||
resolveRoleForCommandMode,
|
||||
resolveToolGroupOverrides,
|
||||
resolveToolGroupStateFromConfigEntry,
|
||||
@@ -20,6 +21,14 @@ describe("agentPermissionsOperation", () => {
|
||||
expect(resolveCommandModeFromRole("autonomous")).toBe("auto");
|
||||
});
|
||||
|
||||
it("resolves autonomous preset defaults to permissive capabilities", () => {
|
||||
expect(resolvePresetDefaultsForRole("autonomous")).toEqual({
|
||||
commandMode: "auto",
|
||||
webAccess: true,
|
||||
fileTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives tool-group state from allow and deny with deny precedence", () => {
|
||||
const state = resolveToolGroupStateFromConfigEntry({
|
||||
allow: ["group:web", "group:runtime"],
|
||||
|
||||
@@ -32,6 +32,14 @@ describe("agentSettingsMutationWorkflow", () => {
|
||||
{ kind: "install-skill", agentId: "agent-1", skillKey: "browser" },
|
||||
createContext({ status: "disconnected" })
|
||||
);
|
||||
const allowlistResult = planAgentSettingsMutation(
|
||||
{ kind: "set-skills-allowlist", agentId: "agent-1" },
|
||||
createContext({ status: "disconnected" })
|
||||
);
|
||||
const globalToggleResult = planAgentSettingsMutation(
|
||||
{ kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: "browser" },
|
||||
createContext({ status: "disconnected" })
|
||||
);
|
||||
const removeResult = planAgentSettingsMutation(
|
||||
{ kind: "remove-skill", agentId: "agent-1", skillKey: "browser" },
|
||||
createContext({ status: "disconnected" })
|
||||
@@ -55,6 +63,18 @@ describe("agentSettingsMutationWorkflow", () => {
|
||||
message: null,
|
||||
guardReason: "not-connected",
|
||||
});
|
||||
expect(allowlistResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: "not-connected",
|
||||
});
|
||||
expect(globalToggleResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: "not-connected",
|
||||
});
|
||||
expect(removeResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
@@ -146,6 +166,10 @@ describe("agentSettingsMutationWorkflow", () => {
|
||||
{ kind: "save-skill-api-key", agentId: "agent-1", skillKey: " " },
|
||||
createContext()
|
||||
);
|
||||
const globalToggleResult = planAgentSettingsMutation(
|
||||
{ kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: " " },
|
||||
createContext()
|
||||
);
|
||||
const removeResult = planAgentSettingsMutation(
|
||||
{ kind: "remove-skill", agentId: "agent-1", skillKey: " " },
|
||||
createContext()
|
||||
@@ -161,10 +185,27 @@ describe("agentSettingsMutationWorkflow", () => {
|
||||
reason: "missing-skill-key",
|
||||
message: null,
|
||||
});
|
||||
expect(globalToggleResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-skill-key",
|
||||
message: null,
|
||||
});
|
||||
expect(removeResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-skill-key",
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows_setting_skills_allowlist_with_normalized_agent_id", () => {
|
||||
const result = planAgentSettingsMutation(
|
||||
{ kind: "set-skills-allowlist", agentId: " agent-1 " },
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "allow",
|
||||
normalizedAgentId: "agent-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from "react";
|
||||
import { createElement, useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentSettingsPanel } from "@/features/agents/components/AgentInspectPanels";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
@@ -103,6 +103,22 @@ const createSkillsReport = (): SkillStatusReport => ({
|
||||
],
|
||||
});
|
||||
|
||||
const createSkillsReportWithOsIncompatibleBrowser = (): SkillStatusReport => {
|
||||
const report = createSkillsReport();
|
||||
return {
|
||||
...report,
|
||||
skills: report.skills.map((entry) =>
|
||||
entry.skillKey === "browser"
|
||||
? {
|
||||
...entry,
|
||||
requirements: { ...entry.requirements, os: ["darwin"] },
|
||||
missing: { ...entry.missing, os: ["darwin"] },
|
||||
}
|
||||
: entry
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
describe("AgentSettingsPanel", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -371,9 +387,8 @@ describe("AgentSettingsPanel", () => {
|
||||
expect(screen.queryByRole("button", { name: "New session" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders_skills_mode_and_runs_bulk_actions", () => {
|
||||
const onUseAllSkills = vi.fn();
|
||||
const onDisableAllSkills = vi.fn();
|
||||
it("renders_skills_mode_and_opens_system_setup_for_non_ready_skills", () => {
|
||||
const onOpenSystemSetup = vi.fn();
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
@@ -390,19 +405,17 @@ describe("AgentSettingsPanel", () => {
|
||||
onRunCronJob: vi.fn(),
|
||||
onDeleteCronJob: vi.fn(),
|
||||
skillsReport: createSkillsReport(),
|
||||
onUseAllSkills,
|
||||
onDisableAllSkills,
|
||||
skillsAllowlist: ["github"],
|
||||
onOpenSystemSetup,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-settings-skills")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use all" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Disable all" }));
|
||||
expect(onUseAllSkills).toHaveBeenCalledTimes(1);
|
||||
expect(onDisableAllSkills).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open System Setup" }));
|
||||
expect(onOpenSystemSetup).toHaveBeenCalledWith("browser");
|
||||
});
|
||||
|
||||
it("hides_bundled_blocked_skills_by_default_and_can_show_them", () => {
|
||||
it("shows_selected_mode_hint_when_allowlist_mode_is_active", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
@@ -419,42 +432,11 @@ describe("AgentSettingsPanel", () => {
|
||||
onRunCronJob: vi.fn(),
|
||||
onDeleteCronJob: vi.fn(),
|
||||
skillsReport: createSkillsReport(),
|
||||
skillsAllowlist: ["github"],
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.queryByText("browser")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Hide bundled + blocked"));
|
||||
fireEvent.click(screen.getByText("Built-in Skills"));
|
||||
|
||||
expect(screen.getByText("browser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders_explicit_missing_and_reason_lines_for_blocked_skills", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "skills",
|
||||
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(),
|
||||
skillsReport: createSkillsReport(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Hide bundled + blocked"));
|
||||
fireEvent.click(screen.getByText("Built-in Skills"));
|
||||
|
||||
expect(screen.getByText("Missing tools: playwright")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reason: disabled, blocked by allowlist, missing tools")).toBeInTheDocument();
|
||||
expect(screen.getByText("This agent is using selected skills only.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters_skills_list_from_search_input", () => {
|
||||
@@ -477,11 +459,9 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Hide bundled + blocked"));
|
||||
fireEvent.change(screen.getByLabelText("Search skills"), {
|
||||
target: { value: "browse" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Built-in Skills"));
|
||||
|
||||
expect(screen.getByText("browser")).toBeInTheDocument();
|
||||
expect(screen.queryByText("github")).not.toBeInTheDocument();
|
||||
@@ -510,9 +490,6 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Hide bundled + blocked"));
|
||||
fireEvent.click(screen.getByText("Workspace Skills"));
|
||||
fireEvent.click(screen.getByText("Built-in Skills"));
|
||||
const githubToggle = screen.getByRole("switch", { name: "Skill github" });
|
||||
const browserToggle = screen.getByRole("switch", { name: "Skill browser" });
|
||||
expect(githubToggle).toHaveAttribute("aria-checked", "true");
|
||||
@@ -524,14 +501,15 @@ describe("AgentSettingsPanel", () => {
|
||||
expect(onSetSkillEnabled).toHaveBeenNthCalledWith(2, "browser", true);
|
||||
});
|
||||
|
||||
it("runs_install_and_api_key_actions", () => {
|
||||
it("runs_system_setup_actions_from_modal", () => {
|
||||
const onInstallSkill = vi.fn();
|
||||
const onSetSkillGlobalEnabled = vi.fn();
|
||||
const onSkillApiKeyChange = vi.fn();
|
||||
const onSaveSkillApiKey = vi.fn();
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "skills",
|
||||
mode: "system",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
@@ -546,30 +524,77 @@ describe("AgentSettingsPanel", () => {
|
||||
skillsReport: createSkillsReport(),
|
||||
skillApiKeyDrafts: { browser: "seed-key" },
|
||||
onInstallSkill,
|
||||
onSetSkillGlobalEnabled,
|
||||
onSkillApiKeyChange,
|
||||
onSaveSkillApiKey,
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Hide bundled + blocked"));
|
||||
fireEvent.click(screen.getByText("Built-in Skills"));
|
||||
fireEvent.change(screen.getByLabelText("Search skills"), {
|
||||
target: { value: "browse" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Configure" }));
|
||||
expect(screen.getByRole("dialog", { name: "Setup browser" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install playwright" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enable globally" }));
|
||||
fireEvent.change(screen.getByLabelText("API key for browser"), {
|
||||
target: { value: "test-key" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save BROWSER_API_KEY" }));
|
||||
|
||||
expect(onInstallSkill).toHaveBeenCalledWith("browser", "browser", "install-playwright");
|
||||
expect(onSetSkillGlobalEnabled).toHaveBeenCalledWith("browser", true);
|
||||
expect(onSkillApiKeyChange).toHaveBeenCalledWith("browser", "test-key");
|
||||
expect(onSaveSkillApiKey).toHaveBeenCalledWith("browser");
|
||||
});
|
||||
|
||||
it("keeps_system_setup_modal_open_until_user_closes_and_then_clears_handoff", async () => {
|
||||
const onSystemInitialSkillHandled = vi.fn();
|
||||
const Harness = () => {
|
||||
const [initialSkillKey, setInitialSkillKey] = useState<string | null>("browser");
|
||||
return createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "system",
|
||||
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(),
|
||||
skillsReport: createSkillsReport(),
|
||||
systemInitialSkillKey: initialSkillKey,
|
||||
onSystemInitialSkillHandled: () => {
|
||||
onSystemInitialSkillHandled();
|
||||
setInitialSkillKey(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
render(createElement(Harness));
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "Setup browser" });
|
||||
expect(onSystemInitialSkillHandled).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Close" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSystemInitialSkillHandled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.queryByRole("dialog", { name: "Setup browser" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prompts_before_removing_skill_files_and_confirms_action", () => {
|
||||
const onRemoveSkill = vi.fn();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "skills",
|
||||
mode: "system",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
@@ -586,11 +611,11 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Workspace Skills"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove skill github" }));
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Remove skill github" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove skill" }));
|
||||
fireEvent.change(screen.getByLabelText("Search skills"), {
|
||||
target: { value: "git" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Configure" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove skill from gateway" }));
|
||||
|
||||
expect(onRemoveSkill).toHaveBeenCalledWith({
|
||||
skillKey: "github",
|
||||
@@ -603,7 +628,7 @@ describe("AgentSettingsPanel", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "skills",
|
||||
mode: "system",
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
@@ -619,8 +644,10 @@ describe("AgentSettingsPanel", () => {
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Hide bundled + blocked"));
|
||||
fireEvent.click(screen.getByText("Built-in Skills"));
|
||||
fireEvent.change(screen.getByLabelText("Search skills"), {
|
||||
target: { value: "browse" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Configure" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Save BROWSER_API_KEY" })).toBeDisabled();
|
||||
});
|
||||
@@ -649,6 +676,40 @@ describe("AgentSettingsPanel", () => {
|
||||
expect(screen.getByText("1/2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows_os_incompatible_skills_for_visibility_in_agent_view", () => {
|
||||
const report = createSkillsReportWithOsIncompatibleBrowser();
|
||||
report.skills = report.skills.map((entry) =>
|
||||
entry.skillKey === "browser"
|
||||
? { ...entry, disabled: false, blockedByAllowlist: false }
|
||||
: entry
|
||||
);
|
||||
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
mode: "skills",
|
||||
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(),
|
||||
skillsReport: report,
|
||||
skillsAllowlist: ["github", "browser"],
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByRole("switch", { name: "Skill browser" })).toBeDisabled();
|
||||
expect(screen.getByText("2/2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Not supported")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Open System Setup" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows_skills_loading_and_error_states", () => {
|
||||
const { rerender } = render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runCreateAgentBootstrapOperation } from "@/features/agents/operations/createAgentBootstrapOperation";
|
||||
import {
|
||||
CREATE_AGENT_DEFAULT_PERMISSIONS,
|
||||
runCreateAgentBootstrapOperation,
|
||||
} from "@/features/agents/operations/createAgentBootstrapOperation";
|
||||
|
||||
describe("createAgentBootstrapOperation", () => {
|
||||
it("exports_autonomous_create_defaults", () => {
|
||||
expect(CREATE_AGENT_DEFAULT_PERMISSIONS).toEqual({
|
||||
commandMode: "auto",
|
||||
webAccess: true,
|
||||
fileTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries load and lookup once before unresolved-created-agent disposition", async () => {
|
||||
const loadAgents = vi.fn(async () => undefined);
|
||||
const findAgentById = vi.fn(() => null);
|
||||
|
||||
@@ -120,6 +120,24 @@ describe("settingsRouteWorkflow", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("changes from skills to system without discard confirmation", () => {
|
||||
expect(
|
||||
planSettingsTabChangeCommands({
|
||||
nextTab: "system",
|
||||
currentInspectSidebar: { agentId: "agent-1", tab: "skills" },
|
||||
settingsRouteAgentId: "agent-1",
|
||||
settingsRouteActive: true,
|
||||
personalityHasUnsavedChanges: true,
|
||||
discardConfirmed: false,
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
kind: "set-inspect-sidebar",
|
||||
value: { agentId: "agent-1", tab: "system" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("plans route-agent synchronization commands", () => {
|
||||
expect(
|
||||
planSettingsRouteSyncCommands({
|
||||
|
||||
@@ -93,6 +93,27 @@ describe("skills gateway client", () => {
|
||||
expect(result).toBe(response);
|
||||
});
|
||||
|
||||
it("updates global enabled state through skills.update", async () => {
|
||||
const response = {
|
||||
ok: true,
|
||||
skillKey: "browser",
|
||||
config: {},
|
||||
};
|
||||
const client = {
|
||||
call: vi.fn(async () => response),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateSkill(client, {
|
||||
skillKey: " browser ",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith("skills.update", {
|
||||
skillKey: "browser",
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails fast when skill key is empty for updates", async () => {
|
||||
const client = {
|
||||
call: vi.fn(),
|
||||
|
||||
@@ -2,12 +2,19 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { SkillStatusEntry } from "@/lib/skills/types";
|
||||
import {
|
||||
deriveAgentSkillDisplayState,
|
||||
buildAgentSkillsAllowlistSet,
|
||||
buildSkillMissingDetails,
|
||||
buildSkillReasons,
|
||||
canRemoveSkill,
|
||||
deriveAgentSkillsAccessMode,
|
||||
deriveSkillReadinessState,
|
||||
filterOsCompatibleSkills,
|
||||
groupSkillsBySource,
|
||||
hasInstallableMissingBinary,
|
||||
isBundledBlockedSkill,
|
||||
isSkillOsIncompatible,
|
||||
normalizeAgentSkillsAllowlist,
|
||||
resolvePreferredInstallOption,
|
||||
} from "@/lib/skills/presentation";
|
||||
|
||||
@@ -70,7 +77,7 @@ describe("skills presentation helpers", () => {
|
||||
"Missing one-of tools (install any): chromium | chrome",
|
||||
"Missing env vars (set in gateway env): GITHUB_TOKEN",
|
||||
"Missing config values (set in openclaw.json): browser.enabled",
|
||||
"Unsupported OS: linux",
|
||||
"Requires OS: Linux",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -93,6 +100,48 @@ describe("skills presentation helpers", () => {
|
||||
expect(reasons).toEqual(["disabled", "blocked by allowlist", "missing tools"]);
|
||||
});
|
||||
|
||||
it("detects_os_incompatibility_from_missing_os_requirements", () => {
|
||||
expect(
|
||||
isSkillOsIncompatible(
|
||||
createSkill({
|
||||
missing: {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: ["darwin"],
|
||||
},
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSkillOsIncompatible(
|
||||
createSkill({
|
||||
missing: {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [" "],
|
||||
},
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("filters_out_os_incompatible_skills_while_preserving_order", () => {
|
||||
const filtered = filterOsCompatibleSkills([
|
||||
createSkill({ name: "github", missing: { bins: [], anyBins: [], env: [], config: [], os: [] } }),
|
||||
createSkill({
|
||||
name: "apple-notes",
|
||||
missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] },
|
||||
}),
|
||||
createSkill({ name: "slack", missing: { bins: [], anyBins: [], env: [], config: [], os: [] } }),
|
||||
]);
|
||||
|
||||
expect(filtered.map((skill) => skill.name)).toEqual(["github", "slack"]);
|
||||
});
|
||||
|
||||
it("detects bundled blocked skills", () => {
|
||||
expect(
|
||||
isBundledBlockedSkill(
|
||||
@@ -169,4 +218,57 @@ describe("skills presentation helpers", () => {
|
||||
);
|
||||
expect(canRemoveSkill(createSkill({ source: "openclaw-extra" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("derives agent access mode from allowlist shape", () => {
|
||||
expect(deriveAgentSkillsAccessMode(undefined)).toBe("all");
|
||||
expect(deriveAgentSkillsAccessMode([])).toBe("none");
|
||||
expect(deriveAgentSkillsAccessMode([" ", "github"])).toBe("selected");
|
||||
});
|
||||
|
||||
it("normalizes allowlist values and creates a lookup set", () => {
|
||||
expect(normalizeAgentSkillsAllowlist([" github ", "github", "slack", " "])).toEqual([
|
||||
"github",
|
||||
"slack",
|
||||
]);
|
||||
expect(buildAgentSkillsAllowlistSet([" github ", "slack"]).has("github")).toBe(true);
|
||||
expect(buildAgentSkillsAllowlistSet([" github ", "slack"]).has("browser")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies readiness with disabled and unavailable precedence", () => {
|
||||
expect(deriveSkillReadinessState(createSkill({ disabled: true, eligible: false }))).toBe(
|
||||
"disabled-globally"
|
||||
);
|
||||
expect(
|
||||
deriveSkillReadinessState(
|
||||
createSkill({
|
||||
eligible: false,
|
||||
missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] },
|
||||
})
|
||||
)
|
||||
).toBe("unavailable");
|
||||
expect(
|
||||
deriveSkillReadinessState(
|
||||
createSkill({
|
||||
eligible: false,
|
||||
blockedByAllowlist: true,
|
||||
})
|
||||
)
|
||||
).toBe("unavailable");
|
||||
expect(
|
||||
deriveSkillReadinessState(
|
||||
createSkill({
|
||||
eligible: false,
|
||||
missing: { bins: ["gh"], anyBins: [], env: [], config: [], os: [] },
|
||||
})
|
||||
)
|
||||
).toBe("needs-setup");
|
||||
expect(deriveSkillReadinessState(createSkill({ eligible: true }))).toBe("ready");
|
||||
});
|
||||
|
||||
it("maps readiness into agent display states", () => {
|
||||
expect(deriveAgentSkillDisplayState("ready")).toBe("ready");
|
||||
expect(deriveAgentSkillDisplayState("needs-setup")).toBe("setup-required");
|
||||
expect(deriveAgentSkillDisplayState("disabled-globally")).toBe("setup-required");
|
||||
expect(deriveAgentSkillDisplayState("unavailable")).toBe("not-supported");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -408,7 +408,9 @@ describe("useAgentSettingsMutationController", () => {
|
||||
expect(ctx.getValue().hasRestartBlockInProgress).toBe(true);
|
||||
});
|
||||
|
||||
expect(restartBlockHookParams?.block).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(restartBlockHookParams?.block).not.toBeNull();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
restartBlockHookParams?.onTimeout();
|
||||
@@ -470,6 +472,25 @@ describe("useAgentSettingsMutationController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads_skills_when_settings_system_tab_is_active", async () => {
|
||||
const report = {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedSkillsDir: "/tmp/skills",
|
||||
skills: [],
|
||||
};
|
||||
mockedLoadAgentSkillStatus.mockResolvedValue(report);
|
||||
const ctx = renderController({
|
||||
settingsRouteActive: true,
|
||||
inspectSidebarAgentId: "agent-1",
|
||||
inspectSidebarTab: "system",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledWith(expect.anything(), "agent-1");
|
||||
expect(ctx.getValue().settingsSkillsReport).toEqual(report);
|
||||
});
|
||||
});
|
||||
|
||||
it("use_all_and_disable_all_skills_write_via_config_queue", async () => {
|
||||
const ctx = renderController();
|
||||
|
||||
@@ -494,6 +515,40 @@ describe("useAgentSettingsMutationController", () => {
|
||||
expect(mockedLoadAgentSkillStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets_selected_skills_allowlist_via_config_queue", async () => {
|
||||
const ctx = renderController();
|
||||
|
||||
await act(async () => {
|
||||
await ctx.getValue().handleSetSkillsAllowlist("agent-1", [" github ", "slack", "github"]);
|
||||
});
|
||||
|
||||
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "update-agent-skills" })
|
||||
);
|
||||
expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
mode: "allowlist",
|
||||
skillNames: ["github", "slack"],
|
||||
})
|
||||
);
|
||||
expect(ctx.loadAgents).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects_empty_selected_skills_allowlist_before_gateway_call", async () => {
|
||||
const ctx = renderController();
|
||||
|
||||
await act(async () => {
|
||||
await ctx.getValue().handleSetSkillsAllowlist("agent-1", [" ", ""]);
|
||||
});
|
||||
|
||||
expect(mockedUpdateGatewayAgentSkillsAllowlist).not.toHaveBeenCalled();
|
||||
expect(ctx.getValue().settingsSkillsError).toBe(
|
||||
"Cannot set selected skills mode: choose at least one skill."
|
||||
);
|
||||
});
|
||||
|
||||
it("installs_skill_dependencies_with_per_skill_busy_and_message_state", async () => {
|
||||
mockedLoadAgentSkillStatus.mockResolvedValue({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
@@ -530,6 +585,29 @@ describe("useAgentSettingsMutationController", () => {
|
||||
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refreshes_skills_after_system_setup_mutation_when_system_tab_is_active", async () => {
|
||||
mockedLoadAgentSkillStatus.mockResolvedValue({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedSkillsDir: "/tmp/skills",
|
||||
skills: [],
|
||||
});
|
||||
const ctx = renderController({
|
||||
settingsRouteActive: true,
|
||||
inspectSidebarAgentId: "agent-1",
|
||||
inspectSidebarTab: "system",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await ctx.getValue().handleInstallSkill("agent-1", "browser", "browser", "install-browser");
|
||||
});
|
||||
|
||||
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("removes_skill_files_with_per_skill_busy_and_message_state", async () => {
|
||||
mockedLoadAgentSkillStatus.mockResolvedValue({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
@@ -563,6 +641,9 @@ describe("useAgentSettingsMutationController", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(ctx.getValue().settingsSkillsReport?.workspaceDir).toBe("/tmp/workspace");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await ctx.getValue().handleRemoveSkill("agent-1", {
|
||||
@@ -627,6 +708,40 @@ describe("useAgentSettingsMutationController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles_global_skill_enabled_via_skill_update", async () => {
|
||||
mockedLoadAgentSkillStatus.mockResolvedValue({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedSkillsDir: "/tmp/skills",
|
||||
skills: [],
|
||||
});
|
||||
const ctx = renderController({
|
||||
settingsRouteActive: true,
|
||||
inspectSidebarAgentId: "agent-1",
|
||||
inspectSidebarTab: "skills",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await ctx.getValue().handleSetSkillGlobalEnabled("agent-1", "browser", false);
|
||||
});
|
||||
|
||||
expect(mockedUpdateSkill).toHaveBeenCalledWith(expect.anything(), {
|
||||
skillKey: "browser",
|
||||
enabled: false,
|
||||
});
|
||||
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "update-skill-setup" })
|
||||
);
|
||||
expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
|
||||
kind: "success",
|
||||
message: "Skill disabled globally",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves_api_key_draft_and_sets_error_message_when_save_fails", async () => {
|
||||
mockedLoadAgentSkillStatus.mockResolvedValue({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
@@ -746,6 +861,23 @@ describe("useAgentSettingsMutationController", () => {
|
||||
configChecks: [],
|
||||
install: [],
|
||||
},
|
||||
{
|
||||
name: "apple-notes",
|
||||
description: "",
|
||||
source: "openclaw-managed",
|
||||
bundled: false,
|
||||
filePath: "/tmp/skills/apple-notes/SKILL.md",
|
||||
baseDir: "/tmp/skills/apple-notes",
|
||||
skillKey: "apple-notes",
|
||||
always: false,
|
||||
disabled: false,
|
||||
blockedByAllowlist: false,
|
||||
eligible: false,
|
||||
requirements: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] },
|
||||
missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] },
|
||||
configChecks: [],
|
||||
install: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const ctx = renderController({
|
||||
@@ -755,7 +887,7 @@ describe("useAgentSettingsMutationController", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctx.getValue().settingsSkillsReport?.skills.length).toBe(3);
|
||||
expect(ctx.getValue().settingsSkillsReport?.skills.length).toBe(4);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -353,4 +353,24 @@ describe("useSettingsRouteController", () => {
|
||||
tab: "skills",
|
||||
});
|
||||
});
|
||||
|
||||
it("switches to system tab without discard prompt when leaving skills", () => {
|
||||
const ctx = renderController({
|
||||
settingsRouteActive: true,
|
||||
settingsRouteAgentId: "agent-1",
|
||||
inspectSidebar: { agentId: "agent-1", tab: "skills" },
|
||||
activeTab: "skills" satisfies SettingsRouteTab,
|
||||
personalityHasUnsavedChanges: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
ctx.getValue().handleSettingsRouteTabChange("system");
|
||||
});
|
||||
|
||||
expect(ctx.confirmDiscard).not.toHaveBeenCalled();
|
||||
expect(ctx.setInspectSidebar).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
tab: "system",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user