feat(ui): add Agents dashboard

This commit is contained in:
Gustavo Madeira Santana
2026-02-02 21:31:17 -05:00
parent c8af8e9555
commit 2a68bcbeb3
32 changed files with 3652 additions and 21 deletions

View File

@@ -989,6 +989,7 @@
white-space: pre-wrap;
overflow: hidden;
display: -webkit-box;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
@@ -1496,3 +1497,398 @@
flex-wrap: wrap;
gap: 8px;
}
/* ===========================================
Agents
=========================================== */
.agents-layout {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 16px;
}
.agents-sidebar {
display: grid;
gap: 12px;
align-self: start;
}
.agents-main {
display: grid;
gap: 16px;
}
.agent-list {
display: grid;
gap: 8px;
}
.agent-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
padding: 10px 12px;
cursor: pointer;
transition: border-color var(--duration-fast) ease;
}
.agent-row:hover {
border-color: var(--border-strong);
}
.agent-row.active {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.agent-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--secondary);
display: grid;
place-items: center;
font-weight: 600;
}
.agent-avatar--lg {
width: 48px;
height: 48px;
font-size: 20px;
}
.agent-info {
display: grid;
gap: 2px;
min-width: 0;
}
.agent-title {
font-weight: 600;
}
.agent-sub {
color: var(--muted);
font-size: 12px;
}
.agent-pill {
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 4px 10px;
font-size: 11px;
color: var(--muted);
background: var(--secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.agent-pill.warn {
color: var(--warn);
border-color: var(--warn);
}
.agent-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
}
.agent-header-main {
display: flex;
gap: 16px;
align-items: center;
}
.agent-header-meta {
display: grid;
justify-items: end;
gap: 6px;
color: var(--muted);
}
.agent-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.agent-tab {
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
background: var(--secondary);
cursor: pointer;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.agent-tab.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.agents-overview-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.agent-kv {
display: grid;
gap: 6px;
}
.agent-kv-sub {
font-size: 12px;
}
.agent-model-select {
display: grid;
gap: 12px;
}
.agent-model-meta {
display: grid;
gap: 6px;
min-width: 200px;
}
.agent-files-grid {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 16px;
}
.agent-files-list {
display: grid;
gap: 8px;
}
.agent-file-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
padding: 10px 12px;
cursor: pointer;
transition: border-color var(--duration-fast) ease;
}
.agent-file-row:hover {
border-color: var(--border-strong);
}
.agent-file-row.active {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.agent-file-name {
font-weight: 600;
}
.agent-file-meta {
color: var(--muted);
font-size: 12px;
margin-top: 4px;
}
.agent-files-editor {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
background: var(--card);
}
.agent-file-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.agent-file-title {
font-weight: 600;
}
.agent-file-sub {
color: var(--muted);
font-size: 12px;
margin-top: 4px;
}
.agent-file-actions {
display: flex;
gap: 8px;
}
.agent-tools-meta {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.agent-tools-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.agent-tools-grid {
display: grid;
gap: 16px;
}
.agent-tools-section {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px;
background: var(--bg-elevated);
}
.agent-tools-header {
font-weight: 600;
margin-bottom: 10px;
}
.agent-tools-list {
display: grid;
gap: 8px 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.agent-tool-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
}
.agent-tool-title {
font-weight: 600;
font-size: 13px;
}
.agent-tool-sub {
color: var(--muted);
font-size: 11px;
margin-top: 2px;
}
.agent-skills-groups {
display: grid;
gap: 16px;
}
.agent-skills-group {
display: grid;
gap: 10px;
}
.agent-skills-group summary {
list-style: none;
}
.agent-skills-header {
display: flex;
align-items: center;
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
cursor: pointer;
gap: 8px;
}
.agent-skills-header > span:last-child {
margin-left: auto;
}
.agent-skills-group summary::-webkit-details-marker {
display: none;
}
.agent-skills-group summary::marker {
content: "";
}
.agent-skills-header::after {
content: "▸";
font-size: 12px;
color: var(--muted);
transition: transform var(--duration-fast) ease;
margin-left: 8px;
}
.agent-skills-group[open] .agent-skills-header::after {
transform: rotate(90deg);
}
.agent-skill-row {
align-items: flex-start;
gap: 18px;
}
.agent-skill-row .list-meta {
display: flex;
align-items: flex-start;
justify-content: flex-end;
min-width: auto;
}
.skills-grid {
grid-template-columns: 1fr;
}
@container (min-width: 900px) {
.skills-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.agents-layout {
grid-template-columns: 1fr;
}
.agent-header {
grid-template-columns: 1fr;
}
.agent-header-meta {
justify-items: start;
}
.agent-files-grid {
grid-template-columns: 1fr;
}
.agent-tools-list {
grid-template-columns: 1fr;
}
}

View File

@@ -62,7 +62,10 @@
.shell--chat-focus .content {
padding-top: 0;
gap: 0;
}
.shell--chat-focus .content > * + * {
margin-top: 0;
}
/* ===========================================
@@ -418,23 +421,32 @@
.content {
grid-area: content;
padding: 12px 16px 32px;
display: flex;
flex-direction: column;
gap: 24px;
display: block;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.content > * + * {
margin-top: 24px;
}
:root[data-theme="light"] .content {
background: var(--bg-content);
}
.content--chat {
display: flex;
flex-direction: column;
gap: 24px;
overflow: hidden;
padding-bottom: 0;
}
.content--chat > * + * {
margin-top: 0;
}
/* Content header */
.content-header {
display: flex;

View File

@@ -3,6 +3,10 @@ import type { AppViewState } from "./app-view-state";
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
import { refreshChatAvatar } from "./app-chat";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files";
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity";
import { loadAgentSkills } from "./controllers/agent-skills";
import { loadAgents } from "./controllers/agents";
import { loadChannels } from "./controllers/channels";
import { loadChatHistory } from "./controllers/chat";
import {
@@ -47,6 +51,7 @@ import {
} from "./controllers/skills";
import { icons } from "./icons";
import { TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation";
import { renderAgents } from "./views/agents";
import { renderChannels } from "./views/channels";
import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config";
@@ -90,6 +95,13 @@ export function renderApp(state: AppViewState) {
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
const configValue =
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
const resolvedAgentId =
state.agentsSelectedId ??
state.agentsList?.defaultId ??
state.agentsList?.agents?.[0]?.id ??
null;
return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
@@ -317,6 +329,352 @@ export function renderApp(state: AppViewState) {
: nothing
}
${
state.tab === "agents"
? renderAgents({
loading: state.agentsLoading,
error: state.agentsError,
agentsList: state.agentsList,
selectedAgentId: resolvedAgentId,
activePanel: state.agentsPanel,
configForm: configValue,
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
channelsLoading: state.channelsLoading,
channelsError: state.channelsError,
channelsSnapshot: state.channelsSnapshot,
channelsLastSuccess: state.channelsLastSuccess,
cronLoading: state.cronLoading,
cronStatus: state.cronStatus,
cronJobs: state.cronJobs,
cronError: state.cronError,
agentFilesLoading: state.agentFilesLoading,
agentFilesError: state.agentFilesError,
agentFilesList: state.agentFilesList,
agentFileActive: state.agentFileActive,
agentFileContents: state.agentFileContents,
agentFileDrafts: state.agentFileDrafts,
agentFileSaving: state.agentFileSaving,
agentIdentityLoading: state.agentIdentityLoading,
agentIdentityError: state.agentIdentityError,
agentIdentityById: state.agentIdentityById,
agentSkillsLoading: state.agentSkillsLoading,
agentSkillsReport: state.agentSkillsReport,
agentSkillsError: state.agentSkillsError,
agentSkillsAgentId: state.agentSkillsAgentId,
skillsFilter: state.skillsFilter,
onRefresh: async () => {
await loadAgents(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
if (agentIds.length > 0) {
void loadAgentIdentities(state, agentIds);
}
},
onSelectAgent: (agentId) => {
if (state.agentsSelectedId === agentId) {
return;
}
state.agentsSelectedId = agentId;
state.agentFilesList = null;
state.agentFilesError = null;
state.agentFilesLoading = false;
state.agentFileActive = null;
state.agentFileContents = {};
state.agentFileDrafts = {};
state.agentSkillsReport = null;
state.agentSkillsError = null;
state.agentSkillsAgentId = null;
void loadAgentIdentity(state, agentId);
if (state.agentsPanel === "files") {
void loadAgentFiles(state, agentId);
}
if (state.agentsPanel === "skills") {
void loadAgentSkills(state, agentId);
}
},
onSelectPanel: (panel) => {
state.agentsPanel = panel;
if (panel === "files" && resolvedAgentId) {
if (state.agentFilesList?.agentId !== resolvedAgentId) {
state.agentFilesList = null;
state.agentFilesError = null;
state.agentFileActive = null;
state.agentFileContents = {};
state.agentFileDrafts = {};
void loadAgentFiles(state, resolvedAgentId);
}
}
if (panel === "skills") {
if (resolvedAgentId) {
void loadAgentSkills(state, resolvedAgentId);
}
}
if (panel === "channels") {
void loadChannels(state, false);
}
if (panel === "cron") {
void state.loadCron();
}
},
onLoadFiles: (agentId) => loadAgentFiles(state, agentId),
onSelectFile: (name) => {
state.agentFileActive = name;
if (!resolvedAgentId) {
return;
}
void loadAgentFileContent(state, resolvedAgentId, name);
},
onFileDraftChange: (name, content) => {
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
},
onFileReset: (name) => {
const base = state.agentFileContents[name] ?? "";
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: base };
},
onFileSave: (name) => {
if (!resolvedAgentId) {
return;
}
const content =
state.agentFileDrafts[name] ?? state.agentFileContents[name] ?? "";
void saveAgentFile(state, resolvedAgentId, name, content);
},
onToolsProfileChange: (agentId, profile, clearAllow) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const basePath = ["agents", "list", index, "tools"];
if (profile) {
updateConfigFormValue(state, [...basePath, "profile"], profile);
} else {
removeConfigFormValue(state, [...basePath, "profile"]);
}
if (clearAllow) {
removeConfigFormValue(state, [...basePath, "allow"]);
}
},
onToolsOverridesChange: (agentId, alsoAllow, deny) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const basePath = ["agents", "list", index, "tools"];
if (alsoAllow.length > 0) {
updateConfigFormValue(state, [...basePath, "alsoAllow"], alsoAllow);
} else {
removeConfigFormValue(state, [...basePath, "alsoAllow"]);
}
if (deny.length > 0) {
updateConfigFormValue(state, [...basePath, "deny"], deny);
} else {
removeConfigFormValue(state, [...basePath, "deny"]);
}
},
onConfigReload: () => loadConfig(state),
onConfigSave: () => saveConfig(state),
onChannelsRefresh: () => loadChannels(state, false),
onCronRefresh: () => state.loadCron(),
onSkillsFilterChange: (next) => (state.skillsFilter = next),
onSkillsRefresh: () => {
if (resolvedAgentId) {
void loadAgentSkills(state, resolvedAgentId);
}
},
onAgentSkillToggle: (agentId, skillName, enabled) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const entry = list[index] as { skills?: unknown };
const normalizedSkill = skillName.trim();
if (!normalizedSkill) {
return;
}
const allSkills =
state.agentSkillsReport?.skills?.map((skill) => skill.name).filter(Boolean) ??
[];
const existing = Array.isArray(entry.skills)
? entry.skills.map((name) => String(name).trim()).filter(Boolean)
: undefined;
const base = existing ?? allSkills;
const next = new Set(base);
if (enabled) {
next.add(normalizedSkill);
} else {
next.delete(normalizedSkill);
}
updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]);
},
onAgentSkillsClear: (agentId) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
removeConfigFormValue(state, ["agents", "list", index, "skills"]);
},
onAgentSkillsDisableAll: (agentId) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
updateConfigFormValue(state, ["agents", "list", index, "skills"], []);
},
onModelChange: (agentId, modelId) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const basePath = ["agents", "list", index, "model"];
if (!modelId) {
removeConfigFormValue(state, basePath);
return;
}
const entry = list[index] as { model?: unknown };
const existing = entry?.model;
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
const next = {
primary: modelId,
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
};
updateConfigFormValue(state, basePath, next);
} else {
updateConfigFormValue(state, basePath, modelId);
}
},
onModelFallbacksChange: (agentId, fallbacks) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const basePath = ["agents", "list", index, "model"];
const entry = list[index] as { model?: unknown };
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
const existing = entry.model;
const resolvePrimary = () => {
if (typeof existing === "string") {
return existing.trim() || null;
}
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const primary = (existing as { primary?: unknown }).primary;
if (typeof primary === "string") {
const trimmed = primary.trim();
return trimmed || null;
}
}
return null;
};
const primary = resolvePrimary();
if (normalized.length === 0) {
if (primary) {
updateConfigFormValue(state, basePath, primary);
} else {
removeConfigFormValue(state, basePath);
}
return;
}
const next = primary
? { primary, fallbacks: normalized }
: { fallbacks: normalized };
updateConfigFormValue(state, basePath, next);
},
})
: nothing
}
${
state.tab === "skills"
? renderSkills({

View File

@@ -7,6 +7,9 @@ import {
stopDebugPolling,
} from "./app-polling";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity";
import { loadAgentSkills } from "./controllers/agent-skills";
import { loadAgents } from "./controllers/agents";
import { loadChannels } from "./controllers/channels";
import { loadConfig, loadConfigSchema } from "./controllers/config";
import { loadCronJobs, loadCronStatus } from "./controllers/cron";
@@ -185,6 +188,28 @@ export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "skills") {
await loadSkills(host as unknown as OpenClawApp);
}
if (host.tab === "agents") {
await loadAgents(host as unknown as OpenClawApp);
await loadConfig(host as unknown as OpenClawApp);
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
if (agentIds.length > 0) {
void loadAgentIdentities(host as unknown as OpenClawApp, agentIds);
}
const agentId =
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
if (agentId) {
void loadAgentIdentity(host as unknown as OpenClawApp, agentId);
if (host.agentsPanel === "skills") {
void loadAgentSkills(host as unknown as OpenClawApp, agentId);
}
if (host.agentsPanel === "channels") {
void loadChannels(host as unknown as OpenClawApp, false);
}
if (host.agentsPanel === "cron") {
void loadCron(host);
}
}
}
if (host.tab === "nodes") {
await loadNodes(host as unknown as OpenClawApp);
await loadDevices(host as unknown as OpenClawApp);

View File

@@ -10,6 +10,8 @@ import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import type {
AgentsListResult,
AgentsFilesListResult,
AgentIdentityResult,
ChannelsStatusSnapshot,
ConfigSnapshot,
CronJob,
@@ -106,6 +108,22 @@ export type AppViewState = {
agentsLoading: boolean;
agentsList: AgentsListResult | null;
agentsError: string | null;
agentsSelectedId: string | null;
agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
agentFilesLoading: boolean;
agentFilesError: string | null;
agentFilesList: AgentsFilesListResult | null;
agentFileContents: Record<string, string>;
agentFileDrafts: Record<string, string>;
agentFileActive: string | null;
agentFileSaving: boolean;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
agentIdentityById: Record<string, AgentIdentityResult>;
agentSkillsLoading: boolean;
agentSkillsError: string | null;
agentSkillsReport: SkillStatusReport | null;
agentSkillsAgentId: string | null;
sessionsLoading: boolean;
sessionsResult: SessionsListResult | null;
sessionsError: string | null;

View File

@@ -5,11 +5,14 @@ import type { AppViewState } from "./app-view-state";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
import type { SkillMessage } from "./controllers/skills";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { Tab } from "./navigation";
import type { ResolvedTheme, ThemeMode } from "./theme";
import type {
AgentsListResult,
AgentsFilesListResult,
AgentIdentityResult,
ConfigSnapshot,
ConfigUiHints,
CronJob,
@@ -197,6 +200,23 @@ export class OpenClawApp extends LitElement {
@state() agentsLoading = false;
@state() agentsList: AgentsListResult | null = null;
@state() agentsError: string | null = null;
@state() agentsSelectedId: string | null = null;
@state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" =
"overview";
@state() agentFilesLoading = false;
@state() agentFilesError: string | null = null;
@state() agentFilesList: AgentsFilesListResult | null = null;
@state() agentFileContents: Record<string, string> = {};
@state() agentFileDrafts: Record<string, string> = {};
@state() agentFileActive: string | null = null;
@state() agentFileSaving = false;
@state() agentIdentityLoading = false;
@state() agentIdentityError: string | null = null;
@state() agentIdentityById: Record<string, AgentIdentityResult> = {};
@state() agentSkillsLoading = false;
@state() agentSkillsError: string | null = null;
@state() agentSkillsReport: SkillStatusReport | null = null;
@state() agentSkillsAgentId: string | null = null;
@state() sessionsLoading = false;
@state() sessionsResult: SessionsListResult | null = null;

View File

@@ -1,4 +1,4 @@
import { LitElement, html, css } from "lit";
import { LitElement, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
/**
@@ -24,7 +24,7 @@ export class ResizableDivider extends LitElement {
flex-shrink: 0;
position: relative;
}
:host::before {
content: "";
position: absolute;
@@ -33,18 +33,18 @@ export class ResizableDivider extends LitElement {
right: -4px;
bottom: 0;
}
:host(:hover) {
background: var(--accent, #007bff);
}
:host(.dragging) {
background: var(--accent, #007bff);
}
`;
render() {
return html``;
return nothing;
}
connectedCallback() {

View File

@@ -0,0 +1,114 @@
import type { GatewayBrowserClient } from "../gateway";
import type {
AgentFileEntry,
AgentsFilesGetResult,
AgentsFilesListResult,
AgentsFilesSetResult,
} from "../types";
export type AgentFilesState = {
client: GatewayBrowserClient | null;
connected: boolean;
agentFilesLoading: boolean;
agentFilesError: string | null;
agentFilesList: AgentsFilesListResult | null;
agentFileContents: Record<string, string>;
agentFileDrafts: Record<string, string>;
agentFileActive: string | null;
agentFileSaving: boolean;
};
function mergeFileEntry(
list: AgentsFilesListResult | null,
entry: AgentFileEntry,
): AgentsFilesListResult | null {
if (!list) {
return list;
}
const hasEntry = list.files.some((file) => file.name === entry.name);
const nextFiles = hasEntry
? list.files.map((file) => (file.name === entry.name ? entry : file))
: [...list.files, entry];
return { ...list, files: nextFiles };
}
export async function loadAgentFiles(state: AgentFilesState, agentId: string) {
if (!state.client || !state.connected || state.agentFilesLoading) {
return;
}
state.agentFilesLoading = true;
state.agentFilesError = null;
try {
const res = await state.client.request<AgentsFilesListResult | null>("agents.files.list", {
agentId,
});
if (res) {
state.agentFilesList = res;
if (state.agentFileActive && !res.files.some((file) => file.name === state.agentFileActive)) {
state.agentFileActive = null;
}
}
} catch (err) {
state.agentFilesError = String(err);
} finally {
state.agentFilesLoading = false;
}
}
export async function loadAgentFileContent(state: AgentFilesState, agentId: string, name: string) {
if (!state.client || !state.connected || state.agentFilesLoading) {
return;
}
if (Object.hasOwn(state.agentFileContents, name)) {
return;
}
state.agentFilesLoading = true;
state.agentFilesError = null;
try {
const res = await state.client.request<AgentsFilesGetResult | null>("agents.files.get", {
agentId,
name,
});
if (res?.file) {
const content = res.file.content ?? "";
state.agentFilesList = mergeFileEntry(state.agentFilesList, res.file);
state.agentFileContents = { ...state.agentFileContents, [name]: content };
if (!Object.hasOwn(state.agentFileDrafts, name)) {
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
}
}
} catch (err) {
state.agentFilesError = String(err);
} finally {
state.agentFilesLoading = false;
}
}
export async function saveAgentFile(
state: AgentFilesState,
agentId: string,
name: string,
content: string,
) {
if (!state.client || !state.connected || state.agentFileSaving) {
return;
}
state.agentFileSaving = true;
state.agentFilesError = null;
try {
const res = await state.client.request<AgentsFilesSetResult | null>("agents.files.set", {
agentId,
name,
content,
});
if (res?.file) {
state.agentFilesList = mergeFileEntry(state.agentFilesList, res.file);
state.agentFileContents = { ...state.agentFileContents, [name]: content };
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
}
} catch (err) {
state.agentFilesError = String(err);
} finally {
state.agentFileSaving = false;
}
}

View File

@@ -0,0 +1,59 @@
import type { GatewayBrowserClient } from "../gateway";
import type { AgentIdentityResult } from "../types";
export type AgentIdentityState = {
client: GatewayBrowserClient | null;
connected: boolean;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
agentIdentityById: Record<string, AgentIdentityResult>;
};
export async function loadAgentIdentity(state: AgentIdentityState, agentId: string) {
if (!state.client || !state.connected || state.agentIdentityLoading) {
return;
}
if (state.agentIdentityById[agentId]) {
return;
}
state.agentIdentityLoading = true;
state.agentIdentityError = null;
try {
const res = await state.client.request<AgentIdentityResult | null>("agent.identity.get", {
agentId,
});
if (res) {
state.agentIdentityById = { ...state.agentIdentityById, [agentId]: res };
}
} catch (err) {
state.agentIdentityError = String(err);
} finally {
state.agentIdentityLoading = false;
}
}
export async function loadAgentIdentities(state: AgentIdentityState, agentIds: string[]) {
if (!state.client || !state.connected || state.agentIdentityLoading) {
return;
}
const missing = agentIds.filter((id) => !state.agentIdentityById[id]);
if (missing.length === 0) {
return;
}
state.agentIdentityLoading = true;
state.agentIdentityError = null;
try {
for (const agentId of missing) {
const res = await state.client.request<AgentIdentityResult | null>("agent.identity.get", {
agentId,
});
if (res) {
state.agentIdentityById = { ...state.agentIdentityById, [agentId]: res };
}
}
} catch (err) {
state.agentIdentityError = String(err);
} finally {
state.agentIdentityLoading = false;
}
}

View File

@@ -0,0 +1,33 @@
import type { GatewayBrowserClient } from "../gateway";
import type { SkillStatusReport } from "../types";
export type AgentSkillsState = {
client: GatewayBrowserClient | null;
connected: boolean;
agentSkillsLoading: boolean;
agentSkillsError: string | null;
agentSkillsReport: SkillStatusReport | null;
agentSkillsAgentId: string | null;
};
export async function loadAgentSkills(state: AgentSkillsState, agentId: string) {
if (!state.client || !state.connected) {
return;
}
if (state.agentSkillsLoading) {
return;
}
state.agentSkillsLoading = true;
state.agentSkillsError = null;
try {
const res = await state.client.request("skills.status", { agentId });
if (res) {
state.agentSkillsReport = res as SkillStatusReport;
state.agentSkillsAgentId = agentId;
}
} catch (err) {
state.agentSkillsError = String(err);
} finally {
state.agentSkillsLoading = false;
}
}

View File

@@ -7,6 +7,7 @@ export type AgentsState = {
agentsLoading: boolean;
agentsError: string | null;
agentsList: AgentsListResult | null;
agentsSelectedId: string | null;
};
export async function loadAgents(state: AgentsState) {
@@ -22,6 +23,11 @@ export async function loadAgents(state: AgentsState) {
const res = await state.client.request("agents.list", {});
if (res) {
state.agentsList = res;
const selected = state.agentsSelectedId;
const known = res.agents.some((entry) => entry.id === selected);
if (!selected || !known) {
state.agentsSelectedId = res.defaultId ?? res.agents[0]?.id ?? null;
}
}
} catch (err) {
state.agentsError = String(err);

View File

@@ -6,11 +6,12 @@ export const TAB_GROUPS = [
label: "Control",
tabs: ["overview", "channels", "instances", "sessions", "cron"],
},
{ label: "Agent", tabs: ["skills", "nodes"] },
{ label: "Agent", tabs: ["agents", "skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] },
] as const;
export type Tab =
| "agents"
| "overview"
| "channels"
| "instances"
@@ -24,6 +25,7 @@ export type Tab =
| "logs";
const TAB_PATHS: Record<Tab, string> = {
agents: "/agents",
overview: "/overview",
channels: "/channels",
instances: "/instances",
@@ -120,6 +122,8 @@ export function inferBasePathFromPathname(pathname: string): string {
export function iconForTab(tab: Tab): IconName {
switch (tab) {
case "agents":
return "folder";
case "chat":
return "messageSquare";
case "overview":
@@ -149,6 +153,8 @@ export function iconForTab(tab: Tab): IconName {
export function titleForTab(tab: Tab) {
switch (tab) {
case "agents":
return "Agents";
case "overview":
return "Overview";
case "channels":
@@ -178,6 +184,8 @@ export function titleForTab(tab: Tab) {
export function subtitleForTab(tab: Tab) {
switch (tab) {
case "agents":
return "Manage agent workspaces, tools, and identities.";
case "overview":
return "Gateway status, entry points, and a fast health read.";
case "channels":

View File

@@ -340,6 +340,41 @@ export type AgentsListResult = {
agents: GatewayAgentRow[];
};
export type AgentIdentityResult = {
agentId: string;
name: string;
avatar: string;
emoji?: string;
};
export type AgentFileEntry = {
name: string;
path: string;
missing: boolean;
size?: number;
updatedAtMs?: number;
content?: string;
};
export type AgentsFilesListResult = {
agentId: string;
workspace: string;
files: AgentFileEntry[];
};
export type AgentsFilesGetResult = {
agentId: string;
workspace: string;
file: AgentFileEntry;
};
export type AgentsFilesSetResult = {
ok: true;
agentId: string;
workspace: string;
file: AgentFileEntry;
};
export type GatewaySessionRow = {
key: string;
kind: "direct" | "group" | "global" | "unknown";

1950
ui/src/ui/views/agents.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -63,6 +63,46 @@ function resolveChannelValue(
return resolved ?? {};
}
const EXTRA_CHANNEL_FIELDS = ["groupPolicy", "streamMode", "dmPolicy"] as const;
function formatExtraValue(raw: unknown): string {
if (raw == null) {
return "n/a";
}
if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
return String(raw);
}
try {
return JSON.stringify(raw);
} catch {
return "n/a";
}
}
function renderExtraChannelFields(value: Record<string, unknown>) {
const entries = EXTRA_CHANNEL_FIELDS.flatMap((field) => {
if (!(field in value)) {
return [];
}
return [[field, value[field]]] as Array<[string, unknown]>;
});
if (entries.length === 0) {
return null;
}
return html`
<div class="status-list" style="margin-top: 12px;">
${entries.map(
([field, raw]) => html`
<div>
<span class="label">${field}</span>
<span>${formatExtraValue(raw)}</span>
</div>
`,
)}
</div>
`;
}
export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
@@ -92,6 +132,7 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
onPatch: props.onPatch,
})}
</div>
${renderExtraChannelFields(value)}
`;
}

View File

@@ -3,6 +3,42 @@ import type { SkillMessageMap } from "../controllers/skills";
import type { SkillStatusEntry, SkillStatusReport } from "../types";
import { clampText } from "../format";
type SkillGroup = {
id: string;
label: string;
skills: SkillStatusEntry[];
};
const SKILL_SOURCE_GROUPS: Array<{ id: string; label: string; sources: string[] }> = [
{ id: "workspace", label: "Workspace Skills", sources: ["openclaw-workspace"] },
{ id: "built-in", label: "Built-in Skills", sources: ["openclaw-bundled"] },
{ id: "installed", label: "Installed Skills", sources: ["openclaw-managed"] },
{ id: "extra", label: "Extra Skills", sources: ["openclaw-extra"] },
];
function groupSkills(skills: SkillStatusEntry[]): SkillGroup[] {
const groups = new Map<string, SkillGroup>();
for (const def of SKILL_SOURCE_GROUPS) {
groups.set(def.id, { id: def.id, label: def.label, skills: [] });
}
const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] };
for (const skill of skills) {
const match = SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source));
if (match) {
groups.get(match.id)?.skills.push(skill);
} else {
other.skills.push(skill);
}
}
const ordered = SKILL_SOURCE_GROUPS.map((group) => groups.get(group.id)).filter(
(group): group is SkillGroup => Boolean(group && group.skills.length > 0),
);
if (other.skills.length > 0) {
ordered.push(other);
}
return ordered;
}
export type SkillsProps = {
loading: boolean;
report: SkillStatusReport | null;
@@ -27,6 +63,7 @@ export function renderSkills(props: SkillsProps) {
[skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter),
)
: skills;
const groups = groupSkills(filtered);
return html`
<section class="card">
@@ -64,8 +101,21 @@ export function renderSkills(props: SkillsProps) {
<div class="muted" style="margin-top: 16px">No skills found.</div>
`
: html`
<div class="list" style="margin-top: 16px;">
${filtered.map((skill) => renderSkill(skill, props))}
<div class="agent-skills-groups" style="margin-top: 16px;">
${groups.map((group) => {
const collapsedByDefault = group.id === "workspace" || group.id === "built-in";
return html`
<details class="agent-skills-group" ?open=${!collapsedByDefault}>
<summary class="agent-skills-header">
<span>${group.label}</span>
<span class="muted">${group.skills.length}</span>
</summary>
<div class="list skills-grid">
${group.skills.map((skill) => renderSkill(skill, props))}
</div>
</details>
`;
})}
</div>
`
}