diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 7503e21ae5e..6e86ad0d23a 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -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"); diff --git a/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts b/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts index 8e91db61664..6e2a93d5633 100644 --- a/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts +++ b/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts @@ -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; + exit: MockFn; }; -export const service = { +type LifecycleServiceHarness = GatewayService & { + install: MockFn; + uninstall: MockFn; + stop: MockFn; + isLoaded: MockFn; + readCommand: MockFn; + readRuntime: MockFn; + restart: MockFn; +}; + +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" }); } diff --git a/src/daemon/test-helpers/schtasks-base-mocks.ts b/src/daemon/test-helpers/schtasks-base-mocks.ts index 48933ecdd1c..e3f0f950482 100644 --- a/src/daemon/test-helpers/schtasks-base-mocks.ts +++ b/src/daemon/test-helpers/schtasks-base-mocks.ts @@ -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), })); diff --git a/src/daemon/test-helpers/schtasks-fixtures.ts b/src/daemon/test-helpers/schtasks-fixtures.ts index a89d7a0eb2e..4762b7543eb 100644 --- a/src/daemon/test-helpers/schtasks-fixtures.ts +++ b/src/daemon/test-helpers/schtasks-fixtures.ts @@ -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> = vi.fn(); +export const killProcessTree: MockFn = vi.fn(); export async function withWindowsEnv( prefix: string, diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index 391abee8dda..ac144265753 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -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", diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts index 0328c7c87f2..2322a106c9d 100644 --- a/src/shared/net/ip.test.ts +++ b/src/shared/net/ip.test.ts @@ -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"); } diff --git a/src/telegram/bot-native-commands.test-helpers.ts b/src/telegram/bot-native-commands.test-helpers.ts index 02f1028becf..eef028c8315 100644 --- a/src/telegram/bot-native-commands.test-helpers.ts +++ b/src/telegram/bot-native-commands.test-helpers.ts @@ -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[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>; +type NativeCommandHarness = { + handlers: Record Promise>; + sendMessage: AnyAsyncMock; + setMyCommands: AnyAsyncMock; + log: AnyMock; + bot: { + api: { + setMyCommands: AnyAsyncMock; + sendMessage: AnyAsyncMock; + }; + command: (name: string, handler: (ctx: unknown) => Promise) => void; + }; +}; const pluginCommandMocks = vi.hoisted(() => ({ getPluginCommandSpecs: vi.fn(() => []), @@ -86,12 +102,12 @@ export function createNativeCommandsHarness(params?: { nativeEnabled?: boolean; groupConfig?: Record; resolveGroupPolicy?: () => ChannelGroupPolicy; -}) { +}): NativeCommandHarness { const handlers: Record Promise> = {}; - 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) { +export function findNotAuthorizedCalls(sendMessage: AnyAsyncMock) { return sendMessage.mock.calls.filter( (call) => typeof call[1] === "string" && call[1].includes("not authorized"), ); diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index f45ffc3f4c0..7fd638766e7 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -5,6 +5,7 @@ export const de: TranslationMap = { version: "Version", health: "Status", ok: "OK", + online: "Online", offline: "Offline", connect: "Verbinden", refresh: "Aktualisieren", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index df80f2d7c78..370fec9c660 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4,6 +4,7 @@ export const en: TranslationMap = { common: { health: "Health", ok: "OK", + online: "Online", offline: "Offline", connect: "Connect", refresh: "Refresh", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index a96ee7ad2d7..091cd2ca937 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -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", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index aaaa26c253e..cb9ba1ba283 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -4,6 +4,7 @@ export const pt_BR: TranslationMap = { common: { health: "Saúde", ok: "OK", + online: "Online", offline: "Offline", connect: "Conectar", refresh: "Atualizar", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index ac321857253..b039be16f41 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -4,6 +4,7 @@ export const zh_CN: TranslationMap = { common: { health: "健康状况", ok: "正常", + online: "在线", offline: "离线", connect: "连接", refresh: "刷新", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 56a80c61d92..a6a616209e7 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -4,6 +4,7 @@ export const zh_TW: TranslationMap = { common: { health: "健康狀況", ok: "正常", + online: "在線", offline: "離線", connect: "連接", refresh: "刷新", diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 536acddd29e..f0e0154329d 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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 { diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 934e285d95b..de6010f3ed7 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -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; diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 56224fabf9e..dd76434e041 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -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; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index d1dc29ca04e..b2806f3208f 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 12f22aef21d..6e19806bb32 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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 { diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 3c929435a7b..036e6a7c588 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -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 { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 0ebafc22d4d..eaf94616032 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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` + + `; +} + export function renderThemeToggle(state: AppViewState) { const setOpen = (orb: HTMLElement, nextOpen: boolean) => { orb.classList.toggle("theme-orb--open", nextOpen); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index b1ddf9e323c..643edfca521 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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) { ${t("common.search")} ⌘K -
- ${renderTopbarThemeModeToggle(state)} -
+
${renderTopbarThemeModeToggle(state)}
@@ -543,9 +542,10 @@ export function renderApp(state: AppViewState) { ? html` ${t("common.version")} v${version} + ${renderSidebarConnectionStatus(state)} ` : html` - + ${renderSidebarConnectionStatus(state)} ` } @@ -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) { diff --git a/ui/src/ui/controllers/logs.test.ts b/ui/src/ui/controllers/logs.test.ts new file mode 100644 index 00000000000..5d1a830de7a --- /dev/null +++ b/ui/src/ui/controllers/logs.test.ts @@ -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", + }), + ); + }); +}); diff --git a/ui/src/ui/controllers/logs.ts b/ui/src/ui/controllers/logs.ts index d2e919c6210..90c2edcf00a 100644 --- a/ui/src/ui/controllers/logs.ts +++ b/ui/src/ui/controllers/logs.ts @@ -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") { diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index 279cb2b53fb..90bce3b65f5 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -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("`; + return `${escapeHtml(label)}`; }; function normalizeMarkdownImageLabel(text?: string | null): string { diff --git a/ui/src/ui/sidebar-status.browser.test.ts b/ui/src/ui/sidebar-status.browser.test.ts new file mode 100644 index 00000000000..315501c36a2 --- /dev/null +++ b/ui/src/ui/sidebar-status.browser.test.ts @@ -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(".sidebar-version"); + const statusDot = app.querySelector(".sidebar-version__status"); + expect(version).not.toBeNull(); + expect(statusDot).not.toBeNull(); + expect(statusDot?.getAttribute("aria-label")).toContain("Online"); + }); +}); diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts new file mode 100644 index 00000000000..f763877937a --- /dev/null +++ b/ui/src/ui/views/agents.test.ts @@ -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 { + 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(".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(".agent-tab")).find( + (button) => button.textContent?.includes("Skills"), + ); + + expect(skillsTab?.textContent?.trim()).toContain("1"); + }); +}); diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 63917b0f732..4e8b9a065ba 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -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 = { files: props.agentFiles.list?.files?.length ?? null, - skills: props.agentSkills.report?.skills?.length ?? null, + skills: selectedSkillCount, channels: channelEntryCount, cron: cronJobCount || null, };