[codex] Polish sidebar status, agent skills, and chat rendering (#45451)

* style: update chat layout and spacing for improved UI consistency

- Adjusted margin and padding for .chat-thread and .content--chat to enhance layout.
- Consolidated CSS selectors for better readability and maintainability.
- Introduced new test for log parsing functionality to ensure accurate message extraction.

* UI: polish agent skills, chat images, and sidebar status

* test: stabilize vitest helper export types

* UI: address review feedback on agents refresh and chat styles

* test: update outbound gateway client fixture values

* test: narrow shared ip fixtures to IPv4
This commit is contained in:
Val Alexander 2026-03-13 16:53:40 -05:00 committed by GitHub
parent 52900b48ad
commit 158d970e2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 427 additions and 53 deletions

View File

@ -46,6 +46,7 @@ describe("runServiceRestart token drift", () => {
});
resetLifecycleServiceMocks();
service.readCommand.mockResolvedValue({
programArguments: [],
environment: { OPENCLAW_GATEWAY_TOKEN: "service-token" },
});
stubEmptyGatewayEnv();
@ -77,6 +78,7 @@ describe("runServiceRestart token drift", () => {
},
});
service.readCommand.mockResolvedValue({
programArguments: [],
environment: { OPENCLAW_GATEWAY_TOKEN: "env-token" },
});
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "env-token");

View File

@ -1,16 +1,36 @@
import { vi } from "vitest";
import type { GatewayService } from "../../../daemon/service.js";
import type { RuntimeEnv } from "../../../runtime.js";
import type { MockFn } from "../../../test-utils/vitest-mock-fn.js";
export const runtimeLogs: string[] = [];
export const defaultRuntime = {
log: (message: string) => runtimeLogs.push(message),
error: vi.fn(),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
type LifecycleRuntimeHarness = RuntimeEnv & {
error: MockFn<RuntimeEnv["error"]>;
exit: MockFn<RuntimeEnv["exit"]>;
};
export const service = {
type LifecycleServiceHarness = GatewayService & {
install: MockFn<GatewayService["install"]>;
uninstall: MockFn<GatewayService["uninstall"]>;
stop: MockFn<GatewayService["stop"]>;
isLoaded: MockFn<GatewayService["isLoaded"]>;
readCommand: MockFn<GatewayService["readCommand"]>;
readRuntime: MockFn<GatewayService["readRuntime"]>;
restart: MockFn<GatewayService["restart"]>;
};
export const defaultRuntime: LifecycleRuntimeHarness = {
log: (...args: unknown[]) => {
runtimeLogs.push(args.map((arg) => String(arg)).join(" "));
},
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
}),
};
export const service: LifecycleServiceHarness = {
label: "TestService",
loadedText: "loaded",
notLoadedText: "not loaded",
@ -32,7 +52,7 @@ export function resetLifecycleServiceMocks() {
service.readCommand.mockClear();
service.restart.mockClear();
service.isLoaded.mockResolvedValue(true);
service.readCommand.mockResolvedValue({ environment: {} });
service.readCommand.mockResolvedValue({ programArguments: [], environment: {} });
service.restart.mockResolvedValue({ outcome: "completed" });
}

View File

@ -14,9 +14,9 @@ vi.mock("../schtasks-exec.js", () => ({
}));
vi.mock("../../infra/ports.js", () => ({
inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args),
inspectPortUsage: (port: number) => inspectPortUsage(port),
}));
vi.mock("../../process/kill-tree.js", () => ({
killProcessTree: (...args: unknown[]) => killProcessTree(...args),
killProcessTree: (pid: number, opts?: { graceMs?: number }) => killProcessTree(pid, opts),
}));

View File

@ -2,11 +2,15 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { vi } from "vitest";
import type { PortUsage } from "../../infra/ports-types.js";
import type { killProcessTree as killProcessTreeImpl } from "../../process/kill-tree.js";
import type { MockFn } from "../../test-utils/vitest-mock-fn.js";
export const schtasksResponses: Array<{ code: number; stdout: string; stderr: string }> = [];
export const schtasksCalls: string[][] = [];
export const inspectPortUsage = vi.fn();
export const killProcessTree = vi.fn();
export const inspectPortUsage: MockFn<(port: number) => Promise<PortUsage>> = vi.fn();
export const killProcessTree: MockFn<typeof killProcessTreeImpl> = vi.fn();
export async function withWindowsEnv(
prefix: string,

View File

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
const mocks = vi.hoisted(() => ({
getDefaultMediaLocalRoots: vi.fn(() => []),
@ -204,8 +205,8 @@ describe("executeSendAction", () => {
url: "http://127.0.0.1:18789",
token: "tok",
timeoutMs: 5000,
clientName: "gateway",
mode: "gateway",
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
},
to: "channel:123",
@ -296,8 +297,8 @@ describe("executeSendAction", () => {
url: "http://127.0.0.1:18789",
token: "tok",
timeoutMs: 5000,
clientName: "gateway",
mode: "gateway",
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
},
to: "channel:123",

View File

@ -6,6 +6,7 @@ import {
isCanonicalDottedDecimalIPv4,
isCarrierGradeNatIpv4Address,
isIpInCidr,
isIpv4Address,
isIpv6Address,
isLegacyIpv4Literal,
isLoopbackIpAddress,
@ -88,7 +89,7 @@ describe("shared ip helpers", () => {
expect(loopback?.kind()).toBe("ipv4");
expect(benchmark?.kind()).toBe("ipv4");
if (!loopback || loopback.kind() !== "ipv4" || !benchmark || benchmark.kind() !== "ipv4") {
if (!loopback || !isIpv4Address(loopback) || !benchmark || !isIpv4Address(benchmark)) {
throw new Error("expected ipv4 fixtures");
}

View File

@ -3,12 +3,28 @@ import type { OpenClawConfig } from "../config/config.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import type { TelegramAccountConfig } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
type RegisterTelegramNativeCommandsParams = Parameters<typeof registerTelegramNativeCommands>[0];
type GetPluginCommandSpecsFn = typeof import("../plugins/commands.js").getPluginCommandSpecs;
type MatchPluginCommandFn = typeof import("../plugins/commands.js").matchPluginCommand;
type ExecutePluginCommandFn = typeof import("../plugins/commands.js").executePluginCommand;
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
type NativeCommandHarness = {
handlers: Record<string, (ctx: unknown) => Promise<void>>;
sendMessage: AnyAsyncMock;
setMyCommands: AnyAsyncMock;
log: AnyMock;
bot: {
api: {
setMyCommands: AnyAsyncMock;
sendMessage: AnyAsyncMock;
};
command: (name: string, handler: (ctx: unknown) => Promise<void>) => void;
};
};
const pluginCommandMocks = vi.hoisted(() => ({
getPluginCommandSpecs: vi.fn<GetPluginCommandSpecsFn>(() => []),
@ -86,12 +102,12 @@ export function createNativeCommandsHarness(params?: {
nativeEnabled?: boolean;
groupConfig?: Record<string, unknown>;
resolveGroupPolicy?: () => ChannelGroupPolicy;
}) {
}): NativeCommandHarness {
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
const sendMessage = vi.fn().mockResolvedValue(undefined);
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const log = vi.fn();
const bot = {
const sendMessage: AnyAsyncMock = vi.fn(async () => undefined);
const setMyCommands: AnyAsyncMock = vi.fn(async () => undefined);
const log: AnyMock = vi.fn();
const bot: NativeCommandHarness["bot"] = {
api: {
setMyCommands,
sendMessage,
@ -153,7 +169,7 @@ export function createTelegramGroupCommandContext(params?: {
};
}
export function findNotAuthorizedCalls(sendMessage: ReturnType<typeof vi.fn>) {
export function findNotAuthorizedCalls(sendMessage: AnyAsyncMock) {
return sendMessage.mock.calls.filter(
(call) => typeof call[1] === "string" && call[1].includes("not authorized"),
);

View File

@ -5,6 +5,7 @@ export const de: TranslationMap = {
version: "Version",
health: "Status",
ok: "OK",
online: "Online",
offline: "Offline",
connect: "Verbinden",
refresh: "Aktualisieren",

View File

@ -4,6 +4,7 @@ export const en: TranslationMap = {
common: {
health: "Health",
ok: "OK",
online: "Online",
offline: "Offline",
connect: "Connect",
refresh: "Refresh",

View File

@ -5,6 +5,7 @@ export const es: TranslationMap = {
version: "Versión",
health: "Estado",
ok: "Correcto",
online: "En línea",
offline: "Desconectado",
connect: "Conectar",
refresh: "Actualizar",

View File

@ -4,6 +4,7 @@ export const pt_BR: TranslationMap = {
common: {
health: "Saúde",
ok: "OK",
online: "Online",
offline: "Offline",
connect: "Conectar",
refresh: "Atualizar",

View File

@ -4,6 +4,7 @@ export const zh_CN: TranslationMap = {
common: {
health: "健康状况",
ok: "正常",
online: "在线",
offline: "离线",
connect: "连接",
refresh: "刷新",

View File

@ -4,6 +4,7 @@ export const zh_TW: TranslationMap = {
common: {
health: "健康狀況",
ok: "正常",
online: "在線",
offline: "離線",
connect: "連接",
refresh: "刷新",

View File

@ -9,7 +9,9 @@
flex-direction: column;
flex: 1 1 0;
height: 100%;
min-height: 0; /* Allow flex shrinking */
width: 100%;
min-height: 0;
/* Allow flex shrinking */
overflow: hidden;
background: transparent !important;
border: none !important;
@ -24,8 +26,8 @@
gap: 12px;
flex-wrap: nowrap;
flex-shrink: 0;
padding-bottom: 12px;
margin-bottom: 12px;
padding-bottom: 0;
margin-bottom: 0;
background: transparent;
}
@ -49,16 +51,22 @@
/* Chat thread - scrollable middle section, transparent */
.chat-thread {
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
flex: 1 1 0;
/* Grow, shrink, and use 0 base for proper scrolling */
overflow-y: auto;
overflow-x: hidden;
padding: 12px 4px;
margin: 0 -4px;
min-height: 0; /* Allow shrinking for flex scroll behavior */
padding: 0 6px 6px;
margin: 0 0 0 0;
min-height: 0;
/* Allow shrinking for flex scroll behavior */
border-radius: 12px;
background: transparent;
}
.chat-thread-inner > :first-child {
margin-top: 0 !important;
}
/* Focus mode exit button */
.chat-focus-exit {
position: absolute;
@ -146,7 +154,8 @@
display: flex;
flex-direction: column;
gap: 12px;
margin-top: auto; /* Push to bottom of flex container */
margin-top: auto;
/* Push to bottom of flex container */
padding: 12px 4px 4px;
background: linear-gradient(to bottom, transparent, var(--bg) 20%);
z-index: 10;
@ -163,7 +172,8 @@
border: 1px solid var(--border);
width: fit-content;
max-width: 100%;
align-self: flex-start; /* Don't stretch in flex column parent */
align-self: flex-start;
/* Don't stretch in flex column parent */
}
.chat-attachment {

View File

@ -82,6 +82,18 @@
line-height: 1.5;
}
.sidebar-markdown .markdown-inline-image {
display: block;
max-width: 100%;
max-height: 420px;
width: auto;
height: auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--secondary) 70%, transparent);
object-fit: contain;
}
.sidebar-markdown pre {
background: rgba(0, 0, 0, 0.12);
border-radius: 4px;

View File

@ -56,6 +56,19 @@
font-size: 0.9em;
}
.chat-text :where(.markdown-inline-image) {
display: block;
max-width: min(100%, 420px);
max-height: 320px;
width: auto;
height: auto;
margin-top: 0.75em;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--secondary) 70%, transparent);
object-fit: contain;
}
.chat-text :where(:not(pre) > code) {
background: rgba(0, 0, 0, 0.15);
padding: 0.15em 0.4em;

View File

@ -2157,7 +2157,7 @@
}
.chat-thread {
margin-top: 16px;
margin-top: 0;
display: flex;
flex-direction: column;
gap: 12px;
@ -2165,7 +2165,7 @@
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 12px;
padding: 0 12px 16px;
min-width: 0;
border-radius: 0;
border: none;

View File

@ -5,7 +5,7 @@
.shell {
--shell-pad: 16px;
--shell-gap: 16px;
--shell-nav-width: 288px;
--shell-nav-width: 258px;
--shell-nav-rail-width: 78px;
--shell-topbar-height: 52px;
--shell-focus-duration: 200ms;
@ -340,7 +340,7 @@
flex-direction: column;
min-height: 0;
flex: 1;
padding: 14px 14px 12px;
padding: 14px 10px 12px;
border: none;
border-radius: 0;
background: transparent;
@ -503,7 +503,7 @@
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 0 12px;
padding: 0 10px;
min-height: 28px;
background: transparent;
border: none;
@ -522,9 +522,9 @@
}
.nav-section__label-text {
font-size: 11px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@ -555,9 +555,9 @@
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-height: 38px;
padding: 0 12px;
gap: 8px;
min-height: 40px;
padding: 0 9px;
border-radius: 12px;
border: 1px solid transparent;
background: transparent;
@ -595,8 +595,8 @@
}
.nav-item__text {
font-size: 13px;
font-weight: 550;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
@ -763,6 +763,24 @@
margin: 0 auto;
}
.sidebar-version__status {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
flex-shrink: 0;
margin-left: auto;
}
.sidebar-version__status.sidebar-connection-status--online {
background: var(--ok);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 14%, transparent);
}
.sidebar-version__status.sidebar-connection-status--offline {
background: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 14%, transparent);
}
.sidebar--collapsed .sidebar-shell__footer {
padding: 8px 0 2px;
}
@ -780,6 +798,10 @@
border-radius: 16px;
}
.sidebar--collapsed .sidebar-version__status {
margin-left: 0;
}
.shell--nav-collapsed .shell-nav {
width: var(--shell-nav-rail-width);
min-width: var(--shell-nav-rail-width);
@ -844,7 +866,7 @@
.content--chat {
display: flex;
flex-direction: column;
gap: 24px;
gap: 2px;
overflow: hidden;
padding-bottom: 0;
}
@ -905,6 +927,7 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 0;
}
.content--chat .content-header > div:first-child {

View File

@ -323,6 +323,10 @@
gap: 8px;
}
.content--chat {
gap: 2px;
}
.content--chat .content-header > div:first-child,
.content--chat .page-meta,
.content--chat .chat-controls {
@ -417,8 +421,8 @@
}
.chat-thread {
margin-top: 8px;
padding: 12px 8px;
margin-top: 0;
padding: 0 8px 12px;
}
.chat-msg {

View File

@ -743,6 +743,23 @@ export function renderTopbarThemeModeToggle(state: AppViewState) {
`;
}
export function renderSidebarConnectionStatus(state: AppViewState) {
const label = state.connected ? t("common.online") : t("common.offline");
const toneClass = state.connected
? "sidebar-connection-status--online"
: "sidebar-connection-status--offline";
return html`
<span
class="sidebar-version__status ${toneClass}"
role="img"
aria-live="polite"
aria-label="Gateway status: ${label}"
title="Gateway status: ${label}"
></span>
`;
}
export function renderThemeToggle(state: AppViewState) {
const setOpen = (orb: HTMLElement, nextOpen: boolean) => {
orb.classList.toggle("theme-orb--open", nextOpen);

View File

@ -10,6 +10,7 @@ import {
renderChatControls,
renderChatSessionSelect,
renderTab,
renderSidebarConnectionStatus,
renderTopbarThemeModeToggle,
} from "./app-render.helpers.ts";
import type { AppViewState } from "./app-view-state.ts";
@ -437,9 +438,7 @@ export function renderApp(state: AppViewState) {
<span class="topbar-search__label">${t("common.search")}</span>
<kbd class="topbar-search__kbd">K</kbd>
</button>
<div class="topbar-status">
${renderTopbarThemeModeToggle(state)}
</div>
<div class="topbar-status">${renderTopbarThemeModeToggle(state)}</div>
</div>
</div>
</header>
@ -543,9 +542,10 @@ export function renderApp(state: AppViewState) {
? html`
<span class="sidebar-version__label">${t("common.version")}</span>
<span class="sidebar-version__text">v${version}</span>
${renderSidebarConnectionStatus(state)}
`
: html`
<span class="sidebar-version__dot"></span>
${renderSidebarConnectionStatus(state)}
`
}
</div>
@ -924,9 +924,21 @@ export function renderApp(state: AppViewState) {
state.agentsList?.defaultId ??
state.agentsList?.agents?.[0]?.id ??
null;
if (state.agentsPanel === "files" && refreshedAgentId) {
void loadAgentFiles(state, refreshedAgentId);
}
if (state.agentsPanel === "skills" && refreshedAgentId) {
void loadAgentSkills(state, refreshedAgentId);
}
if (state.agentsPanel === "tools" && refreshedAgentId) {
void loadToolsCatalog(state, refreshedAgentId);
}
if (state.agentsPanel === "channels") {
void loadChannels(state, false);
}
if (state.agentsPanel === "cron") {
void state.loadCron();
}
},
onSelectAgent: (agentId) => {
if (state.agentsSelectedId === agentId) {

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseLogLine } from "./logs.ts";
describe("parseLogLine", () => {
it("prefers the human-readable message field when structured data is stored in slot 1", () => {
const line = JSON.stringify({
0: '{"subsystem":"gateway/ws"}',
1: {
cause: "unauthorized",
authReason: "password_missing",
},
2: "closed before connect conn=abc code=4008 reason=connect failed",
_meta: {
date: "2026-03-13T19:07:12.128Z",
logLevelName: "WARN",
},
time: "2026-03-13T14:07:12.138-05:00",
});
expect(parseLogLine(line)).toEqual(
expect.objectContaining({
level: "warn",
subsystem: "gateway/ws",
message: "closed before connect conn=abc code=4008 reason=connect failed",
}),
);
});
});

View File

@ -77,6 +77,8 @@ export function parseLogLine(line: string): LogEntry {
let message: string | null = null;
if (typeof obj["1"] === "string") {
message = obj["1"];
} else if (typeof obj["2"] === "string") {
message = obj["2"];
} else if (!contextObj && typeof obj["0"] === "string") {
message = obj["0"];
} else if (typeof obj.message === "string") {

View File

@ -39,6 +39,7 @@ describe("toSanitizedMarkdownHtml", () => {
it("preserves base64 data URI images (#15437)", () => {
const html = toSanitizedMarkdownHtml("![Chart](data:image/png;base64,iVBORw0KGgo=)");
expect(html).toContain("<img");
expect(html).toContain('class="markdown-inline-image"');
expect(html).toContain("data:image/png;base64,");
});

View File

@ -165,7 +165,7 @@ htmlEscapeRenderer.image = (token: { href?: string | null; text?: string | null
if (!INLINE_DATA_IMAGE_RE.test(href)) {
return escapeHtml(label);
}
return `<img src="${escapeHtml(href)}" alt="${escapeHtml(label)}">`;
return `<img class="markdown-inline-image" src="${escapeHtml(href)}" alt="${escapeHtml(label)}">`;
};
function normalizeMarkdownImageLabel(text?: string | null): string {

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { mountApp, registerAppMountHooks } from "./test-helpers/app-mount.ts";
registerAppMountHooks();
describe("sidebar connection status", () => {
it("shows a single online status dot next to the version", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.hello = {
ok: true,
server: { version: "1.2.3" },
} as never;
app.requestUpdate();
await app.updateComplete;
const version = app.querySelector<HTMLElement>(".sidebar-version");
const statusDot = app.querySelector<HTMLElement>(".sidebar-version__status");
expect(version).not.toBeNull();
expect(statusDot).not.toBeNull();
expect(statusDot?.getAttribute("aria-label")).toContain("Online");
});
});

View File

@ -0,0 +1,174 @@
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderAgents, type AgentsProps } from "./agents.ts";
function createSkill() {
return {
name: "Repo Skill",
description: "Skill description",
source: "workspace",
filePath: "/tmp/skill",
baseDir: "/tmp",
skillKey: "repo-skill",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: {
bins: [],
env: [],
config: [],
os: [],
},
missing: {
bins: [],
env: [],
config: [],
os: [],
},
configChecks: [],
install: [],
};
}
function createProps(overrides: Partial<AgentsProps> = {}): AgentsProps {
return {
basePath: "",
loading: false,
error: null,
agentsList: {
defaultId: "alpha",
mainKey: "main",
scope: "workspace",
agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never],
},
selectedAgentId: "beta",
activePanel: "overview",
config: {
form: null,
loading: false,
saving: false,
dirty: false,
},
channels: {
snapshot: null,
loading: false,
error: null,
lastSuccess: null,
},
cron: {
status: null,
jobs: [],
loading: false,
error: null,
},
agentFiles: {
list: null,
loading: false,
error: null,
active: null,
contents: {},
drafts: {},
saving: false,
},
agentIdentityLoading: false,
agentIdentityError: null,
agentIdentityById: {},
agentSkills: {
report: null,
loading: false,
error: null,
agentId: null,
filter: "",
},
toolsCatalog: {
loading: false,
error: null,
result: null,
},
onRefresh: () => undefined,
onSelectAgent: () => undefined,
onSelectPanel: () => undefined,
onLoadFiles: () => undefined,
onSelectFile: () => undefined,
onFileDraftChange: () => undefined,
onFileReset: () => undefined,
onFileSave: () => undefined,
onToolsProfileChange: () => undefined,
onToolsOverridesChange: () => undefined,
onConfigReload: () => undefined,
onConfigSave: () => undefined,
onModelChange: () => undefined,
onModelFallbacksChange: () => undefined,
onChannelsRefresh: () => undefined,
onCronRefresh: () => undefined,
onCronRunNow: () => undefined,
onSkillsFilterChange: () => undefined,
onSkillsRefresh: () => undefined,
onAgentSkillToggle: () => undefined,
onAgentSkillsClear: () => undefined,
onAgentSkillsDisableAll: () => undefined,
onSetDefault: () => undefined,
...overrides,
};
}
describe("renderAgents", () => {
it("shows the skills count only for the selected agent's report", async () => {
const container = document.createElement("div");
render(
renderAgents(
createProps({
agentSkills: {
report: {
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [createSkill()],
},
loading: false,
error: null,
agentId: "alpha",
filter: "",
},
}),
),
container,
);
await Promise.resolve();
const skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
(button) => button.textContent?.includes("Skills"),
);
expect(skillsTab?.textContent?.trim()).toBe("Skills");
});
it("shows the selected agent's skills count when the report matches", async () => {
const container = document.createElement("div");
render(
renderAgents(
createProps({
agentSkills: {
report: {
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [createSkill()],
},
loading: false,
error: null,
agentId: "beta",
filter: "",
},
}),
),
container,
);
await Promise.resolve();
const skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
(button) => button.textContent?.includes("Skills"),
);
expect(skillsTab?.textContent?.trim()).toContain("1");
});
});

View File

@ -113,6 +113,10 @@ export function renderAgents(props: AgentsProps) {
const selectedAgent = selectedId
? (agents.find((agent) => agent.id === selectedId) ?? null)
: null;
const selectedSkillCount =
selectedId && props.agentSkills.agentId === selectedId
? (props.agentSkills.report?.skills?.length ?? null)
: null;
const channelEntryCount = props.channels.snapshot
? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length
@ -122,7 +126,7 @@ export function renderAgents(props: AgentsProps) {
: null;
const tabCounts: Record<string, number | null> = {
files: props.agentFiles.list?.files?.length ?? null,
skills: props.agentSkills.report?.skills?.length ?? null,
skills: selectedSkillCount,
channels: channelEntryCount,
cron: cronJobCount || null,
};