feat(ui): add composer capability menu (#115800)

* feat(ui): composer capability menu with per-session overrides

* refactor(ui): keep chat render within lint ceiling

* fix(ui): preserve loaded capability overrides

* fix(ui): remove stale capability menu field

* fix(ui): gate capability menu on runtime-config readiness
This commit is contained in:
Peter Steinberger
2026-07-29 06:48:08 -04:00
committed by GitHub
parent 77e9ee092f
commit f27899181e
20 changed files with 1600 additions and 25 deletions
+1
View File
@@ -565,6 +565,7 @@ export type GatewaySessionRow = {
thinkingOptions?: string[];
thinkingDefault?: string;
fastMode?: FastMode;
toolOverrides?: import("../lib/sessions/patch.js").SessionToolOverrides;
effectiveFastMode?: FastMode;
effectiveFastModeSource?: FastModeSource;
fastAutoOnSeconds?: number;
@@ -0,0 +1,398 @@
// Control UI E2E coverage proves the composer capability menu against a mocked Gateway.
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
type MockGatewayControls,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
let server: ControlUiE2eServer;
let browser: Browser;
function skill(
name: string,
options: { blocked?: boolean; disabled?: boolean; missingDeps?: boolean } = {},
) {
const missingBins = options.missingDeps ? ["missing-cli"] : [];
return {
name,
description: `${name} skill`,
source: "test",
filePath: `/tmp/openclaw-e2e/skills/${name}/SKILL.md`,
baseDir: `/tmp/openclaw-e2e/skills/${name}`,
skillKey: name.toLowerCase(),
always: false,
disabled: options.disabled ?? false,
blockedByAllowlist: options.blocked ?? false,
eligible: missingBins.length === 0,
requirements: { anyBins: [], bins: missingBins, env: [], config: [], os: [] },
missing: { anyBins: [], bins: missingBins, env: [], config: [], os: [] },
configChecks: [],
install: [],
};
}
function sessionsList(toolOverrides?: Record<string, unknown>) {
return {
count: 1,
defaults: { contextTokens: 200_000, model: "gpt-5.5", modelProvider: "openai" },
path: "",
sessions: [
{
key: "main",
kind: "direct",
model: "gpt-5.5",
modelProvider: "openai",
status: "done",
updatedAt: Date.now(),
...(toolOverrides ? { toolOverrides } : {}),
},
],
ts: Date.now(),
};
}
function configResponse(servers: Record<string, unknown>, webSearch = true) {
const config = {
mcp: { servers },
plugins: webSearch
? {
entries: { brave: { enabled: true, config: { webSearch: { apiKey: "redacted" } } } },
}
: undefined,
tools: { web: { search: webSearch ? { provider: "brave" } : { enabled: false } } },
};
return {
raw: JSON.stringify(config),
hash: "capability-menu-config",
sourceConfig: config,
runtimeConfig: config,
config,
};
}
async function latestToolOverrides(gateway: MockGatewayControls) {
const requests = await gateway.getRequests("sessions.patch");
return (requests.at(-1)?.params as { toolOverrides?: unknown } | undefined)?.toolOverrides;
}
async function openMenu(page: Page) {
const composer = page.locator(".agent-chat__input");
await composer.getByRole("button", { name: "Add attachment" }).click();
await expect
.poll(() => composer.getByRole("menuitem", { name: "Skills" }).isVisible())
.toBe(true);
return composer;
}
describeControlUiE2e("Control UI composer capability menu", () => {
beforeAll(async () => {
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
try {
server = await startControlUiE2eServer();
} catch (error) {
await browser.close();
throw error;
}
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("renders the root stack, proxies attachments, patches sparse overrides, and clears the pill", async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"config.get": configResponse({
github: { url: "https://mcp.example.test", enabled: true },
notion: { command: "notion-mcp", enabled: false },
}),
"sessions.list": sessionsList({
mcpServers: { github: false },
mcpToolsDeny: { notion: ["delete_page"] },
skills: { docs: false },
webSearch: false,
}),
"skills.status": {
workspaceDir: "/tmp/openclaw-e2e/workspace",
managedSkillsDir: "/tmp/openclaw-e2e/skills",
skills: [
skill("Docs"),
skill("Deploy", { disabled: true }),
skill("Broken", { missingDeps: true }),
skill("Private", { blocked: true }),
],
},
},
});
try {
await page.goto(`${server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
const pill = page.locator(".agent-chat__session-overrides-pill");
await expect
.poll(async () => (await pill.textContent())?.replace(/\s+/g, " ").trim())
.toBe("4 session overrides");
let composer = await openMenu(page);
const dropdown = composer.locator("wa-dropdown.agent-chat__capability-menu");
await expect.poll(() => dropdown.getAttribute("data-view")).toBe("root");
await expect
.poll(() => dropdown.getByRole("menuitem").allTextContents())
.toEqual(
expect.arrayContaining([
expect.stringContaining("Take photo"),
expect.stringContaining("Photo"),
expect.stringContaining("File"),
expect.stringContaining("Skills"),
expect.stringContaining("Connectors"),
expect.stringContaining("Manage plugins"),
]),
);
await expect
.poll(() => dropdown.getByRole("menuitemcheckbox", { name: "Web search" }).isVisible())
.toBe(true);
const skillsRoot = dropdown.getByRole("menuitem", { name: /^Skills/ });
await skillsRoot.focus();
await skillsRoot.evaluate((item) => {
item
.closest("wa-dropdown")
?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } }));
});
await expect.poll(() => dropdown.getAttribute("data-view")).toBe("skills");
await expect
.poll(() => dropdown.locator("wa-dropdown-item:focus").textContent())
.toContain("Back");
await dropdown.locator("wa-dropdown-item:focus").evaluate((item) => {
item
.closest("wa-dropdown")
?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } }));
});
await expect.poll(() => dropdown.getAttribute("data-view")).toBe("root");
await expect
.poll(() => dropdown.locator("wa-dropdown-item:focus").textContent())
.toContain("Take photo");
await page.evaluate(() => {
const input = document.querySelector<HTMLInputElement>(".agent-chat__file-input");
if (!input) {
throw new Error("file input missing");
}
input.click = () => {
input.dataset.proxied = "true";
};
});
await dropdown.getByRole("menuitem", { name: "File", exact: true }).click();
await expect
.poll(() => page.locator(".agent-chat__file-input").getAttribute("data-proxied"))
.toBe("true");
await pill.getByRole("button", { name: "Clear session overrides" }).click();
await expect.poll(() => latestToolOverrides(gateway)).toEqual(null);
await expect.poll(() => pill.count()).toBe(0);
composer = await openMenu(page);
const menu = composer.locator("wa-dropdown.agent-chat__capability-menu");
await menu.getByRole("menuitem", { name: /^Skills/ }).click();
await expect.poll(() => menu.getAttribute("data-view")).toBe("skills");
const docs = menu.getByRole("menuitem", { name: /^Docs/ });
const broken = menu.getByRole("menuitem", { name: /Broken.*deps missing/ });
const blocked = menu.getByRole("menuitem", {
name: /Private.*not available for this agent/,
});
await expect.poll(() => broken.isDisabled()).toBe(true);
await expect.poll(() => blocked.isDisabled()).toBe(true);
await docs.click();
await expect.poll(() => latestToolOverrides(gateway)).toEqual({ skills: { docs: false } });
await docs.click();
await expect.poll(() => latestToolOverrides(gateway)).toEqual({});
await menu.getByRole("menuitem", { name: "Back" }).click();
await menu.getByRole("menuitem", { name: /^Connectors/ }).click();
await expect.poll(() => menu.getAttribute("data-view")).toBe("connectors");
const github = menu.getByRole("menuitem", { name: /^github/ });
await github.click();
await expect
.poll(() => latestToolOverrides(gateway))
.toEqual({
mcpServers: { github: false },
});
await github.click();
await expect.poll(() => latestToolOverrides(gateway)).toEqual({});
await expect
.poll(() => menu.getByRole("menuitem", { name: "Browse connectors" }).isDisabled())
.toBe(false);
await expect
.poll(() => menu.getByRole("menuitem", { name: /Add MCP server/ }).count())
.toBe(0);
await menu.getByRole("menuitem", { name: "Back" }).click();
const webSearch = menu.getByRole("menuitemcheckbox", { name: "Web search" });
await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("true");
await webSearch.click();
await expect.poll(() => latestToolOverrides(gateway)).toEqual({ webSearch: false });
await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("false");
await webSearch.click();
await expect.poll(() => latestToolOverrides(gateway)).toEqual({});
await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("true");
const themeBackgrounds: string[] = [];
for (const mode of ["dark", "light"] as const) {
themeBackgrounds.push(
await menu.evaluate((node, nextMode) => {
document.documentElement.dataset.themeMode = nextMode;
const surface = node.shadowRoot?.querySelector('[part="menu"]');
return surface ? getComputedStyle(surface).backgroundColor : "";
}, mode),
);
}
expect(themeBackgrounds[0]).not.toBe("");
expect(themeBackgrounds[1]).not.toBe("");
expect(themeBackgrounds[0]).not.toBe(themeBackgrounds[1]);
} finally {
await context.close();
}
});
it("disables capability mutations and admin rows for a read-only operator", async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
await installMockGateway(page, {
operatorScopes: ["operator.read"],
methodResponses: {
"config.get": configResponse({ github: { url: "https://mcp.example.test" } }),
"sessions.list": sessionsList({ webSearch: false }),
"skills.status": {
workspaceDir: "/tmp/openclaw-e2e/workspace",
managedSkillsDir: "/tmp/openclaw-e2e/skills",
skills: [skill("Docs")],
},
},
});
try {
await page.goto(`${server.baseUrl}chat`);
const composer = await openMenu(page);
const menu = composer.locator("wa-dropdown.agent-chat__capability-menu");
const clear = composer.getByRole("button", { name: "Clear session overrides" });
await expect.poll(() => clear.isDisabled()).toBe(true);
await expect.poll(() => clear.getAttribute("title")).toContain("Write access");
await expect
.poll(() => menu.getByRole("menuitemcheckbox", { name: "Web search" }).isDisabled())
.toBe(true);
await menu.getByRole("menuitem", { name: /^Skills/ }).click();
const docs = menu.getByRole("menuitem", { name: /^Docs/ });
await expect.poll(() => docs.isDisabled()).toBe(true);
await expect.poll(() => docs.getAttribute("title")).toContain("Write access");
await menu.getByRole("menuitem", { name: "Back" }).click();
await menu.getByRole("menuitem", { name: /^Connectors/ }).click();
await expect
.poll(() => menu.getByRole("menuitem", { name: /^github/ }).isDisabled())
.toBe(true);
const browse = menu.getByRole("menuitem", { name: "Browse connectors" });
await expect.poll(() => browse.isDisabled()).toBe(true);
await expect.poll(() => browse.getAttribute("title")).toContain("Admin access");
} finally {
await context.close();
}
});
it("blocks capability mutations until the session row and runtime config load", async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.list", "config.get"],
methodResponses: {
"skills.status": {
workspaceDir: "/tmp/openclaw-e2e/workspace",
managedSkillsDir: "/tmp/openclaw-e2e/skills",
skills: [skill("Docs")],
},
},
});
try {
await page.goto(`${server.baseUrl}chat`);
await Promise.all([
gateway.waitForRequest("sessions.list"),
gateway.waitForRequest("config.get"),
]);
const composer = await openMenu(page);
const menu = composer.locator("wa-dropdown.agent-chat__capability-menu");
const webSearch = menu.getByRole("menuitemcheckbox", { name: "Web search" });
await expect.poll(() => webSearch.isDisabled()).toBe(true);
await expect.poll(() => webSearch.getAttribute("title")).toBe("Loading…");
await webSearch.evaluate((item) => {
item
.closest("wa-dropdown")
?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } }));
});
expect(await gateway.getRequests("sessions.patch")).toHaveLength(0);
await gateway.resolveDeferred(
"sessions.list",
sessionsList({
mcpToolsDeny: { notion: ["delete_page"] },
webSearch: true,
}),
);
await expect.poll(() => webSearch.isDisabled()).toBe(true);
await expect.poll(() => webSearch.getAttribute("title")).toBe("Loading…");
expect(await gateway.getRequests("sessions.patch")).toHaveLength(0);
await gateway.resolveDeferred("config.get", configResponse({}, false));
await expect.poll(() => webSearch.isDisabled()).toBe(false);
await webSearch.click();
await expect
.poll(() => latestToolOverrides(gateway))
.toEqual({
mcpToolsDeny: { notion: ["delete_page"] },
});
} finally {
await context.close();
}
});
it("shows empty skills and connector states", async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"config.get": configResponse({}, false),
"sessions.list": sessionsList(),
"skills.status": {
workspaceDir: "/tmp/openclaw-e2e/workspace",
managedSkillsDir: "/tmp/openclaw-e2e/skills",
skills: [],
},
},
});
try {
await page.goto(`${server.baseUrl}chat`);
const composer = await openMenu(page);
const menu = composer.locator("wa-dropdown.agent-chat__capability-menu");
await expect
.poll(() => menu.getByRole("menuitemcheckbox", { name: "Web search" }).count())
.toBe(1);
await menu.getByRole("menuitem", { name: /^Skills/ }).click();
await expect.poll(() => menu.getByText("No skills available.").isVisible()).toBe(true);
await menu.getByRole("menuitem", { name: "Back" }).click();
await menu.getByRole("menuitem", { name: /^Connectors/ }).click();
await expect.poll(() => menu.getByText("No MCP servers configured.").isVisible()).toBe(true);
} finally {
await context.close();
}
});
});
+28
View File
@@ -4994,6 +4994,34 @@ export const en: TranslationMap = {
attachPhoto: "Photo",
attachFile: "Attach file",
attachFileOption: "File",
menu: {
back: "Back",
skills: "Skills",
connectors: "Connectors",
webSearch: "Web search",
managePlugins: "Manage plugins",
manageSkills: "Manage skills",
browseConnectors: "Browse connectors",
addMcpServer: "Add MCP server…",
toolAccess: "Tool access",
enabledCount: "{count} on",
loadingSkills: "Loading skills…",
skillsLoadFailed: "Couldnt load skills.",
noSkills: "No skills available.",
noConnectors: "No MCP servers configured.",
depsMissing: "deps missing",
skillBlocked: "not available for this agent",
sessionTag: "session",
offlineBlocked: "Connect to the gateway to change session capabilities.",
readOnlyBlocked: "Write access is required to change session capabilities.",
adminBlocked: "Admin access is required to manage connectors.",
savingBlocked: "Wait for the current session capability change to finish.",
},
overrides: {
count: "{count} session overrides",
countOne: "{count} session override",
clear: "Clear session overrides",
},
contextUsage: {
title: "Context usage details",
open: "Open context usage details",
+8
View File
@@ -1,5 +1,12 @@
import type { FastMode, SessionsPatchResult } from "../../api/types.ts";
export type SessionToolOverrides = {
mcpServers?: Record<string, boolean>;
mcpToolsDeny?: Record<string, string[]>;
skills?: Record<string, boolean>;
webSearch?: boolean;
};
export type SessionPatch = {
label?: string | null;
category?: string | null;
@@ -10,6 +17,7 @@ export type SessionPatch = {
fastMode?: FastMode | null;
verboseLevel?: string | null;
reasoningLevel?: string | null;
toolOverrides?: SessionToolOverrides | null;
archived?: boolean;
pinned?: boolean;
unread?: boolean;
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
countSessionToolOverrides,
nextBooleanToolOverrides,
nextWebSearchToolOverrides,
resolveToolOverrideState,
sessionToolOverrideNames,
} from "./tool-overrides.ts";
describe("session tool overrides", () => {
it("resolves sparse booleans against their base state", () => {
expect(resolveToolOverrideState(true, undefined)).toBe(true);
expect(resolveToolOverrideState(false, undefined)).toBe(false);
expect(resolveToolOverrideState(true, false)).toBe(false);
expect(resolveToolOverrideState(false, true)).toBe(true);
});
it("adds and removes named overrides without disturbing sibling groups", () => {
const current = {
mcpServers: { github: false },
mcpToolsDeny: { notion: ["delete_page"] },
webSearch: false,
};
expect(nextBooleanToolOverrides(current, "skills", "release", false, true)).toEqual({
...current,
skills: { release: false },
});
expect(nextBooleanToolOverrides(current, "mcpServers", "github", true, true)).toEqual({
mcpToolsDeny: { notion: ["delete_page"] },
webSearch: false,
});
expect(current).toEqual({
mcpServers: { github: false },
mcpToolsDeny: { notion: ["delete_page"] },
webSearch: false,
});
});
it("restores default-on web search by removing the key", () => {
const off = nextWebSearchToolOverrides({ skills: { docs: true } }, false);
expect(off).toEqual({ skills: { docs: true }, webSearch: false });
expect(nextWebSearchToolOverrides(off, true)).toEqual({ skills: { docs: true } });
expect(nextWebSearchToolOverrides({}, true, false)).toEqual({ webSearch: true });
expect(nextWebSearchToolOverrides({ webSearch: true }, false, false)).toEqual({});
});
it("counts override categories and returns deterministic tooltip names", () => {
const overrides = {
mcpServers: { zeta: false, alpha: true },
mcpToolsDeny: { tools: ["danger"] },
skills: { beta: false },
webSearch: false,
};
expect(countSessionToolOverrides(overrides)).toBe(5);
expect(sessionToolOverrideNames(overrides, "Web search")).toEqual([
"alpha",
"beta",
"tools",
"Web search",
"zeta",
]);
});
});
+81
View File
@@ -0,0 +1,81 @@
import type { SessionToolOverrides } from "./patch.ts";
type BooleanOverrideGroup = "mcpServers" | "skills";
function copyOverrides(overrides: SessionToolOverrides | null | undefined): SessionToolOverrides {
return {
...(overrides?.mcpServers ? { mcpServers: { ...overrides.mcpServers } } : {}),
...(overrides?.mcpToolsDeny
? {
mcpToolsDeny: Object.fromEntries(
Object.entries(overrides.mcpToolsDeny).map(([name, tools]) => [name, [...tools]]),
),
}
: {}),
...(overrides?.skills ? { skills: { ...overrides.skills } } : {}),
...(overrides?.webSearch !== undefined ? { webSearch: overrides.webSearch } : {}),
};
}
export function resolveToolOverrideState(baseEnabled: boolean, override: boolean | undefined) {
return override ?? baseEnabled;
}
export function nextBooleanToolOverrides(
current: SessionToolOverrides | null | undefined,
group: BooleanOverrideGroup,
name: string,
nextEnabled: boolean,
baseEnabled: boolean,
): SessionToolOverrides {
const next = copyOverrides(current);
const values = { ...next[group] };
if (nextEnabled === baseEnabled) {
delete values[name];
} else {
values[name] = nextEnabled;
}
if (Object.keys(values).length === 0) {
delete next[group];
} else {
next[group] = values;
}
return next;
}
export function nextWebSearchToolOverrides(
current: SessionToolOverrides | null | undefined,
nextEnabled: boolean,
baseEnabled = true,
): SessionToolOverrides {
const next = copyOverrides(current);
if (nextEnabled === baseEnabled) {
delete next.webSearch;
} else {
next.webSearch = nextEnabled;
}
return next;
}
export function countSessionToolOverrides(
overrides: SessionToolOverrides | null | undefined,
): number {
return (
Object.keys(overrides?.mcpServers ?? {}).length +
Object.keys(overrides?.skills ?? {}).length +
Object.keys(overrides?.mcpToolsDeny ?? {}).length +
(overrides?.webSearch !== undefined ? 1 : 0)
);
}
export function sessionToolOverrideNames(
overrides: SessionToolOverrides | null | undefined,
webSearchLabel: string,
): string[] {
return [
...Object.keys(overrides?.mcpServers ?? {}),
...Object.keys(overrides?.skills ?? {}),
...Object.keys(overrides?.mcpToolsDeny ?? {}),
...(overrides?.webSearch !== undefined ? [webSearchLabel] : []),
].toSorted((left, right) => left.localeCompare(right));
}
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ConfigSnapshot, GatewaySessionRow } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { ChatComposerCapabilityHost } from "./chat-composer-capability-host.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
function createContext(configSnapshot: ConfigSnapshot | null): ApplicationContext {
return {
gateway: { snapshot: { hello: null } },
navigate: vi.fn(),
runtimeConfig: {
ensureLoaded: vi.fn(async () => undefined),
state: { configLoading: false, configSnapshot },
},
} as unknown as ApplicationContext;
}
function createState(): ChatPageHost {
return {
basePath: "",
client: {} as GatewayBrowserClient,
connected: true,
sessionKey: "main",
} as ChatPageHost;
}
describe("ChatComposerCapabilityHost", () => {
it("blocks session mutations until the row and runtime config have loaded", () => {
const host = new ChatComposerCapabilityHost(vi.fn());
const context = createContext(null);
const state = createState();
const session = { key: "main" } as GatewaySessionRow;
expect(host.props(context, state, undefined, "main").mutationBlockedReason).toBe("Loading…");
expect(host.props(context, state, session, "main").mutationBlockedReason).toBe("Loading…");
context.runtimeConfig.state.configSnapshot = {
runtimeConfig: { tools: { web: { search: { enabled: false } } } },
};
const props = host.props(context, state, session, "main");
expect(props.mutationBlockedReason).toBeNull();
expect(props.webSearchBaseEnabled).toBe(false);
});
it("derives capability defaults from the active runtime snapshot", () => {
const host = new ChatComposerCapabilityHost(vi.fn());
const props = host.props(
createContext({
sourceConfig: {
mcp: { servers: { source: { command: "source-mcp", enabled: true } } },
tools: { web: { search: { enabled: false } } },
},
runtimeConfig: {
mcp: { servers: { runtime: { command: "runtime-mcp", enabled: false } } },
tools: { web: { search: { enabled: true } } },
},
}),
createState(),
{ key: "main" } as GatewaySessionRow,
"main",
);
expect(props.mcpServers.map(({ name, enabled }) => ({ name, enabled }))).toEqual([
{ name: "runtime", enabled: false },
]);
expect(props.webSearchBaseEnabled).toBe(true);
});
});
@@ -0,0 +1,209 @@
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow, SkillStatusEntry } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { readGatewayOperatorAccess } from "../../app/operator-access.ts";
import { t } from "../../i18n/index.ts";
import { summarizeMcpServers } from "../../lib/config/mcp-servers.ts";
import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
import type { SessionToolOverrides } from "../../lib/sessions/patch.ts";
import { loadSkillStatusReport } from "../../lib/skills/index.ts";
import { refreshCurrentChatSessionList } from "./chat-session.ts";
import { patchChatSessionSettings } from "./chat-settings-patches.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
import type {
ChatComposerMenuSkill,
ChatComposerPlusMenuProps,
} from "./components/chat-composer-plus-menu.ts";
type CapabilityMenuProps = Omit<
ChatComposerPlusMenuProps,
| "attachments"
| "disabled"
| "open"
| "view"
| "toolOverrides"
| "onOpenChange"
| "onViewChange"
| "showCapabilities"
>;
function webSearchBaseEnabled(config: Record<string, unknown> | null): boolean {
return asRecord(asRecord(asRecord(config?.tools)?.web)?.search)?.enabled !== false;
}
function toComposerSkill(skill: SkillStatusEntry): ChatComposerMenuSkill {
const missingDeps = Object.values(skill.missing).some((values) => values.length > 0);
const blocked = skill.blockedByAllowlist || skill.blockedByAgentFilter === true;
const baseEnabled = !skill.disabled;
return {
key: skill.skillKey,
name: skill.name,
enabled: baseEnabled && !missingDeps && !blocked,
baseEnabled,
...(missingDeps ? { missingDeps: true } : {}),
...(blocked ? { blocked: true } : {}),
};
}
export class ChatComposerCapabilityHost {
private readonly skills = new Map<string, ChatComposerMenuSkill[]>();
private readonly loading = new Set<string>();
private readonly loadErrors = new Set<string>();
private readonly patchTokens = new Map<string, symbol>();
private client: GatewayBrowserClient | null = null;
constructor(private readonly notify: () => void) {}
private loadSkills(context: ApplicationContext, state: ChatPageHost, agentId: string): void {
const config = context.runtimeConfig.state;
if (!config.configSnapshot && !config.configLoading) {
void context.runtimeConfig.ensureLoaded().catch(() => undefined);
}
const client = state.client;
if (!state.connected || !client || this.skills.has(agentId) || this.loading.has(agentId)) {
return;
}
this.loadErrors.delete(agentId);
this.loading.add(agentId);
this.notify();
void loadSkillStatusReport(client, agentId)
.then((report) => {
if (report && state.client === client && this.client === client) {
this.skills.set(
agentId,
report.skills
.map(toComposerSkill)
.toSorted((left, right) => left.name.localeCompare(right.name)),
);
}
})
.catch(() => {
if (state.client === client && this.client === client) {
this.loadErrors.add(agentId);
}
})
.finally(() => {
if (this.client === client) {
this.loading.delete(agentId);
this.notify();
}
});
}
private patch(
context: ApplicationContext,
state: ChatPageHost,
next: SessionToolOverrides | null,
): void {
if (
!state.connected ||
!state.client ||
!readGatewayOperatorAccess(context.gateway.snapshot).canWrite ||
this.patchTokens.has(state.sessionKey)
) {
return;
}
const sessionKey = state.sessionKey;
const client = state.client;
const patchToken = Symbol(sessionKey);
this.patchTokens.set(sessionKey, patchToken);
const isCurrentPatch = () =>
this.client === client &&
state.client === client &&
state.sessionKey === sessionKey &&
this.patchTokens.get(sessionKey) === patchToken;
state.lastError = null;
state.chatError = null;
this.notify();
void patchChatSessionSettings(
state,
sessionKey,
{ toolOverrides: next },
{
...scopedAgentParamsForSession(state, sessionKey),
},
)
.catch(async (error: unknown) => {
if (!isCurrentPatch()) {
return;
}
try {
await refreshCurrentChatSessionList(state);
} catch {
// The unchanged session row remains authoritative if refresh also fails.
}
if (!isCurrentPatch()) {
return;
}
state.lastError = error instanceof Error ? error.message : String(error);
state.chatError = state.lastError;
state.requestUpdate?.();
})
.finally(() => {
if (this.patchTokens.get(sessionKey) === patchToken) {
this.patchTokens.delete(sessionKey);
if (this.client === client) {
this.notify();
}
}
});
}
props(
context: ApplicationContext,
state: ChatPageHost,
session: GatewaySessionRow | undefined,
agentId: string,
): CapabilityMenuProps {
if (this.client !== state.client) {
this.client = state.client;
this.skills.clear();
this.loading.clear();
this.loadErrors.clear();
this.patchTokens.clear();
}
// Sparse session overrides resolve against active runtime defaults, so display and key
// removal decisions must use the same runtime snapshot that executes the session.
const runtimeConfig = context.runtimeConfig.state.configSnapshot?.runtimeConfig ?? null;
const access = readGatewayOperatorAccess(context.gateway.snapshot);
const gatewayAvailable = state.connected && Boolean(state.client);
const capabilitiesReady = gatewayAvailable && session !== undefined && runtimeConfig !== null;
const mutationBlockedReason = !gatewayAvailable
? t("chat.composer.menu.offlineBlocked")
: !capabilitiesReady
? t("common.loading")
: !access.canWrite
? t("chat.composer.menu.readOnlyBlocked")
: this.patchTokens.has(state.sessionKey)
? t("chat.composer.menu.savingBlocked")
: null;
const adminBlockedReason = !gatewayAvailable
? t("chat.composer.menu.offlineBlocked")
: !access.canAdmin
? t("chat.composer.menu.adminBlocked")
: null;
return {
basePath: state.basePath,
skills:
this.skills.get(agentId)?.map((skill) =>
Object.assign({}, skill, {
enabled:
skill.missingDeps || skill.blocked
? false
: (session?.toolOverrides?.skills?.[skill.key] ?? skill.baseEnabled),
}),
) ?? null,
skillsLoading: this.loading.has(agentId),
skillsError: this.loadErrors.has(agentId),
mcpServers: summarizeMcpServers(runtimeConfig) ?? [],
webSearchBaseEnabled: webSearchBaseEnabled(runtimeConfig),
mutationBlockedReason,
canAdmin: access.canAdmin && gatewayAvailable,
adminBlockedReason,
onLoadSkills: () => this.loadSkills(context, state, agentId),
onPatchToolOverrides: (next) => this.patch(context, state, next),
onNavigate: (routeId, options) => context.navigate(routeId, options),
};
}
}
+4
View File
@@ -40,6 +40,7 @@ import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import type { BoardChatDockSize } from "./board-session-surface.ts";
import { ChatComposerCapabilityHost } from "./chat-composer-capability-host.ts";
import type { ChatHistoryPagination } from "./chat-history-pagination.ts";
import { sendSessionObserverVisibility } from "./chat-observer.ts";
import {
@@ -103,6 +104,9 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
@property({ attribute: false }) boardProvider?: BoardProvider;
protected readonly chatState = new ChatStateController<ChatPageHost>(this);
protected readonly composerCapabilities = new ChatComposerCapabilityHost(() =>
this.requestUpdate(),
);
protected readonly transcript = new ChatTranscriptController(this);
protected readonly questionPromptState = createQuestionPromptState(() => {
this.questionPrompts = listQuestionPrompts(this.questionPromptState);
+18 -18
View File
@@ -108,18 +108,18 @@ export class ChatPane extends ChatPaneHeader {
layout: state.sidebarLayout,
paneWidth: this.paneWidth,
});
const runtimeConfigState = this.context.runtimeConfig.state;
const configSnapshot = runtimeConfigState.configSnapshot;
const serverQueueMode = resolveControlUiServerQueueMode(configSnapshot?.runtimeConfig, {
configNeedsApply: runtimeConfigState.configNeedsApply,
effectiveMode: state.chatEffectiveQueueMode,
sessionMetadataLoaded:
selectedSession !== undefined || state.chatEffectiveQueueMode !== undefined,
sessionMode: state.chatQueueModeOverride,
});
state.chatFollowUpMode = resolveControlUiFollowUpMode(
state.settings.chatFollowUpMode,
serverQueueMode,
resolveControlUiServerQueueMode(
this.context.runtimeConfig.state.configSnapshot?.runtimeConfig,
{
configNeedsApply: this.context.runtimeConfig.state.configNeedsApply,
effectiveMode: state.chatEffectiveQueueMode,
sessionMetadataLoaded:
selectedSession !== undefined || state.chatEffectiveQueueMode !== undefined,
sessionMode: state.chatQueueModeOverride,
},
),
);
const currentAgentId = resolveChatAgentId(state);
const catalogKey = parseCatalogSessionKey(state.sessionKey);
@@ -191,10 +191,9 @@ export class ChatPane extends ChatPaneHeader {
column.panels.some((panel) => panel.slot === "chat"),
);
const sidebarRegionCollapsed = isSidebarRegionCollapsed(sidebarLayout, this.paneWidth);
const primaryWidth = sidebarPrimaryWidth(sidebarLayout, this.paneWidth);
const chatLayoutWidth = sidebarRegionCollapsed
? this.paneWidth
: (sidebarChatColumn?.width ?? primaryWidth);
: (sidebarChatColumn?.width ?? sidebarPrimaryWidth(sidebarLayout, this.paneWidth));
const sessionWorkspace = createSessionWorkspaceProps(state, {
draftScope: this.paneId,
narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
@@ -203,8 +202,7 @@ export class ChatPane extends ChatPaneHeader {
!sessionWorkspace.collapsed &&
!sessionWorkspace.narrowLayout &&
sessionWorkspace.dock !== "bottom";
// The workspace rail claims the side slot first; the tasks rail needs
// room for both columns before it may side-dock next to it.
// The workspace rail claims the first side slot; tasks need room for both columns.
const backgroundTasks = createBackgroundTasksProps(state, {
narrowLayout:
chatLayoutWidth <
@@ -214,8 +212,7 @@ export class ChatPane extends ChatPaneHeader {
},
});
const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout;
// Side-docked workspace surfaces narrow the conversation region; bottom
// strips do not affect whether the session rail can dock beside it.
// Only side-docked rails narrow the conversation region.
const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0);
const chatMainWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
const selectedSessionRailMode =
@@ -231,7 +228,6 @@ export class ChatPane extends ChatPaneHeader {
activeRunIds: selectedSession?.activeRunIds,
usageByRun: state.chatRunUsageById,
});
const loadSidebarFullMessage = createSidebarFullMessageLoader(state, Boolean(catalogKey));
const projectionRunId = resolveChatProjectionRunId({
localRunId: state.chatRunId,
activeRunIds: selectedSession?.activeRunIds,
@@ -371,6 +367,10 @@ export class ChatPane extends ChatPaneHeader {
}
: undefined,
sessions: state.sessionsResult,
toolOverrides: selectedSession?.toolOverrides,
capabilityMenu: catalogKey
? undefined
: this.composerCapabilities.props(this.context, state, selectedSession, currentAgentId),
swarmSessions: this.swarmHydrator?.rows ?? [],
sessionHost: {
assistantAgentId: state.assistantAgentId,
@@ -615,7 +615,7 @@ export class ChatPane extends ChatPaneHeader {
detail: html`<openclaw-chat-detail-panel
class="chat-sidebar"
.content=${state.sidebarContent}
.loadFullMessage=${loadSidebarFullMessage}
.loadFullMessage=${createSidebarFullMessageLoader(state, Boolean(catalogKey))}
.canvasPluginSurfaceUrl=${state.canvasPluginSurfaceUrl}
.embedSandboxMode=${state.embedSandboxMode}
.allowExternalEmbedUrls=${state.allowExternalEmbedUrls}
+1 -1
View File
@@ -112,7 +112,7 @@ function trackPendingChatSettingsPatch(
export function patchChatSessionSettings(
host: ChatPickerPatchHost,
sessionKey: string,
patch: Pick<SessionPatch, "model" | "thinkingLevel" | "fastMode">,
patch: Pick<SessionPatch, "model" | "thinkingLevel" | "fastMode" | "toolOverrides">,
options: {
agentId?: string;
reconcile?: (result: SessionsPatchResult) => Promise<void> | void;
+7
View File
@@ -4167,6 +4167,13 @@ describe("chat attachment picker", () => {
expect(clickInput).toHaveBeenCalledTimes(1);
});
it("keeps attachment-only composers free of capability rows", () => {
const container = renderChatView();
expect(container.querySelectorAll(".agent-chat__attach-menu-option")).toHaveLength(3);
expect(container.querySelector(".agent-chat__capability-menu-item")).toBeNull();
});
it("opens the camera input from the attachment menu and attaches the captured photo", async () => {
const onAttachmentsChange = vi.fn();
const container = renderChatView({ onAttachmentsChange });
+16
View File
@@ -31,6 +31,7 @@ import type {
import type { ControlUiFollowUpMode } from "../../lib/chat/follow-up-mode.ts";
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary.ts";
import type { SessionToolOverrides } from "../../lib/sessions/patch.ts";
import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts";
import type { ChatRunStartupStatus } from "./chat-run-startup.ts";
import type { ChatSessionCompanionThread } from "./chat-session-companion.ts";
@@ -40,6 +41,7 @@ import {
renderBackgroundTasksRail,
type BackgroundTasksProps,
} from "./components/chat-background-tasks.ts";
import type { ChatComposerPlusMenuProps } from "./components/chat-composer-plus-menu.ts";
import type { ChatComposerDisabledBanner } from "./components/chat-composer-types.ts";
import { isChatRunWorking, renderChatComposer } from "./components/chat-composer.ts";
import { inlineChatImageFromEvent, openInlineChatImage } from "./components/chat-image-lightbox.ts";
@@ -160,6 +162,18 @@ export type ChatProps = {
workspaceConflict?: WorkspaceResultConflict;
onDismissWorkspaceConflict?: () => void;
sessions: SessionsListResult | null;
toolOverrides?: SessionToolOverrides;
capabilityMenu?: Omit<
ChatComposerPlusMenuProps,
| "attachments"
| "disabled"
| "open"
| "view"
| "toolOverrides"
| "onOpenChange"
| "onViewChange"
| "showCapabilities"
>;
swarmSessions?: readonly GatewaySessionRow[];
/** Host context resolving global-alias session keys (scope=global fleets). */
sessionHost?: UiSessionDefaultsHost | null;
@@ -404,6 +418,8 @@ export function renderChat(props: ChatProps) {
queue: props.queue,
draft: props.draft,
sessions: props.sessions,
toolOverrides: props.toolOverrides,
capabilityMenu: props.capabilityMenu,
providerUsage: props.providerUsage,
assistantName: props.assistantName,
sendShortcut: props.sendShortcut,
@@ -24,7 +24,7 @@ const PASTED_TEXT_PREVIEW_MAX_LENGTH = 20;
const largePastedTextAttachments = new WeakSet<ChatAttachment>();
const pastedTextPreviews = new WeakMap<ChatAttachment, string>();
type ChatAttachmentControlsProps = {
export type ChatAttachmentControlsProps = {
attachments?: ChatAttachment[];
disabled?: boolean;
getAttachments?: () => ChatAttachment[];
@@ -100,7 +100,7 @@ function currentAttachments(props: ChatAttachmentControlsProps): ChatAttachment[
return props.getAttachments?.() ?? props.attachments ?? [];
}
function clickComposerInput(target: HTMLElement, selector: string) {
export function clickComposerInput(target: HTMLElement, selector: string) {
target.closest("details")?.removeAttribute("open");
target
.closest(".agent-chat__composer-shell, .new-session-page__composer")
@@ -0,0 +1,445 @@
import "@awesome.me/webawesome/dist/components/switch/switch.js";
import { html, nothing, type TemplateResult } from "lit";
import { pathForPluginsHubTab, pathForRoute } from "../../../app-route-paths.ts";
import type { ApplicationNavigationOptions } from "../../../app/context.ts";
import { icons } from "../../../components/icons.ts";
import "../../../components/tooltip.ts";
import "../../../components/web-awesome.ts";
import { t } from "../../../i18n/index.ts";
import type { McpServerSummary } from "../../../lib/config/mcp-servers.ts";
import type { SessionToolOverrides } from "../../../lib/sessions/patch.ts";
import {
nextBooleanToolOverrides,
nextWebSearchToolOverrides,
resolveToolOverrideState,
} from "../../../lib/sessions/tool-overrides.ts";
import { clickComposerInput, type ChatAttachmentControlsProps } from "./chat-attachments.ts";
export type ChatComposerPlusMenuView = "root" | "skills" | "connectors" | `tools:${string}`;
export type ChatComposerMenuSkill = {
key: string;
name: string;
enabled: boolean;
baseEnabled: boolean;
missingDeps?: boolean;
blocked?: boolean;
};
type MenuRoute = "mcp" | "plugins" | "skills";
export type ChatComposerPlusMenuProps = {
attachments: ChatAttachmentControlsProps;
showCapabilities: boolean;
basePath: string;
disabled: boolean;
open: boolean;
view: ChatComposerPlusMenuView;
toolOverrides: SessionToolOverrides | null | undefined;
skills: readonly ChatComposerMenuSkill[] | null;
skillsLoading: boolean;
skillsError: boolean;
mcpServers: readonly McpServerSummary[];
webSearchBaseEnabled: boolean;
mutationBlockedReason: string | null;
canAdmin: boolean;
adminBlockedReason: string | null;
onOpenChange: (open: boolean) => void;
onViewChange: (view: ChatComposerPlusMenuView) => void;
onLoadSkills: () => void;
onPatchToolOverrides: (next: SessionToolOverrides | null) => void;
onNavigate: (routeId: MenuRoute, options?: ApplicationNavigationOptions) => void;
onAddServer?: () => void;
onOpenToolAccess?: (serverName: string) => void;
};
function menuDivider(): TemplateResult {
return html`<div class="agent-chat__capability-menu-divider" role="separator"></div>`;
}
function internalLink(href: string, label: string): TemplateResult {
return html`<a
class="agent-chat__capability-menu-link"
href=${href}
tabindex="-1"
@click=${(event: MouseEvent) => event.preventDefault()}
>${label}</a
>`;
}
function renderBackRow() {
return html`
<wa-dropdown-item class="agent-chat__capability-menu-item" value="back">
<span slot="icon" aria-hidden="true">${icons.arrowLeft}</span>
<span>${t("chat.composer.menu.back")}</span>
</wa-dropdown-item>
${menuDivider()}
`;
}
function renderRootView(props: ChatComposerPlusMenuProps) {
const connectorCount = props.mcpServers.filter((server) =>
resolveToolOverrideState(server.enabled, props.toolOverrides?.mcpServers?.[server.name]),
).length;
const hasSkillOverrides = Object.keys(props.toolOverrides?.skills ?? {}).length > 0;
const enabledSkillCount = props.skills?.filter((skill) => skill.enabled).length ?? 0;
const webSearchEnabled = resolveToolOverrideState(
props.webSearchBaseEnabled,
props.toolOverrides?.webSearch,
);
const attachments = html`
<wa-dropdown-item class="agent-chat__attach-menu-option" value="camera">
<span slot="icon" aria-hidden="true">${icons.camera}</span>
<span>${t("chat.composer.takePhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="photo">
<span slot="icon" aria-hidden="true">${icons.image}</span>
<span>${t("chat.composer.attachPhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="file">
<span slot="icon" aria-hidden="true">${icons.paperclip}</span>
<span>${t("chat.composer.attachFileOption")}</span>
</wa-dropdown-item>
`;
if (!props.showCapabilities) {
return attachments;
}
// Core gates managed and Codex-native search. Config sniffing misses env/native providers;
// without a provider, this session override is a harmless no-op.
return html`
${attachments} ${menuDivider()}
<wa-dropdown-item class="agent-chat__capability-menu-item" value="open-skills">
<span slot="icon" aria-hidden="true">${icons.book}</span>
<span>${t("chat.composer.menu.skills")}</span>
<span slot="details" class="agent-chat__capability-menu-details">
${hasSkillOverrides
? html`<span class="agent-chat__capability-menu-badge"
>${t("chat.composer.menu.enabledCount", {
count: String(enabledSkillCount),
})}</span
>`
: nothing}
<span class="agent-chat__capability-menu-chevron" aria-hidden="true"
>${icons.chevronRight}</span
>
</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__capability-menu-item" value="open-connectors">
<span slot="icon" aria-hidden="true">${icons.puzzle}</span>
<span>${t("chat.composer.menu.connectors")}</span>
<span slot="details" class="agent-chat__capability-menu-details">
<span class="agent-chat__capability-menu-badge">${connectorCount}</span>
<span class="agent-chat__capability-menu-chevron" aria-hidden="true"
>${icons.chevronRight}</span
>
</span>
</wa-dropdown-item>
<wa-dropdown-item
class="agent-chat__capability-menu-item"
type="checkbox"
value="toggle-web-search"
.checked=${webSearchEnabled}
?disabled=${props.mutationBlockedReason !== null}
title=${props.mutationBlockedReason ?? ""}
>
<span slot="icon" aria-hidden="true">${icons.globe}</span>
<span>${t("chat.composer.menu.webSearch")}</span>
</wa-dropdown-item>
${menuDivider()}
<wa-dropdown-item class="agent-chat__capability-menu-item" value="manage-plugins">
<span slot="icon" aria-hidden="true">${icons.puzzle}</span>
${internalLink(
pathForRoute("plugins", props.basePath),
t("chat.composer.menu.managePlugins"),
)}
</wa-dropdown-item>
`;
}
function renderSkillView(props: ChatComposerPlusMenuProps) {
const disabledReason = props.mutationBlockedReason;
const rows = props.skillsLoading
? html`<div class="agent-chat__capability-menu-state" role="status">
${t("chat.composer.menu.loadingSkills")}
</div>`
: props.skillsError
? html`<div class="agent-chat__capability-menu-state" role="alert">
${t("chat.composer.menu.skillsLoadFailed")}
</div>`
: !props.skills || props.skills.length === 0
? html`<div class="agent-chat__capability-menu-state">
${t("chat.composer.menu.noSkills")}
</div>`
: props.skills.map((skill, index) => {
const rowDisabled = skill.missingDeps || skill.blocked || disabledReason !== null;
const title = skill.missingDeps
? t("chat.composer.menu.depsMissing")
: skill.blocked
? t("chat.composer.menu.skillBlocked")
: disabledReason;
return html`
<wa-dropdown-item
class="agent-chat__capability-menu-item agent-chat__capability-menu-toggle"
value=${`skill:${index}`}
?disabled=${rowDisabled}
title=${title ?? ""}
>
<span class="agent-chat__capability-menu-label">
<span>${skill.name}</span>
${skill.missingDeps
? html`<span class="agent-chat__capability-menu-note"
>${t("chat.composer.menu.depsMissing")}</span
>`
: skill.blocked
? html`<span class="agent-chat__capability-menu-note"
>${t("chat.composer.menu.skillBlocked")}</span
>`
: nothing}
</span>
<wa-switch
slot="details"
class="agent-chat__capability-menu-switch"
size="s"
tabindex="-1"
.checked=${skill.enabled}
?disabled=${rowDisabled}
aria-label=${skill.name}
></wa-switch>
</wa-dropdown-item>
`;
});
return html`
${renderBackRow()} ${rows} ${menuDivider()}
<wa-dropdown-item class="agent-chat__capability-menu-item" value="manage-skills">
${internalLink(pathForRoute("skills", props.basePath), t("chat.composer.menu.manageSkills"))}
</wa-dropdown-item>
`;
}
function renderConnectorView(props: ChatComposerPlusMenuProps) {
const disabledReason = props.mutationBlockedReason;
const rows =
props.mcpServers.length === 0
? html`<div class="agent-chat__capability-menu-state">
${t("chat.composer.menu.noConnectors")}
</div>`
: props.mcpServers.map((server, index) => {
const override = props.toolOverrides?.mcpServers?.[server.name];
const enabled = resolveToolOverrideState(server.enabled, override);
return html`
<wa-dropdown-item
class="agent-chat__capability-menu-item agent-chat__capability-menu-toggle"
value=${`connector:${index}`}
?disabled=${disabledReason !== null}
title=${disabledReason ?? ""}
>
<span class="agent-chat__capability-menu-label">
<span>${server.name}</span>
<span class="agent-chat__capability-menu-note">
${enabled ? t("common.enabled") : t("common.disabled")}
${override !== undefined
? html`<span class="agent-chat__capability-menu-session-tag"
>${t("chat.composer.menu.sessionTag")}</span
>`
: nothing}
</span>
</span>
<wa-switch
slot="details"
class="agent-chat__capability-menu-switch"
size="s"
tabindex="-1"
.checked=${enabled}
?disabled=${disabledReason !== null}
aria-label=${server.name}
></wa-switch>
</wa-dropdown-item>
${props.onOpenToolAccess
? html`<wa-dropdown-item
class="agent-chat__capability-menu-item agent-chat__capability-menu-subrow"
value=${`tools:${index}`}
>
<span slot="icon" aria-hidden="true">${icons.wrench}</span>
<span>${t("chat.composer.menu.toolAccess")}</span>
</wa-dropdown-item>`
: nothing}
`;
});
const adminDisabled = !props.canAdmin;
return html`
${renderBackRow()} ${rows} ${menuDivider()}
${props.onAddServer
? html`<wa-dropdown-item
class="agent-chat__capability-menu-item"
value="add-server"
?disabled=${adminDisabled}
title=${adminDisabled ? (props.adminBlockedReason ?? "") : ""}
>
<span slot="icon" aria-hidden="true">${icons.plus}</span>
<span>${t("chat.composer.menu.addMcpServer")}</span>
</wa-dropdown-item>`
: nothing}
<wa-dropdown-item
class="agent-chat__capability-menu-item"
value="browse-connectors"
?disabled=${adminDisabled}
title=${adminDisabled ? (props.adminBlockedReason ?? "") : ""}
>
<span slot="icon" aria-hidden="true">${icons.search}</span>
${internalLink(
pathForPluginsHubTab("discover", props.basePath),
t("chat.composer.menu.browseConnectors"),
)}
</wa-dropdown-item>
`;
}
function handleMenuSelection(
event: CustomEvent<{ item: { value?: string } }>,
props: ChatComposerPlusMenuProps,
) {
const value = event.detail.item.value ?? "";
const menu = event.currentTarget as HTMLElement;
const changeView = (view: ChatComposerPlusMenuView) => {
props.onViewChange(view);
requestAnimationFrame(() =>
menu.querySelector<HTMLElement>("wa-dropdown-item:not([disabled])")?.focus(),
);
};
if (value === "camera" || value === "photo" || value === "file") {
clickComposerInput(menu, `.agent-chat__${value === "file" ? "file" : value}-input`);
return;
}
if (value === "back") {
event.preventDefault();
changeView("root");
return;
}
if (value === "open-skills" || value === "open-connectors") {
event.preventDefault();
changeView(value === "open-skills" ? "skills" : "connectors");
return;
}
if (value === "toggle-web-search") {
event.preventDefault();
if (props.mutationBlockedReason) {
return;
}
const enabled = resolveToolOverrideState(
props.webSearchBaseEnabled,
props.toolOverrides?.webSearch,
);
props.onPatchToolOverrides(
nextWebSearchToolOverrides(props.toolOverrides, !enabled, props.webSearchBaseEnabled),
);
return;
}
if (value.startsWith("skill:")) {
event.preventDefault();
const skill = props.skills?.[Number(value.slice("skill:".length))];
if (skill && !skill.missingDeps && !skill.blocked && !props.mutationBlockedReason) {
props.onPatchToolOverrides(
nextBooleanToolOverrides(
props.toolOverrides,
"skills",
skill.key,
!skill.enabled,
skill.baseEnabled,
),
);
}
return;
}
if (value.startsWith("connector:")) {
event.preventDefault();
const server = props.mcpServers[Number(value.slice("connector:".length))];
if (server && !props.mutationBlockedReason) {
const enabled = resolveToolOverrideState(
server.enabled,
props.toolOverrides?.mcpServers?.[server.name],
);
props.onPatchToolOverrides(
nextBooleanToolOverrides(
props.toolOverrides,
"mcpServers",
server.name,
!enabled,
server.enabled,
),
);
}
return;
}
if (value.startsWith("tools:")) {
const server = props.mcpServers[Number(value.slice("tools:".length))];
if (server) {
props.onOpenToolAccess?.(server.name);
}
return;
}
if (value === "add-server") {
props.onAddServer?.();
return;
}
if (value === "manage-skills") {
props.onNavigate("skills");
} else if (value === "manage-plugins") {
props.onNavigate("plugins");
} else if (value === "browse-connectors") {
props.onNavigate("plugins", {
pathname: pathForPluginsHubTab("discover", props.basePath),
});
}
}
export function renderChatComposerPlusMenu(props: ChatComposerPlusMenuProps) {
const view = props.showCapabilities ? props.view : "root";
const content =
view === "skills"
? renderSkillView(props)
: view === "connectors"
? renderConnectorView(props)
: renderRootView(props);
return html`
<wa-dropdown
class="agent-chat__attach-menu agent-chat__capability-menu"
placement="top-start"
aria-label=${t("chat.composer.addAttachment")}
.open=${props.open}
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) =>
handleMenuSelection(event, props)}
@wa-show=${() => {
if (!props.open) {
props.onOpenChange(true);
}
props.onLoadSkills();
}}
@wa-hide=${() => {
if (props.open) {
props.onOpenChange(false);
}
}}
data-view=${view}
>
<button
slot="trigger"
type="button"
class="agent-chat__input-btn agent-chat__input-btn--attach"
aria-label=${t("chat.composer.addAttachment")}
?disabled=${props.disabled}
title=${t("chat.composer.addAttachment")}
@pointerdown=${(event: PointerEvent) => {
const composer = (event.currentTarget as HTMLElement)
.closest(".agent-chat__composer-shell")
?.querySelector("textarea");
if (document.activeElement === composer) {
event.preventDefault();
}
}}
>
${icons.plus}
</button>
${content}
</wa-dropdown>
`;
}
@@ -29,6 +29,8 @@ function createChatComposerState(): ChatComposerState {
microphoneDevices: [],
microphoneWarning: null,
microphoneDiscoveryRequest: 0,
capabilityMenuOpen: false,
capabilityMenuView: "root",
textareaRef: null,
dictation: null,
dictationDraftKey: null,
@@ -7,6 +7,7 @@ import type { ChatAttachment, ChatQueueItem } from "../../../lib/chat/chat-types
import type { SlashCommandDef } from "../../../lib/chat/commands.ts";
import type { ControlUiFollowUpMode } from "../../../lib/chat/follow-up-mode.ts";
import type { ProviderUsageDisplayProps } from "../../../lib/provider-quota-summary.ts";
import type { SessionToolOverrides } from "../../../lib/sessions/patch.ts";
import type { ComposerDictationController } from "../composer-dictation.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../input-history.ts";
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
@@ -15,6 +16,10 @@ import type { RealtimeTalkLevelSignal } from "../realtime-talk-level.ts";
import type { RealtimeTalkStatus } from "../realtime-talk.ts";
import type { ChatRunUiStatus } from "../run-lifecycle.ts";
import type { CompactionStatus, FallbackStatus, PlanStatus } from "../tool-stream.ts";
import type {
ChatComposerPlusMenuProps,
ChatComposerPlusMenuView,
} from "./chat-composer-plus-menu.ts";
type ChatComposerDisabledBannerContent = {
text: string;
@@ -49,6 +54,18 @@ export type ChatComposerProps = {
queue: ChatQueueItem[];
draft: string;
sessions: SessionsListResult | null;
toolOverrides?: SessionToolOverrides;
capabilityMenu?: Omit<
ChatComposerPlusMenuProps,
| "attachments"
| "disabled"
| "open"
| "view"
| "toolOverrides"
| "onOpenChange"
| "onViewChange"
| "showCapabilities"
>;
providerUsage?: ProviderUsageDisplayProps;
assistantName: string;
sendShortcut?: ChatSendShortcut;
@@ -140,6 +157,8 @@ export type ChatComposerState = {
microphoneDevices: RealtimeTalkInputDevice[];
microphoneWarning: string | null;
microphoneDiscoveryRequest: number;
capabilityMenuOpen: boolean;
capabilityMenuView: ChatComposerPlusMenuView;
// Stable Lit ref: an inline arrow would change identity per render and force
// a layout re-measure of the textarea on every chat render, not just attach.
textareaRef: ((element?: Element) => void) | null;
@@ -5,13 +5,16 @@ import { ref } from "lit/directives/ref.js";
import type { GatewaySessionRow } from "../../../api/types.ts";
import { icons } from "../../../components/icons.ts";
import { t } from "../../../i18n/index.ts";
import {
countSessionToolOverrides,
sessionToolOverrideNames,
} from "../../../lib/sessions/tool-overrides.ts";
import { detectTextDirection } from "../../../lib/text-direction.ts";
import type { ComposerDictationController } from "../composer-dictation.ts";
import {
handleChatAttachmentPaste,
renderAttachmentPreview,
renderChatAttachmentInputs,
renderChatAttachmentMenu,
} from "./chat-attachments.ts";
import type { ChatRunControlsProps } from "./chat-composer-controls.ts";
import { renderChatPrimaryActions } from "./chat-composer-controls.ts";
@@ -21,6 +24,7 @@ import {
observeQuestionDock,
} from "./chat-composer-dom.ts";
import { renderChatGoal } from "./chat-composer-goal.ts";
import { renderChatComposerPlusMenu } from "./chat-composer-plus-menu.ts";
import { renderChatQueue } from "./chat-composer-queue.ts";
import { renderSlashMenu } from "./chat-composer-slash-menu.ts";
import { commitComposerDraft } from "./chat-composer-state.ts";
@@ -116,6 +120,14 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
`
: nothing;
const showComposerInput = showComposer && props.disabledBanner?.kind !== "composer-replacement";
if (!props.capabilityMenu) {
state.capabilityMenuView = "root";
}
const overrideCount = countSessionToolOverrides(props.toolOverrides);
const overrideTooltip = sessionToolOverrideNames(
props.toolOverrides,
t("chat.composer.menu.webSearch"),
).join(", ");
return html`
${renderChatQueue({
@@ -303,9 +315,38 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
: nothing}
<div class="agent-chat__composer-input-row">
${renderChatAttachmentMenu({
...props,
${renderChatComposerPlusMenu({
attachments: props,
showCapabilities: props.capabilityMenu !== undefined,
basePath: props.capabilityMenu?.basePath ?? "",
disabled: !canCompose || props.suggestionComposer === true,
open: state.capabilityMenuOpen,
view: state.capabilityMenuView,
toolOverrides: props.toolOverrides,
skills: props.capabilityMenu?.skills ?? null,
skillsLoading: props.capabilityMenu?.skillsLoading ?? false,
skillsError: props.capabilityMenu?.skillsError ?? false,
mcpServers: props.capabilityMenu?.mcpServers ?? [],
webSearchBaseEnabled: props.capabilityMenu?.webSearchBaseEnabled ?? true,
mutationBlockedReason: props.capabilityMenu?.mutationBlockedReason ?? null,
canAdmin: props.capabilityMenu?.canAdmin ?? false,
adminBlockedReason: props.capabilityMenu?.adminBlockedReason ?? null,
onOpenChange: (open) => {
state.capabilityMenuOpen = open;
if (!open) {
state.capabilityMenuView = "root";
}
requestUpdate();
},
onViewChange: (view) => {
state.capabilityMenuView = view;
requestUpdate();
},
onLoadSkills: props.capabilityMenu?.onLoadSkills ?? (() => {}),
onPatchToolOverrides: props.capabilityMenu?.onPatchToolOverrides ?? (() => {}),
onNavigate: props.capabilityMenu?.onNavigate ?? (() => {}),
onAddServer: props.capabilityMenu?.onAddServer,
onOpenToolAccess: props.capabilityMenu?.onOpenToolAccess,
})}
<div class="agent-chat__composer-combobox">
<textarea
@@ -380,6 +421,45 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
</div>
`
: nothing}
${overrideCount > 0 && props.capabilityMenu
? html`
<openclaw-tooltip .content=${overrideTooltip}>
<span class="agent-chat__session-overrides-pill">
<button
type="button"
class="agent-chat__session-overrides-open"
@click=${() => {
state.capabilityMenuView = "root";
state.capabilityMenuOpen = true;
requestUpdate();
}}
>
${t(
overrideCount === 1
? "chat.composer.overrides.countOne"
: "chat.composer.overrides.count",
{ count: String(overrideCount) },
)}
</button>
<button
type="button"
class="agent-chat__session-overrides-clear"
aria-label=${t("chat.composer.overrides.clear")}
title=${props.capabilityMenu.mutationBlockedReason ?? ""}
?disabled=${props.capabilityMenu.mutationBlockedReason !== null}
@click=${(event: MouseEvent) => {
event.stopPropagation();
if (!props.capabilityMenu?.mutationBlockedReason) {
props.capabilityMenu?.onPatchToolOverrides(null);
}
}}
>
${icons.x}
</button>
</span>
</openclaw-tooltip>
`
: nothing}
${composerControls}
</div>
`
+138
View File
@@ -2644,6 +2644,144 @@ openclaw-chat-page {
stroke-linejoin: round;
}
.agent-chat__capability-menu::part(menu) {
width: 292px;
min-width: 292px;
max-width: min(292px, calc(100vw - 24px));
}
.agent-chat__capability-menu-item,
.agent-chat__capability-menu-state {
color: var(--text);
font-size: 12px;
}
.agent-chat__capability-menu-item::part(base),
.agent-chat__attach-menu-option::part(base) {
min-height: 34px;
}
.agent-chat__capability-menu-item svg,
.agent-chat__capability-menu-state svg {
width: 16px;
height: 16px;
flex: 0 0 16px;
}
.agent-chat__capability-menu-divider {
height: 1px;
margin: 4px 6px;
background: color-mix(in srgb, var(--border) 72%, transparent);
}
.agent-chat__capability-menu-details {
display: inline-flex;
align-items: center;
gap: 7px;
}
.agent-chat__capability-menu-badge,
.agent-chat__capability-menu-session-tag {
display: inline-flex;
align-items: center;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--muted);
font-size: 10px;
line-height: 1;
}
.agent-chat__capability-menu-badge {
min-height: 18px;
padding: 0 6px;
}
.agent-chat__capability-menu-session-tag {
margin-left: 5px;
padding: 3px 5px;
}
.agent-chat__capability-menu-chevron {
display: inline-flex;
color: var(--muted);
}
.agent-chat__capability-menu-label {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.agent-chat__capability-menu-note,
.agent-chat__capability-menu-state {
color: var(--muted);
}
.agent-chat__capability-menu-state {
padding: 10px 12px;
line-height: 1.4;
}
.agent-chat__capability-menu-switch {
pointer-events: none;
}
.agent-chat__capability-menu-subrow::part(base) {
padding-left: 28px;
}
.agent-chat__capability-menu-link {
display: block;
width: 100%;
color: inherit;
text-decoration: none;
}
.agent-chat__session-overrides-pill {
display: inline-flex;
order: 2;
align-items: stretch;
flex: 0 0 auto;
min-height: 28px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.agent-chat__session-overrides-pill button {
border: 0;
background: transparent;
color: var(--text);
font: inherit;
cursor: pointer;
}
.agent-chat__session-overrides-open {
padding: 0 8px 0 10px;
font-size: 11px;
}
.agent-chat__session-overrides-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
padding: 0;
color: var(--muted);
}
.agent-chat__session-overrides-pill button:hover {
background: color-mix(in srgb, var(--text) 7%, transparent);
}
.agent-chat__session-overrides-clear svg {
width: 13px;
height: 13px;
}
/* Selected/open states stay a quiet neutral wash; only live recording gets a
(soft) danger tint so "active" chrome never competes with the send button. */
.agent-chat__input-btn--active,
+8 -1
View File
@@ -820,7 +820,14 @@ function installControlUiMockGateway(
return;
}
const patch = { ...sessionPatches.get(params.key) };
for (const key of ["model", "thinkingLevel", "fastMode", "category", "pinned"] as const) {
for (const key of [
"model",
"thinkingLevel",
"fastMode",
"category",
"pinned",
"toolOverrides",
] as const) {
if (hasOwn(params, key)) {
patch[key] = params[key];
}