mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
77e9ee092f
commit
f27899181e
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: "Couldn’t 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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user