mirror of https://github.com/openclaw/openclaw.git
[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:
parent
52900b48ad
commit
158d970e2b
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const de: TranslationMap = {
|
|||
version: "Version",
|
||||
health: "Status",
|
||||
ok: "OK",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
connect: "Verbinden",
|
||||
refresh: "Aktualisieren",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const en: TranslationMap = {
|
|||
common: {
|
||||
health: "Health",
|
||||
ok: "OK",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
connect: "Connect",
|
||||
refresh: "Refresh",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const pt_BR: TranslationMap = {
|
|||
common: {
|
||||
health: "Saúde",
|
||||
ok: "OK",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
connect: "Conectar",
|
||||
refresh: "Atualizar",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const zh_CN: TranslationMap = {
|
|||
common: {
|
||||
health: "健康状况",
|
||||
ok: "正常",
|
||||
online: "在线",
|
||||
offline: "离线",
|
||||
connect: "连接",
|
||||
refresh: "刷新",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const zh_TW: TranslationMap = {
|
|||
common: {
|
||||
health: "健康狀況",
|
||||
ok: "正常",
|
||||
online: "在線",
|
||||
offline: "離線",
|
||||
connect: "連接",
|
||||
refresh: "刷新",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ describe("toSanitizedMarkdownHtml", () => {
|
|||
it("preserves base64 data URI images (#15437)", () => {
|
||||
const html = toSanitizedMarkdownHtml("");
|
||||
expect(html).toContain("<img");
|
||||
expect(html).toContain('class="markdown-inline-image"');
|
||||
expect(html).toContain("data:image/png;base64,");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue